diff --git a/packages/@n8n/ai-utilities/src/__tests__/utils/log-ai-event.test.ts b/packages/@n8n/ai-utilities/src/__tests__/utils/log-ai-event.test.ts index 845128b44fe..f04eb2252f0 100644 --- a/packages/@n8n/ai-utilities/src/__tests__/utils/log-ai-event.test.ts +++ b/packages/@n8n/ai-utilities/src/__tests__/utils/log-ai-event.test.ts @@ -42,6 +42,106 @@ describe('logAiEvent', () => { expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledTimes(1); }); + it('should sanitize credential-shaped values on tool-called events', () => { + logAiEvent(mockExecuteFunctions, 'ai-tool-called', { + query: { api_key: 'sk-live-abcdef123456' }, + response: 'authorization: Bearer eyJhbGciOiJIUzI1NiJ9', + }); + + const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1]; + expect(payload).toContain('api_key'); + expect(payload).toContain('[REDACTED]'); + expect(payload).not.toContain('sk-live-abcdef123456'); + expect(payload).not.toContain('eyJhbGciOiJIUzI1NiJ9'); + }); + + it('should sanitize credential-shaped values inside stringified JSON responses', () => { + logAiEvent(mockExecuteFunctions, 'ai-tool-called', { + query: 'test query', + response: JSON.stringify({ api_key: 'sk-live-abcdef123456' }, null, 2), + }); + + const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1]; + expect(payload).toContain('[REDACTED]'); + expect(payload).not.toContain('sk-live-abcdef123456'); + }); + + it('should replace object-valued credential properties instead of restoring them', () => { + logAiEvent(mockExecuteFunctions, 'ai-tool-called', { + query: { api_key: { nested: 'sk-live-abcdef123456' } }, + response: { client_secret: ['cs-live-abcdef'] }, + }); + + const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1]; + expect(payload).toContain('[REDACTED]'); + expect(payload).not.toContain('sk-live-abcdef123456'); + expect(payload).not.toContain('cs-live-abcdef'); + }); + + it('should sanitize unlabeled credential formats in tool-called events', () => { + logAiEvent(mockExecuteFunctions, 'ai-tool-called', { + query: 'lookup user', + response: 'the run used sk-abc123DEF456ghi789jkl012', + }); + + const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1]; + expect(payload).toContain('[REDACTED]'); + expect(payload).not.toContain('sk-abc123DEF456ghi789jkl012'); + }); + + it('should sanitize composite credential field names in tool-called events', () => { + logAiEvent(mockExecuteFunctions, 'ai-tool-called', { + query: { accessKeyId: 'AKIAIOSFODNN7EXAMPLE' }, + response: { secretAccessKey: 'wJalrXUtnFEMI/K7MDENG', Cookie: 'session=abc123' }, + }); + + const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1]; + expect(payload).toContain('[REDACTED]'); + expect(payload).not.toContain('AKIAIOSFODNN7EXAMPLE'); + expect(payload).not.toContain('wJalrXUtnFEMI/K7MDENG'); + expect(payload).not.toContain('session=abc123'); + }); + + it('should preserve Date serialization in tool-called events', () => { + logAiEvent(mockExecuteFunctions, 'ai-tool-called', { + query: 'lookup user', + response: { + createdAt: new Date('2026-08-25T12:00:00.000Z'), + message: 'ok', + }, + }); + + const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1]; + expect(payload).toContain('2026-08-25T12:00:00.000Z'); + expect(payload).toContain('"message":"ok"'); + }); + + it('should preserve custom toJSON serialization in tool-called events', () => { + logAiEvent(mockExecuteFunctions, 'ai-tool-called', { + query: 'lookup user', + response: { + toJSON() { + return { status: 'ok', issuedAt: new Date('2020-01-01T00:00:00.000Z') }; + }, + }, + }); + + const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1]; + expect(payload).toContain('"status":"ok"'); + expect(payload).toContain('2020-01-01T00:00:00.000Z'); + }); + + it('should leave other events unsanitized', () => { + const data: IDataObject = { response: 'api_key: sk-live-abcdef123456' }; + + logAiEvent(mockExecuteFunctions, 'ai-llm-generated-output', data); + + expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledWith( + 'ai-llm-generated-output', + JSON.stringify(data), + ); + }); + it('should log different AI event types', () => { const events: AiEvent[] = ['ai-llm-generated-output', 'ai-llm-errored', 'ai-tool-called']; const data: IDataObject = { test: 'data' }; diff --git a/packages/@n8n/ai-utilities/src/__tests__/utils/redact-secrets.test.ts b/packages/@n8n/ai-utilities/src/__tests__/utils/redact-secrets.test.ts new file mode 100644 index 00000000000..66420620c1e --- /dev/null +++ b/packages/@n8n/ai-utilities/src/__tests__/utils/redact-secrets.test.ts @@ -0,0 +1,153 @@ +import { redactSecrets, sanitizeCredentialShapedValues } from 'src/utils/redact-secrets'; + +const join = (prefix: string, suffix: string) => prefix + suffix; + +describe('redactSecrets', () => { + it('should replace credential-shaped values and keep the key', () => { + expect(redactSecrets('Rejected request, api_key: sk-live-abcdef123456')).toBe( + 'Rejected request, api_key: [REDACTED]', + ); + expect(redactSecrets('authorization: Bearer eyJhbGciOiJIUzI1NiJ9')).toBe( + 'authorization: [REDACTED]', + ); + }); + + it('should replace compound keys and unprefixed authorization values', () => { + const input = JSON.stringify({ + message: 'Token exchange failed', + client_secret: 'cs-live-abcdef', + Authorization: 'abcdef-bare-key', + token_type: 'Bearer', + }); + + const redacted = redactSecrets(input); + + expect(redacted).toContain('Token exchange failed'); + expect(redacted).toContain('client_secret'); + expect(redacted).not.toContain('cs-live-abcdef'); + expect(redacted).not.toContain('abcdef-bare-key'); + expect(redacted).toContain('"token_type":"Bearer"'); + }); + + it('should leave strings without credential-shaped values unchanged', () => { + expect(redactSecrets('Hello World')).toBe('Hello World'); + }); + + it('should sanitize unlabeled credential formats', () => { + expect(redactSecrets('the run used sk-abc123DEF456ghi789jkl012 for calls')).toBe( + 'the run used [REDACTED] for calls', + ); + expect(redactSecrets('cloned using ghp_ABCdef123456789012345678901234567890')).toBe( + 'cloned using [REDACTED]', + ); + expect(redactSecrets('signed as AKIAIOSFODNN7EXAMPLE today')).toBe( + 'signed as [REDACTED] today', + ); + expect(redactSecrets('got Bearer eyJhbGciOiJIUzI1NiJ9 back')).toBe('got [REDACTED] back'); + }); + + it('should leave unlabeled lookalikes unchanged', () => { + expect(redactSecrets('we use sk-learn for clustering')).toBe('we use sk-learn for clustering'); + expect(redactSecrets('AKIA is an AWS prefix')).toBe('AKIA is an AWS prefix'); + }); + + it('should sanitize JWT values', () => { + const jwt = `${join('eyJ', 'hbGciOiJIUzI1NiJ9')}.${join('eyJ', 'zdWIiOiIxMjMifQ')}.c2lnbmF0dXJl`; + expect(redactSecrets(`token ${jwt} end`)).toBe('token [REDACTED] end'); + }); + + it('should sanitize PEM private-key blocks', () => { + const pem = `-----BEGIN PRIVATE KEY-----\n${'FAKEKEYMATERIAL'}\n-----END PRIVATE KEY-----`; + expect(redactSecrets(`key:\n${pem}\ndone`)).toBe('key:\n[REDACTED]\ndone'); + }); + + it('should sanitize Token authorization values', () => { + expect(redactSecrets('header is Token abcdef1234567890')).toBe('header is [REDACTED]'); + }); +}); + +describe('sanitizeCredentialShapedValues', () => { + it('should replace object and array credential properties with a sentinel', () => { + expect( + sanitizeCredentialShapedValues({ + api_key: { nested: 'sk-live-abcdef123456' }, + client_secret: ['cs-live-abcdef'], + message: 'ok', + }), + ).toEqual({ + api_key: '[REDACTED]', + client_secret: '[REDACTED]', + message: 'ok', + }); + }); + + it('should keep keys that only resemble credentials', () => { + expect( + sanitizeCredentialShapedValues({ + token_type: 'Bearer', + access_token: 'xyz-live-token', + }), + ).toEqual({ + token_type: 'Bearer', + access_token: '[REDACTED]', + }); + }); + + it('should sanitize composite credential field names', () => { + expect( + sanitizeCredentialShapedValues({ + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + Cookie: 'session=abc123', + secretKey: 'local-dev-key', + message: 'ok', + }), + ).toEqual({ + secretAccessKey: '[REDACTED]', + accessKeyId: '[REDACTED]', + Cookie: '[REDACTED]', + secretKey: '[REDACTED]', + message: 'ok', + }); + }); + + it('should preserve Date serialization', () => { + const createdAt = new Date('2026-08-25T12:00:00.000Z'); + expect( + sanitizeCredentialShapedValues({ + createdAt, + message: 'ok', + }), + ).toEqual({ + createdAt: '2026-08-25T12:00:00.000Z', + message: 'ok', + }); + }); + + it('should sanitize credential-shaped values inside stringified JSON', () => { + const result = sanitizeCredentialShapedValues({ + response: JSON.stringify({ api_key: { nested: 'sk-live-abcdef123456' } }, null, 2), + }); + + expect(result).toEqual({ + response: JSON.stringify({ api_key: '[REDACTED]' }, null, 2), + }); + }); + + it('should keep pretty-printed JSON strings that do not need redaction', () => { + const pretty = JSON.stringify({ msg: 'test response' }, null, 2); + expect(sanitizeCredentialShapedValues({ response: pretty })).toEqual({ + response: pretty, + }); + }); + + it('should sanitize unlabeled credential formats in string fields', () => { + expect( + sanitizeCredentialShapedValues({ + response: 'the run used sk-abc123DEF456ghi789jkl012', + }), + ).toEqual({ + response: 'the run used [REDACTED]', + }); + }); +}); diff --git a/packages/@n8n/ai-utilities/src/index.ts b/packages/@n8n/ai-utilities/src/index.ts index 816f06d8e3c..0306206674c 100644 --- a/packages/@n8n/ai-utilities/src/index.ts +++ b/packages/@n8n/ai-utilities/src/index.ts @@ -4,6 +4,7 @@ export { AI_NODE_SDK_VERSION } from './ai-node-sdk-version'; // Utils export { logWrapper } from './utils/log-wrapper'; export { logAiEvent } from './utils/log-ai-event'; +export { redactSecrets } from './utils/redact-secrets'; export { parseSSEStream } from './utils/sse'; export { validateEmbedQueryInput, diff --git a/packages/@n8n/ai-utilities/src/utils/log-ai-event.ts b/packages/@n8n/ai-utilities/src/utils/log-ai-event.ts index 86ca150a030..ec5d816b776 100644 --- a/packages/@n8n/ai-utilities/src/utils/log-ai-event.ts +++ b/packages/@n8n/ai-utilities/src/utils/log-ai-event.ts @@ -1,13 +1,17 @@ import type { AiEvent, IDataObject, IExecuteFunctions, ISupplyDataFunctions } from 'n8n-workflow'; import { jsonStringify } from 'n8n-workflow'; +import { sanitizeCredentialShapedValues } from './redact-secrets'; + export function logAiEvent( executeFunctions: IExecuteFunctions | ISupplyDataFunctions, event: AiEvent, data?: IDataObject, ) { try { - executeFunctions.logAiEvent(event, data ? jsonStringify(data) : undefined); + const payload = + event === 'ai-tool-called' && data ? sanitizeCredentialShapedValues(data) : data; + executeFunctions.logAiEvent(event, payload ? jsonStringify(payload) : undefined); } catch (error) { executeFunctions.logger.debug(`Error logging AI event: ${event}`); } diff --git a/packages/@n8n/ai-utilities/src/utils/redact-secrets.ts b/packages/@n8n/ai-utilities/src/utils/redact-secrets.ts new file mode 100644 index 00000000000..f584a5972c6 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/utils/redact-secrets.ts @@ -0,0 +1,122 @@ +import { redactDeep, redactText } from '@n8n/utils/redaction/redact-text'; +import { jsonParse, jsonStringify } from 'n8n-workflow'; + +const OPTIONS = { secrets: true, redactSensitiveKeys: true }; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function parseJsonObject(value: string): object | undefined { + try { + const parsed: unknown = JSON.parse(value); + if (parsed !== null && typeof parsed === 'object') { + return parsed; + } + } catch { + return undefined; + } + return undefined; +} + +/** Keep the original string when redaction is a no-op so pretty JSON stays pretty. */ +function serializeJson(original: string, parsed: unknown, redacted: unknown): string { + if (jsonStringify(redacted) === jsonStringify(parsed)) { + return original; + } + return original.includes('\n') ? JSON.stringify(redacted, null, 2) : jsonStringify(redacted); +} + +/** Expand JSON-looking strings so nested payloads can be walked as structure. */ +function parseNestedJson(value: unknown): unknown { + if (typeof value === 'string') { + const parsed = parseJsonObject(value); + return parsed === undefined ? value : parseNestedJson(parsed); + } + + if (Array.isArray(value)) { + return value.map(parseNestedJson); + } + + if (isPlainObject(value)) { + const walked: Record = {}; + for (const [key, child] of Object.entries(value)) { + walked[key] = parseNestedJson(child); + } + return walked; + } + + return value; +} + +/** Put parsed JSON strings back; keep the original when redaction did not change them. */ +function restoreJsonStrings(original: unknown, redacted: unknown): unknown { + if (typeof original === 'string') { + const parsed = parseJsonObject(original); + if (parsed !== undefined) { + return serializeJson(original, parsed, restoreJsonStrings(parsed, redacted)); + } + return redacted; + } + + if (Array.isArray(original) && Array.isArray(redacted)) { + return original.map((item, index) => restoreJsonStrings(item, redacted[index])); + } + + if (isPlainObject(original) && isPlainObject(redacted)) { + const restored: Record = {}; + for (const [key, child] of Object.entries(original)) { + restored[key] = restoreJsonStrings(child, redacted[key]); + } + return restored; + } + + return redacted; +} + +function redactJsonValue(value: unknown): unknown { + return restoreJsonStrings(value, redactDeep(parseNestedJson(value), OPTIONS).value); +} + +/** + * Masks credential-shaped values in text. JSON objects are walked with + * `redactDeep` so keys stay visible; non-JSON text uses `redactText`. + */ +export const redactSecrets = (content: string): string => { + const parsed = parseJsonObject(content); + if (parsed !== undefined) { + return serializeJson(content, parsed, redactJsonValue(parsed)); + } + + const start = content.indexOf('{'); + const end = content.lastIndexOf('}'); + if (start !== -1 && end > start) { + const slice = content.slice(start, end + 1); + const nested = parseJsonObject(slice); + if (nested !== undefined) { + content = + content.slice(0, start) + + serializeJson(slice, nested, redactJsonValue(nested)) + + content.slice(end + 1); + } + } + + return redactText(content, OPTIONS).text; +}; + +/** + * Replaces credential-named properties with a sentinel and masks credential-shaped + * text in strings. JSON.stringify runs first so Date and custom toJSON values + * keep their serialized form, then the JSON-compatible tree is walked. + */ +export function sanitizeCredentialShapedValues(value: unknown): unknown { + if (value !== null && typeof value === 'object') { + const parsed: unknown = jsonParse(jsonStringify(value), { fallbackValue: undefined }); + if (parsed === undefined) { + throw new Error('Unable to sanitize tool-called payload'); + } + return redactJsonValue(parsed); + } + + return redactJsonValue(value); +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts index 0a5b9b5f18b..71e1479351b 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts @@ -5,6 +5,7 @@ import { type INode, type INodeExecutionData, type ISupplyDataFunctions, + type WorkflowExecuteMode, } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; @@ -85,6 +86,95 @@ describe('ToolCode', () => { expect(tool.description).toBe('description text'); expect(tool.func).toBeInstanceOf(Function); }); + + it('should emit ai-tool-called on successful invocation', async () => { + const node = new ToolCode(); + + vi.mocked(JsTaskRunnerSandbox).mockImplementation(function (this: { + runCodeForTool: ReturnType; + }) { + this.runCodeForTool = vi.fn().mockResolvedValue('ok'); + return this; + } as unknown as new ( + ...args: unknown[] + ) => JsTaskRunnerSandbox); + + const logAiEvent = vi.fn(); + const ctx = mock({ + getNode: vi.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), + getNodeParameter: vi.fn().mockImplementation((paramName) => { + switch (paramName) { + case 'description': + return 'description text'; + case 'specifyInputSchema': + return false; + case 'language': + return 'javaScript'; + case 'jsCode': + return 'return "ok";'; + default: + return; + } + }), + getMode: vi.fn((): WorkflowExecuteMode => 'manual'), + addInputData: vi.fn(() => ({ index: 0 })), + addOutputData: vi.fn(), + logAiEvent, + }); + + const supplyDataResult = await node.supplyData.call(ctx, 0); + const tool = supplyDataResult.response as DynamicTool; + + await expect(tool.func('hello')).resolves.toBe('ok'); + expect(logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + JSON.stringify({ query: 'hello', response: 'ok' }), + ); + }); + + it('should sanitize credential-shaped values in the tool-called event', async () => { + const node = new ToolCode(); + + vi.mocked(JsTaskRunnerSandbox).mockImplementation(function (this: { + runCodeForTool: ReturnType; + }) { + this.runCodeForTool = vi.fn().mockResolvedValue('api_key: sk-live-abcdef123456'); + return this; + } as unknown as new ( + ...args: unknown[] + ) => JsTaskRunnerSandbox); + + const logAiEvent = vi.fn(); + const ctx = mock({ + getNode: vi.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), + getNodeParameter: vi.fn().mockImplementation((paramName) => { + switch (paramName) { + case 'description': + return 'description text'; + case 'specifyInputSchema': + return false; + case 'language': + return 'javaScript'; + case 'jsCode': + return 'return "ok";'; + default: + return; + } + }), + getMode: vi.fn((): WorkflowExecuteMode => 'manual'), + addInputData: vi.fn(() => ({ index: 0 })), + addOutputData: vi.fn(), + logAiEvent, + }); + + const supplyDataResult = await node.supplyData.call(ctx, 0); + const tool = supplyDataResult.response as DynamicTool; + + await expect(tool.func('query')).resolves.toBe('api_key: sk-live-abcdef123456'); + const payload = logAiEvent.mock.calls[0][1]; + expect(payload).toContain('api_key: [REDACTED]'); + expect(payload).not.toContain('sk-live-abcdef123456'); + }); }); describe('execute', () => { @@ -222,6 +312,7 @@ describe('ToolCode', () => { }) as unknown as JsTaskRunnerSandbox, ); + const logAiEvent = vi.fn(); const mockExecute = mock({ getInputData: vi.fn(() => inputData), getNode: vi.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), @@ -241,16 +332,16 @@ describe('ToolCode', () => { return; } }), - // @ts-expect-error - Mocking - getMode: vi.fn(() => 'manual'), + getMode: vi.fn((): WorkflowExecuteMode => 'manual'), + logAiEvent, }); DynamicTool.prototype.invoke = vi.fn(async function (this: DynamicTool, args: unknown) { return await this.func(args as string); }); - // @ts-expect-error - Mocking await expect(node.execute.call(mockExecute)).rejects.toThrow(/boom/); + expect(logAiEvent).not.toHaveBeenCalled(); }); it('should keep returning error string when invoked via supplyData (legacy path)', async () => { @@ -263,33 +354,42 @@ describe('ToolCode', () => { }) as unknown as JsTaskRunnerSandbox, ); - const supplyDataResult = await node.supplyData.call( - mock({ - getNode: vi.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), - getNodeParameter: vi.fn().mockImplementation((paramName, _itemIndex) => { - switch (paramName) { - case 'description': - return 'description text'; - case 'name': - return 'wrong_field'; - case 'specifyInputSchema': - return false; - case 'language': - return 'javaScript'; - case 'jsCode': - return 'throw new Error("boom");'; - default: - return; - } - }), - addInputData: vi.fn(() => ({ index: 0 })), - addOutputData: vi.fn(), + const logAiEvent = vi.fn(); + const ctx = mock({ + getNode: vi.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), + getNodeParameter: vi.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'description text'; + case 'name': + return 'wrong_field'; + case 'specifyInputSchema': + return false; + case 'language': + return 'javaScript'; + case 'jsCode': + return 'throw new Error("boom");'; + default: + return; + } }), - 0, - ); + addInputData: vi.fn(() => ({ index: 0 })), + addOutputData: vi.fn(), + logAiEvent, + }); + + const supplyDataResult = await node.supplyData.call(ctx, 0); const tool = supplyDataResult.response as DynamicTool; await expect(tool.func('query')).resolves.toMatch(/There was an error/); + expect(logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + expect.stringContaining('"query":"query"'), + ); + expect(logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + expect.stringContaining('There was an error'), + ); }); }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts index b08f54388ad..e7afac447ef 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -26,7 +26,7 @@ import { schemaTypeField, } from '@utils/descriptions'; import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing'; -import { getConnectionHintNoticeField } from '@n8n/ai-utilities'; +import { getConnectionHintNoticeField, logAiEvent } from '@n8n/ai-utilities'; import type { DynamicZodObject } from '../../../types/zod.types'; @@ -121,6 +121,10 @@ function getTool( void ctx.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]); } + if (log) { + logAiEvent(ctx, 'ai-tool-called', { query, response }); + } + // When invoked from `execute` (log=false) the engine, not the agent, is // driving execution: throw so workflow-execute can record the error // against the tool run. Continue-on-fail for AI tools still lets the diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts index 76f5ec05f93..2ede62acd71 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts @@ -53,6 +53,10 @@ describe('ToolHttpRequest', () => { expect(helpers.httpRequest).toHaveBeenCalled(); expect(res).toContain('error'); expect(res).toContain('Binary data is not supported'); + expect(executeFunctions.logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + expect.stringContaining('Binary data is not supported'), + ); }); it('should return the response text when receiving a text response', async () => { @@ -83,6 +87,42 @@ describe('ToolHttpRequest', () => { const res = await (response as N8nTool).invoke({}); expect(helpers.httpRequest).toHaveBeenCalled(); expect(res).toEqual('Hello World'); + expect(executeFunctions.logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + JSON.stringify({ query: '{}', response: 'Hello World' }), + ); + }); + + it('should sanitize credential-shaped values in the tool-called event', async () => { + helpers.httpRequest.mockResolvedValue({ + body: 'api_key: sk-live-abcdef123456', + headers: { + 'content-type': 'text/plain', + }, + }); + + executeFunctions.getNodeParameter.mockImplementation((paramName: string) => { + switch (paramName) { + case 'method': + return 'GET'; + case 'url': + return 'https://httpbin.org/text/plain'; + case 'options': + return {}; + case 'placeholderDefinitions.values': + return []; + default: + return undefined; + } + }); + + const { response } = await httpTool.supplyData.call(executeFunctions, 0); + + const res = await (response as N8nTool).invoke({}); + expect(res).toBe('api_key: sk-live-abcdef123456'); + const payload = executeFunctions.logAiEvent.mock.calls[0][1]; + expect(payload).toContain('api_key: [REDACTED]'); + expect(payload).not.toContain('sk-live-abcdef123456'); }); it('should return the response text when receiving a text response with a charset', async () => { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index e495a427f85..71991a061b1 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -23,6 +23,8 @@ import { } from 'n8n-workflow'; import { z } from 'zod'; +import { logAiEvent, redactSecrets } from '@n8n/ai-utilities'; + import type { ParameterInputType, ParametersValues, @@ -195,32 +197,6 @@ const defaultOptimizer = (response: T) => { /** Error payloads are appended to the tool output, so they must not flood the model's context. */ const MAX_ERROR_BODY_LENGTH = 2000; -const SECRET_REDACTION = '[redacted]'; - -/** - * Masks credential-shaped values that an API echoed back in its error payload, so they do not - * reach the model or the stored execution data. The key is kept, so `invalid api_key: ` - * still tells the model which credential the API rejected. - * - * A sibling of the redaction applied to skill tool output in - * `packages/@n8n/agents/src/skills/tools.ts`; kept separate because `@n8n/agents` is not a - * dependency here. Keep the two in mind when changing either. - * - * The leading `[\w-]*` matters: `\b` alone never fires inside a compound key such as - * `client_secret`, where every character before `secret` is a word character. The auth scheme is - * optional so an `Authorization` header holding a bare key is masked too. - */ -const redactSecrets = (content: string): string => - content - .replace( - /\b(authorization)(["']?\s*[:=]\s*["']?\s*(?:(?:bearer|basic)\s+)?)[^\s"',;}]+/gi, - `$1$2${SECRET_REDACTION}`, - ) - .replace( - /([\w-]*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|passwd|secret|credential|private[_-]?key))(["']?\s*[:=]\s*)(["']?)[^\s"',;}]+\3/gi, - `$1$2$3${SECRET_REDACTION}$3`, - ); - type FailedRequest = { error?: unknown; response?: { status?: number; data?: unknown }; @@ -923,6 +899,8 @@ export const configureToolFunction = ( void ctx.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]); } + logAiEvent(ctx, 'ai-tool-called', { query, response }); + return response; }; }; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.test.ts index df1155b4350..1a13748cb00 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.test.ts @@ -4,13 +4,23 @@ import { type ISupplyDataFunctions, type IExecuteFunctions, type INodeExecutionData, + type ExecuteWorkflowData, + type IWorkflowDataProxyData, + type WorkflowExecuteMode, } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; +import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; + import { ToolWorkflow } from './ToolWorkflow.node'; +import type { ToolWorkflowV1 } from './v1/ToolWorkflowV1.node'; import type { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node'; import { WorkflowToolService } from './v2/utils/WorkflowToolService'; +vi.mock('n8n-nodes-base/dist/nodes/Set/v2/manual.mode', () => ({ + execute: vi.fn().mockResolvedValue({ json: { query: 'hello' } }), +})); + describe('ToolWorkflowV2', () => { describe('supplyData', () => { beforeEach(() => { @@ -215,3 +225,85 @@ describe('ToolWorkflowV2', () => { }); }); }); + +describe('ToolWorkflowV1', () => { + beforeEach(() => { + vi.mocked(manual.execute).mockResolvedValue({ json: { query: 'hello' } }); + }); + + const createContext = () => { + const logAiEvent = vi.fn(); + const ctx = mock({ + getNode: vi.fn(() => mock({ typeVersion: 1.3, name: 'test tool' })), + getNodeParameter: vi.fn().mockImplementation((paramName, _itemIndex, fallback) => { + switch (paramName) { + case 'name': + return 'test_tool'; + case 'description': + return 'description text'; + case 'specifyInputSchema': + return false; + case 'source': + return 'database'; + case 'workflowId': + return { value: 'wf-id' }; + default: + return fallback; + } + }), + getWorkflowDataProxy: vi.fn( + () => + ({ + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + }) as unknown as IWorkflowDataProxyData, + ), + getMode: vi.fn((): WorkflowExecuteMode => 'manual'), + addInputData: vi.fn(() => ({ index: 0 })), + addOutputData: vi.fn(), + executeWorkflow: vi.fn(), + logAiEvent, + }); + return { ctx, logAiEvent }; + }; + + it('should emit ai-tool-called on successful invocation', async () => { + const { ctx, logAiEvent } = createContext(); + const mockExecuteWorkflowResponse: ExecuteWorkflowData = { + data: [[{ json: { result: 'ok' } }]], + executionId: 'test-execution', + }; + vi.spyOn(ctx, 'executeWorkflow').mockResolvedValueOnce(mockExecuteWorkflowResponse); + + const toolWorkflowNode = new ToolWorkflow(); + const node = toolWorkflowNode.nodeVersions[1.3] as ToolWorkflowV1; + const supplyDataResult = await node.supplyData.call(ctx, 0); + const tool = supplyDataResult.response as DynamicTool; + + const expectedResponse = JSON.stringify({ result: 'ok' }, null, 2); + await expect(tool.func('hello')).resolves.toBe(expectedResponse); + expect(logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + JSON.stringify({ query: 'hello', response: expectedResponse }), + ); + }); + + it('should emit ai-tool-called when the sub-workflow fails', async () => { + const { ctx, logAiEvent } = createContext(); + vi.spyOn(ctx, 'executeWorkflow').mockRejectedValueOnce(new Error('Workflow execution failed')); + + const toolWorkflowNode = new ToolWorkflow(); + const node = toolWorkflowNode.nodeVersions[1.3] as ToolWorkflowV1; + const supplyDataResult = await node.supplyData.call(ctx, 0); + const tool = supplyDataResult.response as DynamicTool; + + const result = await tool.func('hello'); + + expect(result).toContain('There was an error'); + expect(result).toContain('Workflow execution failed'); + expect(logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + JSON.stringify({ query: 'hello', response: result }), + ); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts index 4cbdec52721..b95baa83d00 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/ToolWorkflowV1.node.ts @@ -22,6 +22,8 @@ import type { } from 'n8n-workflow'; import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow'; +import { logAiEvent } from '@n8n/ai-utilities'; + import { versionDescription } from './versionDescription'; import type { DynamicZodObject } from '../../../../types/zod.types'; import { convertJsonSchemaToZod, generateSchemaFromExample } from '../../../../utils/schemaParsing'; @@ -197,6 +199,7 @@ export class ToolWorkflowV1 implements INodeType { const json = jsonParse(response, { fallbackValue: { response } }); void this.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json }]], metadata); } + logAiEvent(this, 'ai-tool-called', { query, response }); return response; }; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts index f45aa6f5caf..6279f6c4c0a 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts @@ -46,6 +46,7 @@ function createMockContext(overrides?: Partial): ISupplyDa executeWorkflow: vi.fn(), addInputData: vi.fn(), addOutputData: vi.fn(), + logAiEvent: vi.fn(), getCredentials: vi.fn(), getCredentialsProperties: vi.fn(), getInputData: vi.fn(), @@ -126,6 +127,13 @@ describe('WorkflowTool::WorkflowToolService', () => { expect(result).toBe(JSON.stringify(TEST_RESPONSE, null, 2)); expect(context.addOutputData).toHaveBeenCalled(); + expect(context.logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + JSON.stringify({ + query: 'test query', + response: JSON.stringify(TEST_RESPONSE, null, 2), + }), + ); // Here we validate that the runIndex is correctly updated expect(context.cloneWith).toHaveBeenCalledWith({ @@ -140,6 +148,37 @@ describe('WorkflowTool::WorkflowToolService', () => { }); }); + it('should sanitize credential-shaped values in the tool-called event', async () => { + const TEST_RESPONSE = { api_key: 'sk-live-abcdef123456' }; + + const mockExecuteWorkflowResponse: ExecuteWorkflowData = { + data: [[{ json: TEST_RESPONSE }]], + executionId: 'test-execution', + }; + + vi.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockExecuteWorkflowResponse); + vi.spyOn(context, 'addInputData').mockReturnValue({ index: 0 }); + vi.spyOn(context, 'getNodeParameter').mockReturnValue('database'); + vi.spyOn(context, 'getWorkflowDataProxy').mockReturnValue({ + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + } as unknown as IWorkflowDataProxyData); + vi.spyOn(context, 'cloneWith').mockReturnValue(context); + + const tool = await service.createTool({ + ctx: context, + name: 'TestTool', + description: 'Test Description', + itemIndex: 0, + }); + const result = await tool.func('test query'); + + expect(result).toContain('sk-live-abcdef123456'); + const payload = vi.mocked(context.logAiEvent).mock.calls[0][1]; + expect(payload).toContain('[REDACTED]'); + expect(payload).not.toContain('sk-live-abcdef123456'); + }); + it('returns un-stringified data if manualLogging is false (meaning it was called from the engine)', async () => { const TEST_RESPONSE = { msg: 'test response' }; @@ -167,6 +206,7 @@ describe('WorkflowTool::WorkflowToolService', () => { const result = await tool.func('test query'); expect(result).toEqual([{ json: TEST_RESPONSE }]); + expect(context.logAiEvent).not.toHaveBeenCalled(); }); it('should handle errors during tool execution', async () => { @@ -182,6 +222,10 @@ describe('WorkflowTool::WorkflowToolService', () => { ); vi.spyOn(context, 'addInputData').mockReturnValue({ index: 0 }); vi.spyOn(context, 'getNodeParameter').mockReturnValue('database'); + vi.spyOn(context, 'getWorkflowDataProxy').mockReturnValue({ + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + } as unknown as IWorkflowDataProxyData); vi.spyOn(context, 'cloneWith').mockReturnValue(context); const tool = await service.createTool(toolParams); @@ -189,6 +233,13 @@ describe('WorkflowTool::WorkflowToolService', () => { expect(result).toContain('There was an error'); expect(context.addOutputData).toHaveBeenCalled(); + expect(context.logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + JSON.stringify({ + query: 'test query', + response: 'There was an error: "Workflow execution failed"', + }), + ); }); it('should throw on tool error when manualLogging is false so the engine records the failure', async () => { @@ -214,6 +265,7 @@ describe('WorkflowTool::WorkflowToolService', () => { const tool = await service.createTool(toolParams); await expect(tool.func('test query')).rejects.toThrow(/Workflow execution failed/); + expect(context.logAiEvent).not.toHaveBeenCalled(); }); }); @@ -874,6 +926,43 @@ describe('WorkflowTool::WorkflowToolService', () => { expect(result).toBe('There was an error: "Execution was cancelled"'); expect(executeWorkflowMock).not.toHaveBeenCalled(); + expect(contextWithRetryNode.logAiEvent).toHaveBeenCalledTimes(1); + expect(contextWithRetryNode.logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + JSON.stringify({ + query: 'test query', + response: 'There was an error: "Execution was cancelled"', + }), + ); + }); + + it('should not emit ai-tool-called when cancelled and manualLogging is false', async () => { + const executeWorkflowMock = vi.fn().mockResolvedValue({ + data: [[{ json: { result: 'success' } }]], + executionId: 'success-exec-id', + }); + + abortController.abort(); + + const contextWithRetryNode = createAbortSignalContext( + executeWorkflowMock, + abortController.signal, + ); + + service = new WorkflowToolService(contextWithRetryNode); + const tool = await service.createTool({ + ctx: contextWithRetryNode, + name: 'Test Tool', + description: 'Test Description', + itemIndex: 0, + manualLogging: false, + }); + + const result = await tool.func('test query'); + + expect(result).toBe('There was an error: "Execution was cancelled"'); + expect(executeWorkflowMock).not.toHaveBeenCalled(); + expect(contextWithRetryNode.logAiEvent).not.toHaveBeenCalled(); }); it('should handle abort signal during retry wait', async () => { @@ -905,6 +994,55 @@ describe('WorkflowTool::WorkflowToolService', () => { expect(result).toBe('There was an error: "Execution was cancelled"'); expect(sleepMock).toHaveBeenCalledWith(100, abortController.signal); expect(executeWorkflowMock).toHaveBeenCalledTimes(1); // Only first attempt + expect(contextWithRetryNode.logAiEvent).toHaveBeenCalledTimes(2); + expect(contextWithRetryNode.logAiEvent).toHaveBeenNthCalledWith( + 1, + 'ai-tool-called', + JSON.stringify({ + query: 'test query', + response: 'There was an error: "First attempt fails"', + }), + ); + expect(contextWithRetryNode.logAiEvent).toHaveBeenNthCalledWith( + 2, + 'ai-tool-called', + JSON.stringify({ + query: 'test query', + response: 'There was an error: "Execution was cancelled"', + }), + ); + }); + + it('should not emit ai-tool-called when retry wait is cancelled and manualLogging is false', async () => { + sleepMock.mockRejectedValue(new Error('Execution was cancelled')); + + const executeWorkflowMock = vi + .fn() + .mockRejectedValueOnce(new Error('First attempt fails')) + .mockResolvedValueOnce({ + data: [[{ json: { result: 'success' } }]], + executionId: 'success-exec-id', + }); + + const contextWithRetryNode = createAbortSignalContext( + executeWorkflowMock, + abortController.signal, + ); + + service = new WorkflowToolService(contextWithRetryNode); + const tool = await service.createTool({ + ctx: contextWithRetryNode, + name: 'Test Tool', + description: 'Test Description', + itemIndex: 0, + manualLogging: false, + }); + + const result = await tool.func('test query'); + + expect(result).toBe('There was an error: "Execution was cancelled"'); + expect(executeWorkflowMock).toHaveBeenCalledTimes(1); + expect(contextWithRetryNode.logAiEvent).not.toHaveBeenCalled(); }); it('should handle abort signal during execution', async () => { @@ -931,6 +1069,41 @@ describe('WorkflowTool::WorkflowToolService', () => { expect(result).toBe('There was an error: "Execution was cancelled"'); expect(executeWorkflowMock).toHaveBeenCalledTimes(1); + expect(contextWithRetryNode.logAiEvent).toHaveBeenCalledTimes(1); + expect(contextWithRetryNode.logAiEvent).toHaveBeenCalledWith( + 'ai-tool-called', + JSON.stringify({ + query: 'test query', + response: 'There was an error: "Execution was cancelled"', + }), + ); + }); + + it('should not emit ai-tool-called when execution is cancelled and manualLogging is false', async () => { + const executeWorkflowMock = vi.fn().mockImplementation(() => { + abortController.abort(); + throw new UnexpectedError('Workflow execution failed'); + }); + + const contextWithRetryNode = createAbortSignalContext( + executeWorkflowMock, + abortController.signal, + ); + + service = new WorkflowToolService(contextWithRetryNode); + const tool = await service.createTool({ + ctx: contextWithRetryNode, + name: 'Test Tool', + description: 'Test Description', + itemIndex: 0, + manualLogging: false, + }); + + const result = await tool.func('test query'); + + expect(result).toBe('There was an error: "Execution was cancelled"'); + expect(executeWorkflowMock).toHaveBeenCalledTimes(1); + expect(contextWithRetryNode.logAiEvent).not.toHaveBeenCalled(); }); it('should complete successfully if not aborted', async () => { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts index 01946b87ad3..5094805f9c3 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -26,7 +26,7 @@ import { parseErrorMetadata, } from 'n8n-workflow'; -import { createZodSchemaFromArgs, extractFromAIParameters } from '@n8n/ai-utilities'; +import { createZodSchemaFromArgs, extractFromAIParameters, logAiEvent } from '@n8n/ai-utilities'; import { sleep } from '@n8n/utils/sleep'; function isNodeExecutionData(data: unknown): data is INodeExecutionData[] { @@ -95,6 +95,21 @@ export class WorkflowToolService { } let lastError: ExecutionError | undefined; + const cancelledResponse = 'There was an error: "Execution was cancelled"'; + + const emitCancelledToolCall = ( + loggingContext: ISupplyDataFunctions | IExecuteFunctions, + outputRunIndex: number, + ) => { + if (!manualLogging) { + return; + } + const cancelledData: INodeExecutionData[] = [{ json: { error: cancelledResponse } }]; + void loggingContext.addOutputData(NodeConnectionTypes.AiTool, outputRunIndex, [ + cancelledData, + ]); + logAiEvent(loggingContext, 'ai-tool-called', { query, response: cancelledResponse }); + }; for (let tryIndex = 0; tryIndex < maxTries; tryIndex++) { const localRunIndex = runIndex++; @@ -115,7 +130,8 @@ export class WorkflowToolService { // Check if execution was cancelled before retry if (abortSignal?.aborted) { - return 'There was an error: "Execution was cancelled"'; + emitCancelledToolCall(context, localRunIndex); + return cancelledResponse; } if (tryIndex !== 0) { @@ -125,7 +141,8 @@ export class WorkflowToolService { try { await sleep(waitBetweenTries, abortSignal); } catch (abortError) { - return 'There was an error: "Execution was cancelled"'; + emitCancelledToolCall(context, localRunIndex); + return cancelledResponse; } } } @@ -167,7 +184,7 @@ export class WorkflowToolService { [responseData], metadata, ); - + logAiEvent(context, 'ai-tool-called', { query, response: processedResponse }); return processedResponse; } // If manualLogging is false we've been called by the engine and need @@ -181,7 +198,8 @@ export class WorkflowToolService { } catch (error) { // Check if error is due to cancellation if (abortSignal?.aborted) { - return 'There was an error: "Execution was cancelled"'; + emitCancelledToolCall(context, localRunIndex); + return cancelledResponse; } const executionError = error as ExecutionError; @@ -199,6 +217,7 @@ export class WorkflowToolService { [errorData], metadata, ); + logAiEvent(context, 'ai-tool-called', { query, response: errorResponse }); } if (tryIndex === maxTries - 1) {