diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index b898543d029..1f5a13b5737 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -31,6 +31,7 @@ const { mockGetCustomToolByIdOrTitle, mockGenerateInternalToken, mockResolveWorkspaceFileReference, + mockGetEffectiveDecryptedEnv, } = vi.hoisted(() => ({ mockIsHosted: { value: false }, mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, @@ -46,6 +47,7 @@ const { mockGetCustomToolByIdOrTitle: vi.fn(), mockGenerateInternalToken: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), + mockGetEffectiveDecryptedEnv: vi.fn(), })) const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPinnedIP @@ -107,6 +109,10 @@ vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({ getHostedKeyRateLimiter: () => mockRateLimiterFns, })) +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: (...args: unknown[]) => mockGetEffectiveDecryptedEnv(...args), +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args), })) @@ -234,6 +240,26 @@ vi.mock('@/tools/registry', () => { return { success: true, output: data } }, }, + test_env_ref_tool: { + id: 'test_env_ref_tool', + name: 'Test Env Reference Tool', + description: 'Accepts a user-only API key and an llm-writable note', + version: '1.0.0', + params: { + apiKey: { type: 'string', required: true, visibility: 'user-only' }, + note: { type: 'string', required: false, visibility: 'user-or-llm' }, + }, + request: { + url: '/api/tools/test/env-ref', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (p: any) => ({ apiKey: p.apiKey, note: p.note }), + }, + transformResponse: async (response: any) => { + const data = await response.json() + return { success: true, output: data } + }, + }, test_file_array_tool: { id: 'test_file_array_tool', name: 'Test File Array Tool', @@ -1259,6 +1285,184 @@ describe('Copilot OAuth Credential Enforcement', () => { }) }) +describe('Copilot Env Variable Reference Resolution', () => { + let cleanupEnvVars: () => void + + function mockJsonFetch() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve({ ok: true }), + text: () => Promise.resolve(JSON.stringify({ ok: true })), + clone: vi.fn().mockReturnThis(), + }) + global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch + return fetchMock + } + + function sentRequestBody(fetchMock: ReturnType): Record { + return JSON.parse(fetchMock.mock.calls[0][1]?.body as string) + } + + const copilotContext = () => + createToolExecutionContext({ + workspaceId: 'workspace-456', + userId: 'user-123', + copilotToolExecution: true, + } as any) + + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) + mockGetEffectiveDecryptedEnv.mockReset() + mockGetEffectiveDecryptedEnv.mockResolvedValue({ SENTRY_AUTH_TOKEN: 'sntrys_real_token' }) + }) + + afterEach(() => { + vi.resetAllMocks() + cleanupEnvVars() + }) + + it('resolves a whole-value {{VAR}} reference in a user-only param', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', 'workspace-456') + expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') + }) + + it('trims whitespace inside the braces like the executor resolver', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{ SENTRY_AUTH_TOKEN }}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') + }) + + it('never resolves references in llm-writable (user-or-llm) params', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}', note: '{{SENTRY_AUTH_TOKEN}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(sentRequestBody(fetchMock).note).toBe('{{SENTRY_AUTH_TOKEN}}') + }) + + it('leaves embedded references untouched in user-only params', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: 'Bearer {{SENTRY_AUTH_TOKEN}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(sentRequestBody(fetchMock).apiKey).toBe('Bearer {{SENTRY_AUTH_TOKEN}}') + expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled() + }) + + it('fails with a clear error before any request when the variable is missing', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{MISSING_VAR}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('MISSING_VAR') + expect(result.error).toContain('apiKey') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fails fast instead of forwarding the placeholder when user context is missing', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { + executionContext: createToolExecutionContext({ + workspaceId: 'workspace-456', + userId: undefined, + copilotToolExecution: true, + } as any), + } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('authenticated user context') + expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('explains the personal-only scope when a variable is missing without a workspace context', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{MISSING_VAR}}' }, + { + executionContext: createToolExecutionContext({ + workspaceId: undefined, + userId: 'user-123', + copilotToolExecution: true, + } as any), + } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('only personal variables are available') + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', undefined) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('does not resolve references outside copilot execution', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { executionContext: createToolExecutionContext({ userId: 'user-123' } as any) } + ) + + expect(result.success).toBe(true) + expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled() + expect(sentRequestBody(fetchMock).apiKey).toBe('{{SENTRY_AUTH_TOKEN}}') + }) + + it('never mutates the caller-owned params object (log-leak guard)', async () => { + mockJsonFetch() + const callerParams = { apiKey: '{{SENTRY_AUTH_TOKEN}}' } + + const result = await executeTool('test_env_ref_tool', callerParams, { + executionContext: copilotContext(), + }) + + expect(result.success).toBe(true) + expect(callerParams.apiKey).toBe('{{SENTRY_AUTH_TOKEN}}') + }) +}) + describe('Centralized Error Handling', () => { let cleanupEnvVars: () => void diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 1602aa1cb79..5e597c53422 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -33,6 +33,7 @@ import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-c import { isCustomTool, isMcpTool } from '@/executor/constants' import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' import type { ExecutionContext, UserFile } from '@/executor/types' +import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' import type { @@ -183,6 +184,71 @@ async function normalizeCopilotFileParams( } } +/** + * Resolves whole-value {{ENV_VAR}} references in user-only params for copilot + * tool executions. Chat agents never see secret values (the workspace VFS + * exposes env var names only), so they pass references; workflow runs resolve + * these in the executor, and this is the equivalent step for direct tool + * calls, delegating to the executor's resolver so both paths share one set of + * reference semantics. Resolution is deliberately restricted to params + * declared `visibility: 'user-only'` (API keys and other operator-supplied + * secrets) and to values that are exactly one reference, so LLM-writable + * params (URLs, headers, bodies) can never be used to extract secret values. + * + * Mutates only the given params object — callers pass the per-execution copy, + * never the copilot-side tool-call state, so decrypted values cannot leak + * into failure logs or persisted chat state. + */ +async function resolveCopilotEnvReferences( + tool: ToolConfig, + params: Record, + scope: ToolExecutionScope +): Promise { + if (!scope.copilotToolExecution) { + return + } + + const pending: Array<{ paramId: string; value: string }> = [] + for (const [paramId, paramDef] of Object.entries(tool.params || {})) { + if (paramDef?.visibility !== 'user-only') continue + const value = params[paramId] + if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { + pending.push({ paramId, value }) + } + } + + if (pending.length === 0) { + return + } + + if (!scope.userId) { + throw new Error( + `Cannot resolve environment variable reference in parameter "${pending[0].paramId}" without an authenticated user context.` + ) + } + + const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') + const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) + + for (const { paramId, value } of pending) { + const missingKeys: string[] = [] + const resolved = resolveEnvVarReferences(value, envVars, { + allowEmbedded: false, + missingKeys, + }) + if (missingKeys.length > 0) { + const scopeHint = scope.workspaceId + ? '' + : ' (no workspace context — only personal variables are available here)' + throw new Error( + `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + + `Check environment/variables.json for available variable names.` + ) + } + params[paramId] = resolved as string + } +} + function readExplicitCredentialSelector(params: Record): string | undefined { for (const key of ['credentialId', 'oauthCredential', 'credential'] as const) { const value = params[key] @@ -1025,6 +1091,7 @@ export async function executeTool( await normalizeCopilotFileParams(tool, contextParams, scope) normalizeCopilotCredentialParams(contextParams) enforceCopilotCredentialSelection(toolId, tool, contextParams, scope) + await resolveCopilotEnvReferences(tool, contextParams, scope) // Inject hosted API key if tool supports it and user didn't provide one const hostedKeyInfo = await injectHostedKeyIfNeeded(