diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/Guardrails.node.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/Guardrails.node.ts index c0f3a385d72..c4eb34ce0ae 100644 --- a/packages/@n8n/nodes-langchain/nodes/Guardrails/Guardrails.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/Guardrails.node.ts @@ -1,50 +1,43 @@ -import type { IExecuteFunctions, INodeExecutionData, INodeType } from 'n8n-workflow'; +import { + VersionedNodeType, + type INodeTypeBaseDescription, + type IVersionedNodeType, +} from 'n8n-workflow'; -import { process } from './actions/process'; -import { versionDescription } from './description'; -import { getChatModel } from './helpers/model'; +import { GuardrailsV1 } from './v1/GuardrailsV1.node'; +import { GuardrailsV2 } from './v2/GuardrailsV2.node'; -export class Guardrails implements INodeType { - description = versionDescription; +export class Guardrails extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Guardrails', + name: 'guardrails', + icon: 'file:guardrails.svg', + group: ['transform'], + defaultVersion: 2, + description: + 'Safeguard AI models from malicious input or prevent them from generating undesirable responses', + codex: { + alias: ['LangChain', 'Guardrails', 'PII', 'Secret', 'Injection', 'Sanitize'], + categories: ['AI'], + subcategories: { + AI: ['Agents', 'Miscellaneous', 'Root Nodes'], + }, + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.guardrails/', + }, + ], + }, + }, + }; - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - const operation = this.getNodeParameter('operation', 0) as 'classify' | 'sanitize'; - const model = operation === 'classify' ? await getChatModel.call(this) : null; + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new GuardrailsV1(baseDescription), + 2: new GuardrailsV2(baseDescription), + }; - const failedItems: INodeExecutionData[] = []; - const passedItems: INodeExecutionData[] = []; - for (let i = 0; i < items.length; i++) { - try { - const responseData = await process.call(this, i, model); - if (responseData.passed) { - passedItems.push({ - json: { guardrailsInput: responseData.guardrailsInput, ...responseData.passed }, - pairedItem: { item: i }, - }); - } - if (responseData.failed) { - failedItems.push({ - json: { guardrailsInput: responseData.guardrailsInput, ...responseData.failed }, - pairedItem: { item: i }, - }); - } - } catch (error) { - if (this.continueOnFail()) { - failedItems.push({ - json: { error: error.message, guardrailsInput: '' }, - pairedItem: { item: i }, - }); - } else { - throw error; - } - } - } - - if (operation === 'classify') { - return [passedItems, failedItems]; - } - - return [passedItems]; + super(nodeVersions, baseDescription); } } diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/actions/execute.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/actions/execute.ts new file mode 100644 index 00000000000..d0465129151 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/actions/execute.ts @@ -0,0 +1,49 @@ +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; + +import { process } from './process'; +import type { GuardrailsOptions } from './types'; +import { hasLLMGuardrails } from '../helpers/configureNodeInputs'; +import { getChatModel } from '../helpers/model'; + +export async function execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const operation = this.getNodeParameter('operation', 0) as 'classify' | 'sanitize'; + const model = hasLLMGuardrails(this.getNodeParameter('guardrails', 0) as GuardrailsOptions) + ? await getChatModel.call(this) + : null; + + const failedItems: INodeExecutionData[] = []; + const passedItems: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const responseData = await process.call(this, i, model); + if (responseData.passed) { + passedItems.push({ + json: { guardrailsInput: responseData.guardrailsInput, ...responseData.passed }, + pairedItem: { item: i }, + }); + } + if (responseData.failed) { + failedItems.push({ + json: { guardrailsInput: responseData.guardrailsInput, ...responseData.failed }, + pairedItem: { item: i }, + }); + } + } catch (error) { + if (this.continueOnFail()) { + failedItems.push({ + json: { error: error.message, guardrailsInput: '' }, + pairedItem: { item: i }, + }); + } else { + throw error; + } + } + } + + if (operation === 'classify') { + return [passedItems, failedItems]; + } + + return [passedItems]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/actions/process.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/actions/process.ts index bc70961c407..6019ce89274 100644 --- a/packages/@n8n/nodes-langchain/nodes/Guardrails/actions/process.ts +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/actions/process.ts @@ -80,6 +80,13 @@ export async function process( input: [], }; + const checkModelAvailable = (model: BaseChatModel | null): model is BaseChatModel => { + if (!model) { + throw new NodeOperationError(this.getNode(), 'Chat Model is required'); + } + return true; + }; + if (guardrails.pii?.value) { const { entities } = guardrails.pii.value; stageGuardrails.preflight.push({ @@ -121,10 +128,6 @@ export async function process( } if (operation === 'classify') { - if (!model) { - throw new NodeOperationError(this.getNode(), 'Chat Model is required for classify operation'); - } - if (guardrails.keywords) { stageGuardrails.input.push({ name: 'keywords', @@ -132,7 +135,7 @@ export async function process( }); } - if (guardrails.jailbreak?.value) { + if (guardrails.jailbreak?.value && checkModelAvailable(model)) { const { prompt, threshold } = guardrails.jailbreak.value; stageGuardrails.input.push({ name: 'jailbreak', @@ -145,7 +148,7 @@ export async function process( }); } - if (guardrails.nsfw?.value) { + if (guardrails.nsfw?.value && checkModelAvailable(model)) { const { prompt, threshold } = guardrails.nsfw.value; stageGuardrails.input.push({ name: 'nsfw', @@ -158,7 +161,7 @@ export async function process( }); } - if (guardrails.topicalAlignment?.value) { + if (guardrails.topicalAlignment?.value && checkModelAvailable(model)) { const { prompt, threshold } = guardrails.topicalAlignment.value; stageGuardrails.input.push({ name: 'topicalAlignment', @@ -171,7 +174,7 @@ export async function process( }); } - if (guardrails.custom?.guardrail) { + if (guardrails.custom?.guardrail && checkModelAvailable(model)) { for (const customGuardrail of guardrails.custom.guardrail) { const { prompt, threshold, name } = customGuardrail; stageGuardrails.input.push({ diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/description.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/description.ts index c23636c2185..696315cfc27 100644 --- a/packages/@n8n/nodes-langchain/nodes/Guardrails/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/description.ts @@ -1,11 +1,10 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ -import { NodeConnectionTypes, type INodeProperties, type INodeTypeDescription } from 'n8n-workflow'; +import { type INodeProperties } from 'n8n-workflow'; import { JAILBREAK_PROMPT } from './actions/checks/jailbreak'; import { NSFW_SYSTEM_PROMPT } from './actions/checks/nsfw'; import { PII_NAME_MAP, PIIEntity } from './actions/checks/pii'; import { TOPICAL_ALIGNMENT_SYSTEM_PROMPT } from './actions/checks/topicalAlignment'; -import { configureNodeInputs } from './helpers/configureNodeInputs'; import { LLM_SYSTEM_RULES } from './helpers/model'; const THRESHOLD_OPTION: INodeProperties = { @@ -49,402 +48,364 @@ const wrapValue = (properties: INodeProperties[]) => ({ values: properties, }); -export const versionDescription: INodeTypeDescription = { - displayName: 'Guardrails', - name: 'guardrails', - icon: 'file:guardrails.svg', - group: ['transform'], - version: 1, - description: - 'Safeguard AI models from malicious input or prevent them from generating undesirable responses', - defaults: { - name: 'Guardrails', +export const propertiesDescription: INodeProperties[] = [ + { + displayName: + 'Use guardrails to validate text against a set of policies (e.g. NSFW, prompt injection) or to sanitize it (e.g. personal data, secret keys)', + name: 'guardrailsUsage', + type: 'notice', + default: '', }, - codex: { - alias: ['LangChain', 'Guardrails', 'PII', 'Secret', 'Injection', 'Sanitize'], - categories: ['AI'], - subcategories: { - AI: ['Agents', 'Miscellaneous', 'Root Nodes'], - }, - resources: { - primaryDocumentation: [ - { - url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.guardrails/', - }, - ], - }, - }, - inputs: `={{(${configureNodeInputs})($parameter.operation)}}`, - outputs: `={{ - ((parameters) => { - const operation = parameters.operation ?? 'classify'; - - if (operation === 'classify') { - return [{displayName: "Pass", type: "${NodeConnectionTypes.Main}"}, {displayName: "Fail", type: "${NodeConnectionTypes.Main}"}] - } - - return [{ displayName: "", type: "${NodeConnectionTypes.Main}"}] - })($parameter) - }}`, - properties: [ - { - displayName: - 'Use guardrails to validate text against a set of policies (e.g. NSFW, prompt injection) or to sanitize it (e.g. personal data, secret keys)', - name: 'guardrailsUsage', - type: 'notice', - default: '', - }, - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Check Text for Violations', - value: 'classify', - action: 'Check text for violations', - description: 'Validate text against a set of policies (e.g. NSFW, prompt injection)', - }, - { - name: 'Sanitize Text', - value: 'sanitize', - action: 'Sanitize text', - // eslint-disable-next-line n8n-nodes-base/node-param-description-excess-final-period - description: 'Redact text to mask personal data, secret keys, URLs, etc.', - }, - ], - default: 'classify', - }, - { - displayName: 'Text To Check', - name: 'text', - type: 'string', - required: true, - default: '', - typeOptions: { - rows: 1, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Check Text for Violations', + value: 'classify', + action: 'Check text for violations', + description: 'Validate text against a set of policies (e.g. NSFW, prompt injection)', }, + { + name: 'Sanitize Text', + value: 'sanitize', + action: 'Sanitize text', + // eslint-disable-next-line n8n-nodes-base/node-param-description-excess-final-period + description: 'Redact text to mask personal data, secret keys, URLs, etc.', + }, + ], + default: 'classify', + }, + { + displayName: 'Text To Check', + name: 'text', + type: 'string', + required: true, + default: '', + typeOptions: { + rows: 1, }, - { - displayName: 'Guardrails', - name: 'guardrails', - placeholder: 'Add Guardrail', - type: 'collection', - default: {}, - options: [ - { - displayName: 'Keywords', - name: 'keywords', - type: 'string', - default: '', - description: - 'This guardrail checks if specified keywords appear in the input text and can be configured to trigger tripwires based on keyword matches. Multiple keywords can be added separated by comma.', - displayOptions: { - show: { - '/operation': ['classify'], - }, + }, + { + displayName: 'Guardrails', + name: 'guardrails', + placeholder: 'Add Guardrail', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Keywords', + name: 'keywords', + type: 'string', + default: '', + description: + 'This guardrail checks if specified keywords appear in the input text and can be configured to trigger tripwires based on keyword matches. Multiple keywords can be added separated by comma.', + displayOptions: { + show: { + '/operation': ['classify'], }, }, - { - displayName: 'Jailbreak', - name: 'jailbreak', - type: 'fixedCollection', - default: { value: { threshold: 0.7 } }, - description: 'Detects attempts to jailbreak or bypass AI safety measures', - options: [wrapValue([THRESHOLD_OPTION, ...getPromptOption(JAILBREAK_PROMPT)])], - displayOptions: { - show: { - '/operation': ['classify'], - }, + }, + { + displayName: 'Jailbreak', + name: 'jailbreak', + type: 'fixedCollection', + default: { value: { threshold: 0.7 } }, + description: 'Detects attempts to jailbreak or bypass AI safety measures', + options: [wrapValue([THRESHOLD_OPTION, ...getPromptOption(JAILBREAK_PROMPT)])], + displayOptions: { + show: { + '/operation': ['classify'], }, }, - { - displayName: 'NSFW', - name: 'nsfw', - type: 'fixedCollection', - default: { value: { threshold: 0.7 } }, - description: 'Detects attempts to generate NSFW content', - options: [wrapValue([THRESHOLD_OPTION, ...getPromptOption(NSFW_SYSTEM_PROMPT)])], - displayOptions: { - show: { - '/operation': ['classify'], - }, + }, + { + displayName: 'NSFW', + name: 'nsfw', + type: 'fixedCollection', + default: { value: { threshold: 0.7 } }, + description: 'Detects attempts to generate NSFW content', + options: [wrapValue([THRESHOLD_OPTION, ...getPromptOption(NSFW_SYSTEM_PROMPT)])], + displayOptions: { + show: { + '/operation': ['classify'], }, }, - { - displayName: 'Personal Data (PII)', - name: 'pii', - type: 'fixedCollection', - default: { value: { type: 'all' } }, - description: 'Detects attempts to use personal data content', - options: [ - wrapValue([ - { - displayName: 'Type', - name: 'type', - type: 'options', - default: '', - options: [ - { name: 'All', value: 'all' }, - { name: 'Selected', value: 'selected' }, - ], - }, - { - displayName: 'Entities', - name: 'entities', - type: 'multiOptions', - default: [], - displayOptions: { - show: { - type: ['selected'], - }, - }, - options: Object.values(PIIEntity).map((entity) => ({ - name: PII_NAME_MAP[entity], - value: entity, - })), - }, - ]), - ], - }, - { - displayName: 'Secret Keys', - name: 'secretKeys', - type: 'fixedCollection', - default: { value: { permissiveness: 'balanced' } }, - description: - 'Detects attempts to use secret keys in the input text. Scans text for common patterns, applies entropy analysis to detect random-looking strings.', - options: [ - wrapValue([ - { - displayName: 'Permissiveness', - name: 'permissiveness', - type: 'options', - default: '', - options: [ - { - name: 'Strict', - value: 'strict', - description: - 'Most sensitive, may have more false positives (commonly flag high entropy filenames or code)', - }, - { - name: 'Balanced', - value: 'balanced', - description: 'Balanced between sensitivity and specificity', - }, - { - name: 'Permissive', - value: 'permissive', - description: - 'Least sensitive, may miss some secret keys (but also reduces false positives)', - }, - ], - }, - ]), - ], - }, - { - displayName: 'Topical Alignment', - name: 'topicalAlignment', - type: 'fixedCollection', - default: { value: { threshold: 0.7 } }, - description: 'Detects attempts to stray from the business scope', - options: [ - wrapValue([ - THRESHOLD_OPTION, - ...getPromptOption( - TOPICAL_ALIGNMENT_SYSTEM_PROMPT, - false, - 'Make sure you replace the placeholder.', - ), - ]), - ], - displayOptions: { - show: { - '/operation': ['classify'], - }, - }, - }, - { - displayName: 'URLs', - name: 'urls', - type: 'fixedCollection', - default: { value: { allowedSchemes: ['https'], allowedUrls: '' } }, - description: 'Blocks URLs that are not in the allowed list', - options: [ - wrapValue([ - { - displayName: 'Block All URLs Except', - name: 'allowedUrls', - type: 'string', - // keep placeholder to avoid limitation that removes collections with unchanged default values - default: 'PLACEHOLDER', - description: - 'Multiple URLs can be added separated by comma. Leave empty to block all URLs.', - }, - { - displayName: 'Allowed Schemes', - name: 'allowedSchemes', - type: 'multiOptions', - default: ['https'], - // eslint-disable-next-line n8n-nodes-base/node-param-multi-options-type-unsorted-items - options: [ - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - { name: 'https', value: 'https' }, - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - { name: 'http', value: 'http' }, - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - { name: 'ftp', value: 'ftp' }, - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - { name: 'data', value: 'data' }, - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - { name: 'javascript', value: 'javascript' }, - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - { name: 'vbscript', value: 'vbscript' }, - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - { name: 'mailto', value: 'mailto' }, - ], - }, - { - displayName: 'Block Userinfo', - name: 'blockUserinfo', - type: 'boolean', - default: true, - description: - 'Whether to block URLs with userinfo (user:pass@domain) to prevent credential injection', - displayOptions: { - show: { - '/operation': ['classify'], - }, - }, - }, - { - displayName: 'Sanitize Userinfo', - name: 'blockUserinfo', - type: 'boolean', - default: true, - description: - 'Whether to sanitize URLs with userinfo (user:pass@domain) to prevent credential injection', - displayOptions: { - show: { - '/operation': ['sanitize'], - }, - }, - }, - { - displayName: 'Allow Subdomains', - name: 'allowSubdomains', - type: 'boolean', - default: true, - description: - 'Whether to allow subdomains (e.g. sub.domain.com if domain.com is allowed)', - }, - ]), - ], - }, - { - displayName: 'Custom', - name: 'custom', - type: 'fixedCollection', - typeOptions: { - sortable: true, - multipleValues: true, - }, - placeholder: 'Add Custom Guardrail', - default: { - guardrail: [{ name: 'Custom Guardrail' }], - }, - options: [ + }, + { + displayName: 'Personal Data (PII)', + name: 'pii', + type: 'fixedCollection', + default: { value: { type: 'all' } }, + description: 'Detects attempts to use personal data content', + options: [ + wrapValue([ { - displayName: 'Guardrail', - name: 'guardrail', - values: [ - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - description: 'Name of the custom guardrail', - }, - THRESHOLD_OPTION, - ...getPromptOption('', false), + displayName: 'Type', + name: 'type', + type: 'options', + default: '', + options: [ + { name: 'All', value: 'all' }, + { name: 'Selected', value: 'selected' }, ], }, - ], - displayOptions: { - show: { - '/operation': ['classify'], - }, - }, - }, - { - displayName: 'Custom Regex', - name: 'customRegex', - type: 'fixedCollection', - typeOptions: { - sortable: true, - multipleValues: true, - }, - placeholder: 'Add Custom Regex', - default: {}, - options: [ { - displayName: 'Regex', - name: 'regex', - values: [ + displayName: 'Entities', + name: 'entities', + type: 'multiOptions', + default: [], + displayOptions: { + show: { + type: ['selected'], + }, + }, + options: Object.values(PIIEntity).map((entity) => ({ + name: PII_NAME_MAP[entity], + value: entity, + })), + }, + ]), + ], + }, + { + displayName: 'Secret Keys', + name: 'secretKeys', + type: 'fixedCollection', + default: { value: { permissiveness: 'balanced' } }, + description: + 'Detects attempts to use secret keys in the input text. Scans text for common patterns, applies entropy analysis to detect random-looking strings.', + options: [ + wrapValue([ + { + displayName: 'Permissiveness', + name: 'permissiveness', + type: 'options', + default: '', + options: [ { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', + name: 'Strict', + value: 'strict', description: - 'Name of the custom regex. Will be used for replacement when sanitizing.', + 'Most sensitive, may have more false positives (commonly flag high entropy filenames or code)', }, { - displayName: 'Regex', - name: 'value', - type: 'string', - default: '', - description: 'Regex to match the input text', - placeholder: '/text/gi', + name: 'Balanced', + value: 'balanced', + description: 'Balanced between sensitivity and specificity', + }, + { + name: 'Permissive', + value: 'permissive', + description: + 'Least sensitive, may miss some secret keys (but also reduces false positives)', }, ], }, - ], - }, - ], - }, - { - displayName: 'Customize System Message', - name: 'customizeSystemMessage', - description: - 'Whether to customize the system message used by the guardrail to specify the output format', - type: 'boolean', - default: false, - displayOptions: { - show: { - '/operation': ['classify'], + ]), + ], + }, + { + displayName: 'Topical Alignment', + name: 'topicalAlignment', + type: 'fixedCollection', + default: { value: { threshold: 0.7 } }, + description: 'Detects attempts to stray from the business scope', + options: [ + wrapValue([ + THRESHOLD_OPTION, + ...getPromptOption( + TOPICAL_ALIGNMENT_SYSTEM_PROMPT, + false, + 'Make sure you replace the placeholder.', + ), + ]), + ], + displayOptions: { + show: { + '/operation': ['classify'], + }, }, }, - }, - { - displayName: 'System Message', - name: 'systemMessage', - type: 'string', - description: - 'The system message used by the guardrail to enforce thresholds and JSON output according to schema', - hint: 'This message is appended after prompts defined by guardrails', - default: LLM_SYSTEM_RULES, - typeOptions: { - rows: 6, + { + displayName: 'URLs', + name: 'urls', + type: 'fixedCollection', + default: { value: { allowedSchemes: ['https'], allowedUrls: '' } }, + description: 'Blocks URLs that are not in the allowed list', + options: [ + wrapValue([ + { + displayName: 'Block All URLs Except', + name: 'allowedUrls', + type: 'string', + // keep placeholder to avoid limitation that removes collections with unchanged default values + default: 'PLACEHOLDER', + description: + 'Multiple URLs can be added separated by comma. Leave empty to block all URLs.', + }, + { + displayName: 'Allowed Schemes', + name: 'allowedSchemes', + type: 'multiOptions', + default: ['https'], + // eslint-disable-next-line n8n-nodes-base/node-param-multi-options-type-unsorted-items + options: [ + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + { name: 'https', value: 'https' }, + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + { name: 'http', value: 'http' }, + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + { name: 'ftp', value: 'ftp' }, + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + { name: 'data', value: 'data' }, + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + { name: 'javascript', value: 'javascript' }, + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + { name: 'vbscript', value: 'vbscript' }, + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + { name: 'mailto', value: 'mailto' }, + ], + }, + { + displayName: 'Block Userinfo', + name: 'blockUserinfo', + type: 'boolean', + default: true, + description: + 'Whether to block URLs with userinfo (user:pass@domain) to prevent credential injection', + displayOptions: { + show: { + '/operation': ['classify'], + }, + }, + }, + { + displayName: 'Sanitize Userinfo', + name: 'blockUserinfo', + type: 'boolean', + default: true, + description: + 'Whether to sanitize URLs with userinfo (user:pass@domain) to prevent credential injection', + displayOptions: { + show: { + '/operation': ['sanitize'], + }, + }, + }, + { + displayName: 'Allow Subdomains', + name: 'allowSubdomains', + type: 'boolean', + default: true, + description: + 'Whether to allow subdomains (e.g. sub.domain.com if domain.com is allowed)', + }, + ]), + ], }, - displayOptions: { - show: { - '/customizeSystemMessage': [true], + { + displayName: 'Custom', + name: 'custom', + type: 'fixedCollection', + typeOptions: { + sortable: true, + multipleValues: true, + }, + placeholder: 'Add Custom Guardrail', + default: { + guardrail: [{ name: 'Custom Guardrail' }], + }, + options: [ + { + displayName: 'Guardrail', + name: 'guardrail', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the custom guardrail', + }, + THRESHOLD_OPTION, + ...getPromptOption('', false), + ], + }, + ], + displayOptions: { + show: { + '/operation': ['classify'], + }, }, }, + { + displayName: 'Custom Regex', + name: 'customRegex', + type: 'fixedCollection', + typeOptions: { + sortable: true, + multipleValues: true, + }, + placeholder: 'Add Custom Regex', + default: {}, + options: [ + { + displayName: 'Regex', + name: 'regex', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: + 'Name of the custom regex. Will be used for replacement when sanitizing.', + }, + { + displayName: 'Regex', + name: 'value', + type: 'string', + default: '', + description: 'Regex to match the input text', + placeholder: '/text/gi', + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'Customize System Message', + name: 'customizeSystemMessage', + description: + 'Whether to customize the system message used by the guardrail to specify the output format', + type: 'boolean', + default: false, + displayOptions: { + show: { + '/operation': ['classify'], + }, }, - ], -}; + }, + { + displayName: 'System Message', + name: 'systemMessage', + type: 'string', + description: + 'The system message used by the guardrail to enforce thresholds and JSON output according to schema', + hint: 'This message is appended after prompts defined by guardrails', + default: LLM_SYSTEM_RULES, + typeOptions: { + rows: 6, + }, + displayOptions: { + show: { + '/customizeSystemMessage': [true], + }, + }, + }, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/helpers/configureNodeInputs.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/helpers/configureNodeInputs.ts index 1a3a187b873..9fb7fed189c 100644 --- a/packages/@n8n/nodes-langchain/nodes/Guardrails/helpers/configureNodeInputs.ts +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/helpers/configureNodeInputs.ts @@ -1,4 +1,43 @@ -export const configureNodeInputs = (operation: 'classify' | 'sanitize') => { +import type { GuardrailsOptions } from '../actions/types'; + +const LLM_CHECKS = ['nsfw', 'topicalAlignment', 'custom', 'jailbreak'] as const satisfies Array< + keyof GuardrailsOptions +>; + +export const hasLLMGuardrails = (guardrails: GuardrailsOptions) => { + const checks = Object.keys(guardrails ?? {}); + return checks.some((check) => (LLM_CHECKS as string[]).includes(check)); +}; + +export const configureNodeInputsV2 = (parameters: { guardrails: GuardrailsOptions }) => { + // typeof LLM_CHECKS guarantees that it's in sync with hasLLMGuardrails + const CHECKS: typeof LLM_CHECKS = ['nsfw', 'topicalAlignment', 'custom', 'jailbreak']; + const checks = Object.keys(parameters?.guardrails ?? {}); + const hasLLMChecks = checks.some((check) => (CHECKS as string[]).includes(check)); + if (!hasLLMChecks) { + return ['main']; + } + + return [ + 'main', + { + type: 'ai_languageModel', + displayName: 'Chat Model', + maxConnections: 1, + required: true, + filter: { + excludedNodes: [ + '@n8n/n8n-nodes-langchain.lmCohere', + '@n8n/n8n-nodes-langchain.lmOllama', + 'n8n/n8n-nodes-langchain.lmOpenAi', + '@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference', + ], + }, + }, + ]; +}; + +export const configureNodeInputsV1 = (operation: 'classify' | 'sanitize') => { if (operation === 'sanitize') { // sanitize operations don't use a chat model return ['main']; diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/test/Guardrails.node.test.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/test/Guardrails.node.test.ts index 2f492961cd3..c8dbee758e2 100644 --- a/packages/@n8n/nodes-langchain/nodes/Guardrails/test/Guardrails.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/test/Guardrails.node.test.ts @@ -4,11 +4,10 @@ import type { IExecuteFunctions, INodeExecutionData, INode } from 'n8n-workflow' import { NodeOperationError } from 'n8n-workflow'; import * as ProcessActions from '../actions/process'; -import { Guardrails } from '../Guardrails.node'; import * as ModelHelpers from '../helpers/model'; +import { execute } from '../actions/execute'; describe('Guardrails', () => { - let guardrailsNode: Guardrails; let mockExecuteFunctions: jest.Mocked; let mockNode: jest.Mocked; let mockModel: jest.Mocked; @@ -16,13 +15,12 @@ describe('Guardrails', () => { beforeEach(() => { jest.clearAllMocks(); - guardrailsNode = new Guardrails(); mockExecuteFunctions = mockDeep(); mockNode = mock({ id: 'test-node', name: 'Guardrails Node', type: 'n8n-nodes-langchain.guardrails', - typeVersion: 1, + typeVersion: 2, position: [0, 0], parameters: {}, }); @@ -41,6 +39,13 @@ describe('Guardrails', () => { mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => { const params: Record = { operation: 'classify', + guardrails: { + nsfw: { + value: { + threshold: 0.5, + }, + }, + }, }; return params[paramName]; }); @@ -52,19 +57,18 @@ describe('Guardrails', () => { processSpy.mockResolvedValue({ guardrailsInput: 'processed text', passed: { - checks: [{ name: 'test', triggered: false }], + checks: [{ name: 'nsfw', triggered: false }], }, failed: null, }); - - const result = await guardrailsNode.execute.call(mockExecuteFunctions); + const result = await execute.call(mockExecuteFunctions); expect(result).toHaveLength(2); expect(result[0]).toHaveLength(1); expect(result[0][0]).toEqual({ json: { guardrailsInput: 'processed text', - checks: [{ name: 'test', triggered: false }], + checks: [{ name: 'nsfw', triggered: false }], }, pairedItem: { item: 0 }, }); @@ -83,6 +87,13 @@ describe('Guardrails', () => { mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => { const params: Record = { operation: 'classify', + guardrails: { + nsfw: { + value: { + threshold: 0.5, + }, + }, + }, }; return params[paramName]; }); @@ -114,7 +125,7 @@ describe('Guardrails', () => { failed: null, }); - const result = await guardrailsNode.execute.call(mockExecuteFunctions); + const result = await execute.call(mockExecuteFunctions); expect(result).toHaveLength(2); expect(result[0]).toHaveLength(3); @@ -167,7 +178,7 @@ describe('Guardrails', () => { failed: null, }); - const result = await guardrailsNode.execute.call(mockExecuteFunctions); + const result = await execute.call(mockExecuteFunctions); expect(result).toHaveLength(2); expect(result[0]).toHaveLength(2); @@ -218,9 +229,7 @@ describe('Guardrails', () => { const testError = new NodeOperationError(mockNode, 'Process failed'); processSpy.mockRejectedValue(testError); - await expect(guardrailsNode.execute.bind(mockExecuteFunctions)()).rejects.toThrow( - NodeOperationError, - ); + await expect(execute.bind(mockExecuteFunctions)()).rejects.toThrow(NodeOperationError); }); it('should handle error gracefully when continueOnFail is true', async () => { @@ -242,7 +251,7 @@ describe('Guardrails', () => { const testError = new Error('Process failed'); processSpy.mockRejectedValue(testError); - const result = await guardrailsNode.execute.call(mockExecuteFunctions); + const result = await execute.call(mockExecuteFunctions); expect(result).toHaveLength(2); expect(result[0]).toHaveLength(0); @@ -290,7 +299,7 @@ describe('Guardrails', () => { failed: null, }); - const result = await guardrailsNode.execute.call(mockExecuteFunctions); + const result = await execute.call(mockExecuteFunctions); expect(result).toHaveLength(2); expect(result[0]).toHaveLength(2); @@ -342,7 +351,7 @@ describe('Guardrails', () => { failed: null, }); - const result = await guardrailsNode.execute.call(mockExecuteFunctions); + const result = await execute.call(mockExecuteFunctions); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(1); @@ -371,7 +380,7 @@ describe('Guardrails', () => { failed: null, }); - const result = await guardrailsNode.execute.call(mockExecuteFunctions); + const result = await execute.call(mockExecuteFunctions); expect(result).toHaveLength(2); expect(result[0]).toHaveLength(1); diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/test/helpers/configureNodeInputs.test.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/test/helpers/configureNodeInputs.test.ts new file mode 100644 index 00000000000..b72dad1c9f0 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/test/helpers/configureNodeInputs.test.ts @@ -0,0 +1,75 @@ +import type { GuardrailsOptions } from '../../actions/types'; +import { configureNodeInputsV2, hasLLMGuardrails } from '../../helpers/configureNodeInputs'; + +describe('configureNodeInputs', () => { + describe('hasLLMGuardrails+configureNodeInputs', () => { + it.each([ + { + guardrails: { nsfw: { value: { threshold: 0.5 } } }, + expected: true, + expectedInputs: 2, + name: 'nsfw', + }, + { + guardrails: { topicalAlignment: { value: { threshold: 0.7, prompt: 'test' } } }, + expected: true, + expectedInputs: 2, + name: 'topicalAlignment', + }, + { + guardrails: { + custom: { guardrail: [{ name: 'custom', prompt: 'test prompt', threshold: 0.6 }] }, + }, + expected: true, + expectedInputs: 2, + name: 'custom', + }, + { + guardrails: { jailbreak: { value: { threshold: 0.8 } } }, + expected: true, + expectedInputs: 2, + name: 'jailbreak', + }, + { + guardrails: { + nsfw: { value: { threshold: 0.5 } }, + topicalAlignment: { value: { threshold: 0.7, prompt: 'test' } }, + custom: { guardrail: [{ name: 'custom1', prompt: 'test prompt', threshold: 0.6 }] }, + jailbreak: { value: { threshold: 0.8 } }, + }, + expectedInputs: 2, + name: 'multiple LLM checks', + expected: true, + }, + { + guardrails: { + keywords: 'test, keywords', + pii: { value: { type: 'all' } }, + }, + expected: false, + expectedInputs: 1, + name: 'only non-LLM checks', + }, + { + guardrails: {}, + expected: false, + expectedInputs: 1, + name: 'empty guardrails', + }, + { + guardrails: undefined, + expected: false, + expectedInputs: 1, + name: 'undefined guardrails', + }, + ])( + 'should return $expected when guardrails contain $name', + ({ guardrails, expected, expectedInputs }) => { + expect(hasLLMGuardrails(guardrails as GuardrailsOptions)).toBe(expected); + expect(configureNodeInputsV2({ guardrails: guardrails as GuardrailsOptions })).toHaveLength( + expectedInputs, + ); + }, + ); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/test/process.test.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/test/process.test.ts index 0e7591b0af4..4ac7edb2bb1 100644 --- a/packages/@n8n/nodes-langchain/nodes/Guardrails/test/process.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/test/process.test.ts @@ -77,16 +77,16 @@ describe('Guardrails Process', () => { }); } - it('Throws When Operation Is Classify And Model Is Null', async () => { + it('Throws When Operation Is LLM-based And Model Is Null', async () => { setParams({ text: 'hello', operation: 'classify', - guardrails: {}, + guardrails: { nsfw: { value: { threshold: 0.5 } } }, customizeSystemMessage: false, }); await expect(processGuardrails.call(exec, 0, null as unknown as BaseChatModel)).rejects.toThrow( - 'Chat Model is required for classify operation', + 'Chat Model is required', ); }); diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/v1/GuardrailsV1.node.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/v1/GuardrailsV1.node.ts new file mode 100644 index 00000000000..5216daa4c74 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/v1/GuardrailsV1.node.ts @@ -0,0 +1,43 @@ +import { + type INodeType, + type INodeTypeBaseDescription, + type INodeTypeDescription, + type IExecuteFunctions, + type INodeExecutionData, + NodeConnectionTypes, +} from 'n8n-workflow'; + +import { execute } from '../actions/execute'; +import { propertiesDescription } from '../description'; +import { configureNodeInputsV1 } from '../helpers/configureNodeInputs'; + +export class GuardrailsV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: [1], + inputs: `={{(${configureNodeInputsV1})($parameter.operation)}}`, + outputs: `={{ + ((parameters) => { + const operation = parameters.operation ?? 'classify'; + + if (operation === 'classify') { + return [{displayName: "Pass", type: "${NodeConnectionTypes.Main}"}, {displayName: "Fail", type: "${NodeConnectionTypes.Main}"}] + } + + return [{ displayName: "", type: "${NodeConnectionTypes.Main}"}] + })($parameter) + }}`, + defaults: { + name: 'Guardrails', + }, + properties: propertiesDescription, + }; + } + + async execute(this: IExecuteFunctions): Promise { + return await execute.call(this); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/v2/GuardrailsV2.node.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/v2/GuardrailsV2.node.ts new file mode 100644 index 00000000000..62a627f0239 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/v2/GuardrailsV2.node.ts @@ -0,0 +1,43 @@ +import { + type INodeType, + type INodeTypeBaseDescription, + type INodeTypeDescription, + type IExecuteFunctions, + type INodeExecutionData, + NodeConnectionTypes, +} from 'n8n-workflow'; + +import { execute } from '../actions/execute'; +import { propertiesDescription } from '../description'; +import { configureNodeInputsV2 } from '../helpers/configureNodeInputs'; + +export class GuardrailsV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: [2], + inputs: `={{(${configureNodeInputsV2})($parameter)}}`, + outputs: `={{ + ((parameters) => { + const operation = parameters.operation ?? 'classify'; + + if (operation === 'classify') { + return [{displayName: "Pass", type: "${NodeConnectionTypes.Main}"}, {displayName: "Fail", type: "${NodeConnectionTypes.Main}"}] + } + + return [{ displayName: "", type: "${NodeConnectionTypes.Main}"}] + })($parameter) + }}`, + defaults: { + name: 'Guardrails', + }, + properties: propertiesDescription, + }; + } + + async execute(this: IExecuteFunctions): Promise { + return await execute.call(this); + } +} diff --git a/packages/frontend/editor-ui/src/app/stores/nodeTypes.store.ts b/packages/frontend/editor-ui/src/app/stores/nodeTypes.store.ts index 39fdbeeb178..c113331b203 100644 --- a/packages/frontend/editor-ui/src/app/stores/nodeTypes.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/nodeTypes.store.ts @@ -269,8 +269,13 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => { }); const isConfigurableNode = computed(() => { - return (workflow: Workflow, node: INode, nodeTypeName: string): boolean => { - const nodeType = getNodeType.value(nodeTypeName); + return ( + workflow: Workflow, + node: INode, + nodeTypeName: string, + nodeTypeVersion?: number, + ): boolean => { + const nodeType = getNodeType.value(nodeTypeName, nodeTypeVersion); if (nodeType === null) { return false; } diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts index 84be45b105e..9460e6a9283 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts @@ -116,7 +116,12 @@ export function useCanvasMapping({ options: { trigger: isTriggerNodeById.value[node.id], configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type), - configurable: nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type), + configurable: nodeTypesStore.isConfigurableNode( + workflowObject.value, + node, + node.type, + node.typeVersion, + ), inputs: { labelSize: nodeInputLabelSizeById.value[node.id], },