feat(Guardrails Node): Require Chat model only for LLM checks (#22241)

This commit is contained in:
yehorkardash
2025-11-26 10:09:32 +02:00
committed by GitHub
parent 8f6c3b2dce
commit c1dade7ad3
12 changed files with 676 additions and 451 deletions
@@ -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<INodeExecutionData[][]> {
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);
}
}
@@ -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<INodeExecutionData[][]> {
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];
}
@@ -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({
@@ -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],
},
},
},
];
@@ -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'];
@@ -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<IExecuteFunctions>;
let mockNode: jest.Mocked<INode>;
let mockModel: jest.Mocked<BaseChatModel>;
@@ -16,13 +15,12 @@ describe('Guardrails', () => {
beforeEach(() => {
jest.clearAllMocks();
guardrailsNode = new Guardrails();
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockNode = mock<INode>({
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<string, any> = {
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<string, any> = {
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);
@@ -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,
);
},
);
});
});
@@ -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',
);
});
@@ -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<INodeExecutionData[][]> {
return await execute.call(this);
}
}
@@ -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<INodeExecutionData[][]> {
return await execute.call(this);
}
}
@@ -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;
}
@@ -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],
},