diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index fb60d3c0649..24fd80608ce 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -1052,6 +1052,10 @@ After creating the block, you MUST validate it against every tool it references: 4. **Verify conditions** — each subBlock should only show for the operations that actually use it 5. **Verify `{Service}BlockMeta` is exported** with at least 7 templates, each having `icon`, `title`, `prompt`, `modules`, `category`, and `tags` 6. **If any tool outputs are still unknown**, explicitly tell the user instead of guessing block outputs +7. **Verify the tool execution boundary** — blocks never create or call API routes. Every referenced + tool must already be either a registered `InternalToolConfig.operation` or an absolute external + HTTP(S) `ToolConfig.request`. If transport needs to change, use the `add-tools` skill; do not add a + same-origin `/api/...` hop from the block. ## Option Lists: `selectorKey` or `options`, never a per-block fetcher diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index 3f3bbaccf9a..b8e69d488bb 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -60,6 +60,18 @@ apps/sim/tools/{service}/ ### Key Patterns +Choose the tool boundary before writing the declaration: + +- Use `InternalToolConfig.operation` for same-process Sim/provider work. Put the handler under + `apps/sim/lib/internal/{service}/execute-tool.ts` and register every ID in + `apps/sim/lib/internal/tool-operations/registry.server.ts`. +- Use `ToolConfig.request` only for an absolute external HTTP(S) provider endpoint. + +Never point a tool at `/api/...`, construct an absolute URL back to Sim, declare +`request.internal`, or add an API route merely to reuse code, normalize files, or authorize +resources. A real external/browser route and an in-process tool may share the same operation, but +neither calls the other. Follow the full transport and handler rules in the `add-tools` skill. + **types.ts:** ```typescript import type { ToolResponse } from '@/tools/types' @@ -82,7 +94,7 @@ export interface {Service}Response extends ToolResponse { **Tool file pattern:** ```typescript -export const {service}{Action}Tool: ToolConfig = { +export const {service}{Action}Tool: InternalToolConfig = { id: '{service}_{action}', name: '{Service} {Action}', description: '...', @@ -95,16 +107,11 @@ export const {service}{Action}Tool: ToolConfig = { // ... other params }, - request: { url, method, headers, body }, - - transformResponse: async (response) => { - const data = await response.json() - return { - success: true, - output: { - field: data.field ?? null, // Always handle nullables - }, - } + operation: { + input: (params) => ({ + accessToken: params.accessToken, + // Map only the semantic operation input. + }), }, outputs: { /* ... */ }, @@ -135,7 +142,8 @@ and leave the field unannotated. sent with their normal request semantics. A URL, domain, resource ID, control field, or opaque payload is not model-visible merely because the provider is AI-backed or may process the referenced resource later. -- **Text or structured content consumed by an AI model:** declare `request.modelInput` with +- **Text or structured content consumed by an AI model:** declare `request.modelInput` for an + external provider request or `operation.modelInput` for an in-process operation, with `mode: 'project'` and select only the exact model-visible fields. The shared executor replaces activated Sim secrets with canonical `{{NAME}}` labels before request formatting. For nested or JSON-string fields, use a small shared selector plus `applyProjected`; verify that selecting the @@ -144,20 +152,19 @@ and leave the field unannotated. top-level param in `request.modelInput`. Project the private copy before the existing request formatter parses it; keep formatter behavior deterministic when a whole-value placeholder is not valid in the serialized grammar. Do not introduce a second hard-rejection path. -- **Opaque model input owned by an authenticated internal route** such as inline audio, image, - video, or document bytes: add `privateProvenance` to a projected request, or use +- **Opaque model input owned by an in-process operation** such as inline audio, image, video, or + document bytes: add `privateProvenance` to the operation model-input declaration, or use `mode: 'private-provenance'` when there is no textual projection. Do not select storage keys, - paths, signed URLs, or ordinary remote URLs as byte provenance; the owning route must authorize - stored bytes independently at model egress. The route must call + paths, signed URLs, or ordinary remote URLs as byte provenance; the owning operation must + authorize stored bytes independently at model egress. The operation must call `validateOpaqueModelInputProvenance` before downloading or sending content to the model and must apply the workspace-file provenance guard before reading a persisted workspace file. - **Sim-owned durable storage or internal execution handoff** that can later enter a workflow/model (table cells, Agent memory, knowledge documents/chunks, workspace-file contents, or child-workflow - input): transport encrypted field-scoped provenance with `request.secretProvenance`. The - authenticated receiver validates the exact selection and scope, strips the private envelope, and - persists, imports, or propagates it at the owning boundary. Preserve shared legacy behavior for - headerless internal calls and rows/files whose provenance marker is `NULL`; never invent a - tool-local migration rule. + input): transport encrypted field-scoped provenance with `operation.secretProvenance`. The + operation validates the exact selection and trusted scope, then persists, imports, or propagates + it at the owning boundary. Preserve shared legacy behavior for rows/files whose provenance marker + is `NULL`; never invent a tool-local migration rule. Hard rules: @@ -166,7 +173,8 @@ Hard rules: transport and strips private metadata from functional results. - Never attach private provenance to an external URL or to `directExecution`. Project proven model-visible external fields with `request.modelInput`; otherwise preserve ordinary request - semantics. Use an authenticated internal route when encrypted provenance must cross the boundary. + semantics. Use a registered in-process operation when encrypted provenance must cross the + boundary. - Never sanitize arbitrary third-party tool results. Projection applies only to secrets activated by Sim's resolved-secret provenance for that execution/tool call. - Do not add provenance merely because a value is persisted, returned by a tool, or appears in a @@ -596,6 +604,10 @@ If creating V2 versions (API-aligned outputs): - [ ] Created `tools/{service}/` directory - [ ] Created `types.ts` with all interfaces - [ ] Created tool file for each operation +- [ ] Chose exactly one boundary per tool: registered `InternalToolConfig.operation` or absolute + external HTTP(S) `ToolConfig.request` +- [ ] No tool points to `/api/...`, constructs a URL back to Sim, declares `request.internal`, or + has an HTTP fallback for an in-process operation - [ ] All params have correct visibility - [ ] All nullable fields use `?? null` - [ ] All optional outputs have `optional: true` @@ -607,6 +619,8 @@ If creating V2 versions (API-aligned outputs): external resource locators and control inputs retain their request semantics - [ ] Confirmed ordinary third-party tool results are not generically sanitized - [ ] Added provenance compatibility and fail-closed boundary tests where applicable +- [ ] `bun run check:tool-request-boundary` passes +- [ ] Internal-operation registry completeness test passes for every operation-backed tool ### Block - [ ] Created `blocks/blocks/{service}.ts` @@ -721,7 +735,8 @@ interface UserFile { ### File Input Pattern (Uploads) -For tools that accept file uploads, **always route through an internal API endpoint** rather than calling external APIs directly. This ensures proper file content retrieval. +File authorization, normalization, storage reads, provider upload, and response mapping belong in a +registered in-process operation. Do not create an internal API route for file tools. #### 1. Block SubBlocks for File Input @@ -757,137 +772,36 @@ Use the basic/advanced mode pattern: #### 2. Normalize File Input in Block Config -In `tools.config.tool`, use `normalizeFileInput` to handle all input variants: +`tools.config.tool` selects the tool before variable resolution and must not mutate or coerce input. +Use `tools.config.params`, which runs after variable resolution, to normalize all file variants: ```typescript import { normalizeFileInput } from '@/blocks/utils' tools: { config: { - tool: (params) => { - // Normalize file from basic (uploadFile), advanced (fileRef), or legacy (fileContent) - const normalizedFile = normalizeFileInput( - params.uploadFile || params.fileRef || params.fileContent, - { single: true } - ) - if (normalizedFile) { - params.file = normalizedFile - } - return `{service}_${params.operation}` + tool: (params) => `{service}_${params.operation}`, + params: (params) => { + // Serialization collapses the basic/advanced pair into the canonical `file` key. + const normalizedFile = normalizeFileInput(params.file, { single: true }) + return normalizedFile ? { file: normalizedFile } : {} }, }, } ``` -#### 3. Create Special Internal Tool Execution Route - -Create `apps/sim/app/api/tools/{service}/{action}/route.ts`. This raw route pattern is only for an integration's provider-execution boundary when it needs special file normalization, large-body handling, or protocol behavior. It is not the pattern for CRUD or other operations on protected Sim resources. For those, use the `migrate-application-operation` skill and an authorized application use case with the ordinary internal/v2 route builders. - -Internal tool routes are HTTP boundaries and follow the same contract policy as public routes — define the request/response shape in `apps/sim/lib/api/contracts/tools/{service}.ts` (or an existing aggregate) and validate with canonical helpers from `@/lib/api/server`. Never write a route-local Zod schema. Authenticate and perform cheap admission before parsing or downloading files. +#### 3. Define and register the in-process operation ```typescript -// apps/sim/lib/api/contracts/tools/{service}.ts -import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts' -import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' - -export const {service}UploadBodySchema = z.object({ - accessToken: z.string(), - file: FileInputSchema.optional().nullable(), - fileContent: z.string().optional().nullable(), - // ... other params -}) - -export const {service}UploadResponseSchema = z.object({ - success: z.boolean(), - output: z.object({ id: z.string(), url: z.string() }).optional(), - error: z.string().optional(), -}) - -export const {service}UploadContract = defineRouteContract({ - method: 'POST', - path: '/api/tools/{service}/upload', - body: {service}UploadBodySchema, - response: { mode: 'json', schema: {service}UploadResponseSchema }, -}) - -export type {Service}UploadBody = z.input -export type {Service}UploadResponse = z.output -``` - -```typescript -// apps/sim/app/api/tools/{service}/upload/route.ts -import { createLogger } from '@sim/logger' -import { NextResponse, type NextRequest } from 'next/server' -import { {service}UploadContract } from '@/lib/api/contracts/tools/{service}' -import { parseRequest } from '@/lib/api/server' -import { checkInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { type RawFileInput } from '@/lib/uploads/utils/file-schemas' -import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' - -const logger = createLogger('{Service}UploadAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - // Auth always runs BEFORE parseRequest — never validate untrusted input before authenticating. - const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { - return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest({service}UploadContract, request, {}) - if (!parsed.success) return parsed.response - const data = parsed.data.body - - let fileBuffer: Buffer - let fileName: string - - // Prefer UserFile input, fall back to legacy base64 - if (data.file) { - const userFiles = processFilesToUserFiles([data.file as RawFileInput], requestId, logger) - if (userFiles.length === 0) { - return NextResponse.json({ success: false, error: 'Invalid file' }, { status: 400 }) - } - const userFile = userFiles[0] - fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) - fileName = userFile.name - } else if (data.fileContent) { - // Legacy: base64 string (backwards compatibility) - fileBuffer = Buffer.from(data.fileContent, 'base64') - fileName = 'file' - } else { - return NextResponse.json({ success: false, error: 'File required' }, { status: 400 }) - } - - // Now call external API with fileBuffer - const response = await fetch('https://api.{service}.com/upload', { - method: 'POST', - headers: { Authorization: `Bearer ${data.accessToken}` }, - body: new Uint8Array(fileBuffer), // Convert Buffer for fetch - }) - - // ... handle response -}) -``` - -#### 4. Update Tool to Use Internal Route - -```typescript -export const {service}UploadTool: ToolConfig = { +export const {service}UploadTool: InternalToolConfig = { id: '{service}_upload', // ... params: { file: { type: 'file', required: false, visibility: 'user-or-llm' }, fileContent: { type: 'string', required: false, visibility: 'hidden' }, // Legacy }, - request: { - url: '/api/tools/{service}/upload', // Internal route - method: 'POST', - body: (params) => ({ + operation: { + input: (params) => ({ accessToken: params.accessToken, file: params.file, fileContent: params.fileContent, @@ -896,6 +810,13 @@ export const {service}UploadTool: ToolConfig = { } ``` +Implement `apps/sim/lib/internal/{service}/execute-tool.ts` and keep the file/provider work in typed +operations beside it. The handler validates `request.input`, derives storage authority only from +trusted `request.context`, authorizes every stored file before reading bytes, forwards +`request.signal`, enforces declared and actual byte caps, and returns the canonical tool response. +Register `{service}_upload` in `apps/sim/lib/internal/tool-operations/registry.server.ts` and add a +registry/direct-handler test. There is no HTTP fallback. + ### File Output Pattern (Downloads) For tools that return files, use `FileToolProcessor` to store files and return `UserFile` objects. @@ -923,11 +844,11 @@ transformResponse: async (response, context) => { } ``` -#### In API Route (for complex file handling) +#### In the operation handler (for complex file handling) ```typescript -// Return file data that FileToolProcessor can handle -return NextResponse.json({ +// Return file data that FileToolProcessor can handle. No API route is involved. +return Response.json({ success: true, output: { file: { diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md index 6ae100ce86b..0b7a5cc1f6c 100644 --- a/.agents/skills/add-tools/SKILL.md +++ b/.agents/skills/add-tools/SKILL.md @@ -42,7 +42,29 @@ tools/{service}/ ## Tool Configuration Structure -Every tool MUST follow this exact structure: +### Choose the execution boundary first + +Every tool must use exactly one of these configurations: + +- **In-process operation (preferred):** use `InternalToolConfig` when the executor and the + implementation run in the same Sim process/trust/runtime plane. Materialize typed + `operation.input`, implement the handler under `apps/sim/lib/internal/{service}/execute-tool.ts`, + and register every tool ID in `apps/sim/lib/internal/tool-operations/registry.server.ts`. +- **External provider request:** use `ToolConfig.request` only when the URL is an absolute external + HTTP(S) provider endpoint. + +Never set a tool URL to `/api/...`, construct an absolute URL back to Sim, declare +`request.internal`, import a route module, or create an API route merely to normalize files, +authorize access, or reuse server code. A real browser/API route may remain as a thin adapter, but +the route and the tool must call the same operation directly. A true cross-process/capability +boundary uses an explicit server client and is not disguised as a tool self-hop. + +For protected Sim resources, the internal handler calls the domain's authorized application use +case with trusted execution context; use the `migrate-application-operation` skill. + +### External provider request + +Use this structure only for an absolute external provider API: ```typescript import type { {ServiceName}{Action}Params } from '@/tools/{service}/types' @@ -126,6 +148,38 @@ export const {serviceName}{Action}Tool: ToolConfig< } ``` +### In-process operation + +```typescript +import type { InternalToolConfig } from '@/tools/types' + +export const {serviceName}{Action}Tool: InternalToolConfig< + {ServiceName}{Action}Params, + {ServiceName}{Action}Response +> = { + id: '{service}_{action}', + name: '{Service} {Action}', + description: 'Brief description', + version: '1.0.0', + params: { + // Same canonical metadata as an external tool. + }, + operation: { + input: (params) => ({ + // Map resolved tool params into the typed semantic operation input. + }), + }, + outputs: { + // Define each output field. + }, +} +``` + +The registered handler accepts `InternalToolOperationCall`, validates `request.input`, uses only +trusted `request.context` for authority, forwards `request.signal`, and returns the same bounded +`Response` contract expected by the tool executor. It has no URL, method, request headers, fetch +fallback, or caller-controlled `_context` authority. + ## Critical Rules for Parameters ### Visibility Options @@ -149,17 +203,17 @@ export const {serviceName}{Action}Tool: ToolConfig< - Leave ordinary external API inputs and third-party results unchanged. Add provenance handling only when an exact field is proven to cross a Sim model, durable-storage, or internal-execution boundary. -- Project AI-consumed text/structured fields with the smallest exact `request.modelInput` selector. +- Project AI-consumed text/structured fields with the smallest exact model-input selector: + `request.modelInput` for an external request or `operation.modelInput` for an in-process operation. - Treat URLs, domains, resource IDs, and control fields as ordinary request values unless the exact field is proven model-visible. For serialized external model content, project the serialized top-level param through `request.modelInput` before the existing formatter parses it; do not add a separate hard-rejection mechanism. -- For authenticated internal routes, use `privateProvenance` for actual inline/raw model bytes or - `request.secretProvenance` for durable writes and execution handoffs. Do not treat a storage key, +- For in-process operations, use `operation.modelInput` for actual inline/raw model bytes or + `operation.secretProvenance` for durable writes and execution handoffs. Do not treat a storage key, path, signed URL, or remote URL as provenance for fetched bytes; authorize tracked stored bytes at - the owning model-egress boundary. Authenticate first, validate the exact selection and scope, - strip the private envelope, then import or propagate provenance at the receiving boundary. - Preserve documented headerless legacy behavior. + the owning model-egress boundary. Validate the exact selection and trusted scope, then import or + propagate provenance at the receiving operation boundary. - Never substitute secret plaintext into source, serialize plaintext provenance, hand-roll private headers, or blanket-sanitize tool results. - Add focused tests for named projection, identical unproven public text, malformed/incomplete @@ -466,6 +520,10 @@ All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet` ## Checklist Before Finishing - [ ] All tool IDs use snake_case +- [ ] Chose exactly one boundary: registered `InternalToolConfig.operation` or absolute external + HTTP(S) `ToolConfig.request` +- [ ] No tool request points to `/api/...`, constructs a URL back to Sim, or declares + `request.internal` - [ ] All params have explicit `required: true` or `required: false` - [ ] All params have appropriate `visibility` - [ ] All nullable response fields use `?? null` @@ -492,7 +550,9 @@ After creating all tools, you MUST validate every tool before finishing: - All required params are marked `required: true` - All optional params are marked `required: false` - Param types match the API (string, number, boolean, json) - - Request URL, method, headers, and body match the API spec + - For external tools, request URL, method, headers, and body match the provider API spec + - For internal tools, `operation.input` matches the handler schema and the handler is registered + with no HTTP fallback - `transformResponse` extracts the correct fields from the API response - All output fields match what the API actually returns - No fields are missing from outputs that the API provides diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index bfec917d60b..f3e776c848f 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -505,6 +505,11 @@ Two rules the checks enforce: ## Checklist +Webhook and polling routes are legitimate external ingress boundaries. They must not call this +Sim app's own API routes to reuse provider or business logic. Extract the shared provider operation +or authorized application use case and call it directly from the trigger handler and any other +server adapter. HTTP is reserved for an actual cross-process/capability boundary. + ### Trigger Definition - [ ] Created `utils.ts` with options, instructions, extra fields, and output builders - [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT diff --git a/AGENTS.md b/AGENTS.md index ae4d76e0de0..b13fecd2b96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,14 @@ You are a professional software engineer. All code must follow best practices: a - Never substitute a billing owner, uploader, creator, or API-key owner for the acting principal. Fail fast when the identity model or operation policy cannot express the caller. - Use the `migrate-application-operation` skill whenever creating or migrating a protected endpoint, tool command, or resource method. +### Tool Execution Boundary + +- A tool has exactly one execution boundary. Use `InternalToolConfig.operation` when the executor can call the implementation in the same process and trust/runtime plane. Put the server handler under `apps/sim/lib/internal//execute-tool.ts` and register it in `apps/sim/lib/internal/tool-operations/registry.server.ts`. +- `ToolConfig.request` is only for absolute external HTTP(S) provider APIs. A tool definition must never point at `/api/...`, construct an absolute URL back to this Sim app, or declare an `internal` request policy. Do not add a same-origin route merely to reuse code, normalize files, or perform authorization. +- Real browser/API ingress and real cross-process capability boundaries may remain HTTP. Their route and any in-process tool adapter call the same application/provider operation; neither calls the other, and tool code never imports route modules. +- Protected Sim resources still enter through authorized application use cases. The internal tool handler is a trusted surface adapter, not an authorization or database bypass. +- `bun run check:tool-request-boundary` rejects detectable tool self-hops, the external request formatter rejects relative URLs at runtime, and the internal-operation registry test requires every operation-backed tool to have a loadable handler. + ### Root Structure ``` diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts index 7ccdbd0e308..407f9b48d2f 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts @@ -38,7 +38,7 @@ function resolveContentProvenance( headers: request.headers, payload, authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request, principal, workspaceId), + userId: internalKnowledgeProvenanceUserId(request.headers, principal, workspaceId), ...(workspaceId ? { workspaceId } : {}), selectionKeys: includeContent ? ['chunk-content'] : [], }) @@ -65,7 +65,7 @@ export const GET = defineInternalJsonRoute({ finalizeKnowledgePersistedResponse({ headers: request.headers, authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId), + userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId), workspaceId: result.workspaceId, body, chunks: [ @@ -102,7 +102,7 @@ export const PUT = defineInternalJsonRoute({ finalizeKnowledgePersistedResponse({ headers: request.headers, authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId), + userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId), workspaceId: result.workspaceId, body, chunks: [ diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index b80777a372f..8123a11cc1a 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -39,7 +39,7 @@ function resolveContentProvenance( headers: request.headers, payload, authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request, principal, workspaceId), + userId: internalKnowledgeProvenanceUserId(request.headers, principal, workspaceId), ...(workspaceId ? { workspaceId } : {}), selectionKeys: includeContent ? ['chunk-content'] : [], }) @@ -70,7 +70,7 @@ export const GET = defineInternalJsonRoute({ finalizeKnowledgePersistedResponse({ headers: request.headers, authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId), + userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId), workspaceId: result.workspaceId, body, chunks: result.chunks.map((chunk) => ({ diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index d0176412b12..9bc8d8999ad 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -45,7 +45,7 @@ export const GET = defineInternalJsonRoute({ finalizeKnowledgePersistedResponse({ headers: request.headers, authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId), + userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId), workspaceId: result.workspaceId, body, documents: [ diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 2cefd4f01e5..ce22bfe43ac 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -60,7 +60,7 @@ export const GET = defineInternalJsonRoute({ finalizeKnowledgePersistedResponse({ headers: request.headers, authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId), + userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId), workspaceId: result.workspaceId, body, documents: result.documents.map((document) => ({ diff --git a/apps/sim/background/async-preprocessing-correlation.test.ts b/apps/sim/background/async-preprocessing-correlation.test.ts index 2f414d69aaa..01aa600083a 100644 --- a/apps/sim/background/async-preprocessing-correlation.test.ts +++ b/apps/sim/background/async-preprocessing-correlation.test.ts @@ -227,6 +227,74 @@ describe('async preprocessing correlation threading', () => { ) }) + it.each([ + { + name: 'workspace API key', + serializedPrincipal: { + version: 1 as const, + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + }, + isPublicApiAccess: false, + }, + { + name: 'public API system', + serializedPrincipal: { + version: 1 as const, + principal: { + kind: 'system' as const, + serviceId: 'public_api' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + }, + isPublicApiAccess: true, + }, + ])( + 'restores the exact serialized $name principal before Trigger worker execution', + async ({ serializedPrincipal, isPublicApiAccess }) => { + mockPreprocessExecution.mockResolvedValueOnce({ + success: true, + actorUserId: 'actor-1', + workflowRecord: { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', + variables: {}, + }, + billingAttribution, + executionTimeout: {}, + }) + mockExecuteWorkflowCore.mockResolvedValueOnce({ + success: true, + status: 'success', + output: { ok: true }, + metadata: { duration: 10, userId: 'actor-1' }, + }) + + await executeWorkflowJob({ + principal: serializedPrincipal, + workflowId: 'workflow-1', + userId: 'actor-1', + workspaceId: 'workspace-1', + billingAttribution, + triggerType: 'api', + executionId: `execution-${serializedPrincipal.principal.kind}`, + requestId: `request-${serializedPrincipal.principal.kind}`, + isPublicApiAccess, + }) + + const executionMetadata = mockExecutionSnapshot.mock.calls[0]?.[0] + expect(executionMetadata.userId).toBe('actor-1') + expect(executionMetadata.principal).toEqual(serializedPrincipal.principal) + expect(executionMetadata.isPublicApiAccess).toBe(isPublicApiAccess) + expect(executionMetadata.principal).not.toHaveProperty('userId') + } + ) + it('restores a legacy authenticated workflow job as its recorded user actor', async () => { mockPreprocessExecution.mockResolvedValueOnce({ success: true, @@ -545,6 +613,14 @@ describe('async preprocessing correlation threading', () => { loggingSession, }) ) + const executionMetadata = mockExecutionSnapshot.mock.calls[0]?.[0] + expect(executionMetadata.userId).toBe('actor-2') + expect(executionMetadata.principal).toEqual({ + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }) }) it('passes workflow correlation into preprocessing', async () => { diff --git a/apps/sim/background/webhook-execution.test.ts b/apps/sim/background/webhook-execution.test.ts index 1de01f40684..b81cc566dba 100644 --- a/apps/sim/background/webhook-execution.test.ts +++ b/apps/sim/background/webhook-execution.test.ts @@ -346,6 +346,51 @@ describe('executeWebhookJob fault vs error handling', () => { ) }) + it('restores the exact serialized webhook principal without substituting the billing actor', async () => { + const serializedPrincipal = { + version: 1 as const, + principal: { + kind: 'system' as const, + serviceId: 'webhook' as const, + webhookId: 'webhook-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + provider: 'slack', + subject: { + kind: 'external_user' as const, + provider: 'slack', + tenantId: 'team-1', + subjectId: 'slack-user-1', + }, + }, + } + mockExecuteWorkflowCore.mockResolvedValueOnce({ + success: true, + status: 'completed', + output: {}, + logs: [], + executionState: { + blockStates: {}, + executedBlocks: [], + blockLogs: [], + decisions: {}, + completedLoops: [], + activeExecutionPath: [], + }, + }) + + await executeWebhookJob({ + ...payload, + provider: 'slack', + principal: serializedPrincipal, + }) + + const executionMetadata = mockExecutionSnapshot.mock.calls[0]?.[0] + expect(executionMetadata.userId).toBe('user-1') + expect(executionMetadata.principal).toEqual(serializedPrincipal.principal) + expect(executionMetadata.principal).not.toHaveProperty('userId') + }) + it('persists the reconstructed legacy principal on setup retries', async () => { executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValueOnce({ success: false, diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 46a2b825c86..5d7591cc673 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -3688,9 +3688,11 @@ describe('AgentBlockHandler', () => { expect(mockDiscoverMcpServerToolsAsExecutor).toHaveBeenCalledWith( expect.objectContaining({ - userId: contextWithWorkspace.userId, workspaceId: 'test-workspace-123', - workflowId: 'test-workflow-456', + context: expect.objectContaining({ + userId: contextWithWorkspace.userId, + workflowId: 'test-workflow-456', + }), serverId: 'mcp-legacy-server', }) ) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 4dd75851d61..3664fb82227 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -1283,10 +1283,14 @@ export class AgentBlockHandler implements BlockHandler { } return discoverMcpServerToolsAsExecutor({ - userId: ctx.userId, workspaceId: ctx.workspaceId, - workflowId: ctx.workflowId, - ...(ctx.executionId ? { executionId: ctx.executionId } : {}), + context: { + workflowId: ctx.workflowId, + workspaceId: ctx.workspaceId, + executionId: ctx.executionId, + userId: ctx.userId, + executorDelegationOrigin: ctx.executorDelegationOrigin, + }, serverId, signal: ctx.abortSignal, }) @@ -1349,6 +1353,7 @@ export class AgentBlockHandler implements BlockHandler { workspaceId: ctx.workspaceId, executionId: ctx.executionId, userId: ctx.userId, + executorDelegationOrigin: ctx.executorDelegationOrigin, }, toolIndex, resolveCustomBlockBinding: (blockType: string) => diff --git a/apps/sim/executor/handlers/pi/local/sim-tools.ts b/apps/sim/executor/handlers/pi/local/sim-tools.ts index 35c5961e0f3..483eb87f0a6 100644 --- a/apps/sim/executor/handlers/pi/local/sim-tools.ts +++ b/apps/sim/executor/handlers/pi/local/sim-tools.ts @@ -220,6 +220,7 @@ export async function buildSimToolSpecs( workspaceId: ctx.workspaceId, executionId: ctx.executionId, userId: ctx.userId, + executorDelegationOrigin: ctx.executorDelegationOrigin, }, resolveCustomBlockBinding: (blockType: string) => resolveCustomBlockToolBinding(blockType, ctx.workspaceId), diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index cfc90325e68..b1dc669b4bf 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -229,8 +229,16 @@ describe('WorkflowBlockHandler', () => { mockContext = { workflowId: 'parent-workflow-id', + executionId: 'parent-execution-id', userId: 'user-1', principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + executorDelegationOrigin: { + subjectUserId: 'user-1', + workflowId: 'parent-workflow-id', + executionId: 'parent-execution-id', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'parent-workflow-id', mode: 'draft' }, + }, blockStates: new Map(), blockLogs: [], metadata: { @@ -438,6 +446,7 @@ describe('WorkflowBlockHandler', () => { workflowId: 'parent-workflow-id', executionId: 'parent-execution-id', principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'parent-workflow-id', mode: 'draft' }, }, }) ) @@ -2096,6 +2105,7 @@ describe('WorkflowBlockHandler', () => { workflowId: 'parent-workflow-id', executionId: 'parent-execution-id', principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'parent-workflow-id', mode: 'draft' }, }, }) ) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 74a5f715995..c8a29c2fe8d 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -1,4 +1,3 @@ -import { resolvePrincipalSubject } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { findCause, getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -330,20 +329,18 @@ export class WorkflowBlockHandler implements BlockHandler { if (!ctx.principal) { throw new Error('Workflow child loading requires an execution principal') } - const principalSubject = resolvePrincipalSubject(ctx.principal) - const workflowReadDelegationOrigin: ExecutorDelegationOrigin = isCustomBlock - ? { - ...(loadUserId ? { subjectUserId: loadUserId } : {}), - workflowId, - } - : (ctx.executorDelegationOrigin ?? { - ...(principalSubject?.kind === 'sim_user' - ? { subjectUserId: principalSubject.userId } - : {}), - workflowId: ctx.workflowId, - ...(ctx.executionId ? { executionId: ctx.executionId } : {}), - principal: ctx.principal, - }) + let workflowReadDelegationOrigin: ExecutorDelegationOrigin + if (isCustomBlock) { + workflowReadDelegationOrigin = { + ...(loadUserId ? { subjectUserId: loadUserId } : {}), + workflowId, + } + } else { + if (!ctx.executorDelegationOrigin) { + throw new Error('Child workflow loading requires executor delegation authority') + } + workflowReadDelegationOrigin = ctx.executorDelegationOrigin + } if (!isCustomBlock) childExecutorDelegationOrigin = workflowReadDelegationOrigin // A custom block runs the source's latest deployment; if the source has been // undeployed there's nothing to run. `BoundarySafeError` marks the message as diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 08cd4ad311e..7a2046fdb5a 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -372,6 +372,8 @@ export interface SecureFetchOptions { stripAuthOnRedirect?: boolean /** Omit for the historical behavior used by existing workflows. */ redirectPolicy?: HttpRedirectPolicy + /** Rejects a redirect target before DNS resolution or a follow-up request is attempted. */ + assertRedirectTarget?: (url: string) => void /** * Pre-validated, IP-pinned `http://` proxy URL (see {@link validateAndPinProxyUrl}). * When set, the connection routes through this proxy and target-IP pinning is @@ -1059,6 +1061,12 @@ export async function secureFetchWithPinnedIP( res.resume() const redirectUrl = resolveRedirectUrl(url, location) + try { + options.assertRedirectTarget?.(redirectUrl) + } catch (error) { + settledReject(error) + return + } validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp }) .then((validation) => { if (!validation.isValid) { diff --git a/apps/sim/lib/core/security/pinned-redirect-replay.server.test.ts b/apps/sim/lib/core/security/pinned-redirect-replay.server.test.ts index 5d0141add32..cccf9ddfa8d 100644 --- a/apps/sim/lib/core/security/pinned-redirect-replay.server.test.ts +++ b/apps/sim/lib/core/security/pinned-redirect-replay.server.test.ts @@ -56,6 +56,29 @@ async function startRecordingServer(hops: RecordedHop[]): Promise { } describe('secureFetchWithPinnedIP redirect replay', () => { + it('rejects a redirect target before following it', async () => { + const hops: RecordedHop[] = [] + const target = await startRecordingServer(hops) + const origin = await startServer((req, res) => { + req.resume() + res.writeHead(302, { location: `${target}/after` }) + res.end() + }) + const assertRedirectTarget = vi.fn((url: string) => { + if (url === `${target}/after`) throw new Error('redirect target rejected') + }) + + await expect( + secureFetchWithPinnedIP(origin, '127.0.0.1', { + allowHttp: true, + assertRedirectTarget, + }) + ).rejects.toThrow('redirect target rejected') + + expect(assertRedirectTarget).toHaveBeenCalledWith(`${target}/after`) + expect(hops).toEqual([]) + }) + it('preserves historical replay when no redirect policy is present', async () => { const hops: RecordedHop[] = [] const target = await startRecordingServer(hops) diff --git a/apps/sim/lib/custom-tools/application/use-cases.test.ts b/apps/sim/lib/custom-tools/application/use-cases.test.ts index a0f38f4e7b4..98acac58fe4 100644 --- a/apps/sim/lib/custom-tools/application/use-cases.test.ts +++ b/apps/sim/lib/custom-tools/application/use-cases.test.ts @@ -131,6 +131,43 @@ describe('custom tool application use cases', () => { expect(mocks.audit).not.toHaveBeenCalled() }) + it('authorizes an actorless deployment without enabling personal fallback', async () => { + const result = await readAvailableCustomToolByIdOrTitleUseCase.execute({ + principal: executorPrincipal({ + subjectUserId: undefined, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: workspace.workspaceId, + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, + }, + }), + input: { + workspaceId: workspace.workspaceId, + identifier: tool.id, + lookup: 'id_or_title', + }, + }) + + expect(result).toEqual({ tool }) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getAvailableTool).toHaveBeenCalledWith({ + identifier: tool.id, + workspaceId: workspace.workspaceId, + lookup: 'id_or_title', + }) + }) + it('conceals a workspace assertion outside the delegated workspace before lookup', async () => { mocks.loadContext.mockResolvedValueOnce({ ...workspace, workspaceId: 'workspace-2' }) diff --git a/apps/sim/lib/custom-tools/application/use-cases.ts b/apps/sim/lib/custom-tools/application/use-cases.ts index eb91230c3a8..177b6e379b1 100644 --- a/apps/sim/lib/custom-tools/application/use-cases.ts +++ b/apps/sim/lib/custom-tools/application/use-cases.ts @@ -3,6 +3,7 @@ import { type Principal, requirePrincipalSubjectUserId, resolvePrincipalAttribution, + resolvePrincipalSubject, } from '@sim/auth/principal' import type { customTools } from '@sim/db/schema' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' @@ -162,9 +163,10 @@ export const readAvailableCustomToolByIdOrTitleUseCase = defineAuthorizedWorkspa resolveWorkspaceContext(input.workspaceId), authorizationOptions, async execute({ principal, input, context }) { + const subject = resolvePrincipalSubject(principal) const tool = await getAvailableCustomTool({ identifier: input.identifier, - userId: requirePrincipalSubjectUserId(principal), + ...(subject?.kind === 'sim_user' ? { userId: subject.userId } : {}), workspaceId: context.workspaceId, lookup: input.lookup, }) diff --git a/apps/sim/lib/execution/payloads/materialization.server.test.ts b/apps/sim/lib/execution/payloads/materialization.server.test.ts index c2a649e2e9d..7b55b117813 100644 --- a/apps/sim/lib/execution/payloads/materialization.server.test.ts +++ b/apps/sim/lib/execution/payloads/materialization.server.test.ts @@ -3,10 +3,12 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDownloadServableFileFromStorage, mockVerifyFileAccess } = vi.hoisted(() => ({ - mockDownloadServableFileFromStorage: vi.fn(), - mockVerifyFileAccess: vi.fn(), -})) +const { mockDownloadServableFileFromStorage, mockReadWorkspaceFileByKey, mockVerifyFileAccess } = + vi.hoisted(() => ({ + mockDownloadServableFileFromStorage: vi.fn(), + mockReadWorkspaceFileByKey: vi.fn(), + mockVerifyFileAccess: vi.fn(), + })) vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ downloadServableFileFromStorage: mockDownloadServableFileFromStorage, @@ -16,6 +18,10 @@ vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFileAccess, })) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', () => ({ + readWorkspaceFileRecordByKey: { execute: mockReadWorkspaceFileByKey }, +})) + import { readUserFileContent } from '@/lib/execution/payloads/materialization.server' import type { UserFile } from '@/executor/types' @@ -36,6 +42,7 @@ describe('readUserFileContent', () => { vi.clearAllMocks() generatedPdf.size = PDF_SOURCE.length mockVerifyFileAccess.mockResolvedValue(true) + mockReadWorkspaceFileByKey.mockResolvedValue({ file: { id: 'file-1' } }) mockDownloadServableFileFromStorage.mockResolvedValue({ buffer: PDF_BYTES, contentType: 'application/pdf', @@ -53,4 +60,144 @@ describe('readUserFileContent', () => { expect(content).not.toBe(PDF_SOURCE.toString('base64')) expect(generatedPdf.size).toBe(PDF_BYTES.length) }) + + it('authorizes execution-scoped files without inventing a human subject', async () => { + const executionFile: UserFile = { + id: 'file-2', + name: 'result.txt', + url: '', + size: 6, + type: 'text/plain', + key: 'execution/workspace-1/workflow-1/execution-1/result.txt', + context: 'execution', + } + mockDownloadServableFileFromStorage.mockResolvedValueOnce({ buffer: Buffer.from('result') }) + + await expect( + readUserFileContent(executionFile, { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + encoding: 'text', + }) + ).resolves.toBe('result') + + expect(mockVerifyFileAccess).not.toHaveBeenCalled() + }) + + it.each(['profile-pictures', 'og-images', 'workspace-logos'] as const)( + 'authorizes actorless reads from the trusted public %s context', + async (context) => { + const publicFile: UserFile = { + id: 'public-file', + name: 'public.png', + url: '', + size: 6, + type: 'image/png', + key: `${context}/public.png`, + context, + } + mockDownloadServableFileFromStorage.mockResolvedValueOnce({ buffer: Buffer.from('public') }) + + await expect(readUserFileContent(publicFile, { encoding: 'text' })).resolves.toBe('public') + + expect(mockVerifyFileAccess).not.toHaveBeenCalled() + expect(mockReadWorkspaceFileByKey).not.toHaveBeenCalled() + } + ) + + it('does not let an actorless caller relabel a private key as public', async () => { + const relabeledFile: UserFile = { + id: 'private-file', + name: 'private.txt', + url: '', + size: 7, + type: 'text/plain', + key: 'workspace/workspace-1/private.txt', + context: 'og-images', + } + + await expect(readUserFileContent(relabeledFile, { encoding: 'text' })).rejects.toThrow( + 'File context does not match its storage key.' + ) + + expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled() + expect(mockVerifyFileAccess).not.toHaveBeenCalled() + }) + + it('authorizes workspace files with the preserved actorless deployment principal', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + workspaceId: 'workspace-1', + delegationId: 'function-1', + audience: 'sim:function-executions', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + }, + }, + } + + await readUserFileContent(generatedPdf, { + principal, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + requestId: 'request-1', + encoding: 'base64', + }) + + expect(mockVerifyFileAccess).not.toHaveBeenCalled() + expect(mockReadWorkspaceFileByKey).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + key: generatedPdf.key, + assertedWorkspaceId: 'workspace-1', + }, + principal: expect.objectContaining({ + audience: 'sim:workspace-files', + delegationContext: principal.delegationContext, + }), + }) + ) + }) + + it('authorizes an exact workspace storage key with the workspace-key principal', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await readUserFileContent(generatedPdf, { + principal, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + encoding: 'base64', + }) + + expect(mockVerifyFileAccess).not.toHaveBeenCalled() + expect(mockReadWorkspaceFileByKey).toHaveBeenCalledWith({ + principal, + input: { + key: generatedPdf.key, + assertedWorkspaceId: 'workspace-1', + }, + }) + }) }) diff --git a/apps/sim/lib/execution/payloads/materialization.server.ts b/apps/sim/lib/execution/payloads/materialization.server.ts index ec7c7bfb82e..ec3f7ef2509 100644 --- a/apps/sim/lib/execution/payloads/materialization.server.ts +++ b/apps/sim/lib/execution/payloads/materialization.server.ts @@ -1,5 +1,7 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger, type Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' import { @@ -21,13 +23,17 @@ import { bufferToBase64, inferContextFromKey, isGeneratedDocumentSourceType, + isPublicStorageContext, } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { rebindWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { readWorkspaceFileRecordByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key' import type { UserFile } from '@/executor/types' const logger = createLogger('ExecutionPayloadMaterialization') export interface ExecutionMaterializationContext { + principal?: Principal workflowId?: string workspaceId?: string executionId?: string @@ -244,7 +250,7 @@ function assertExecutionFileScope(key: string, options: ExecutionMaterialization } } -function getVerifiedStorageContext(file: UserFile): StorageContext { +function getVerifiedStorageContext(file: Pick): StorageContext { if (!file.key) { throw new Error('File content requires a storage key.') } @@ -258,13 +264,48 @@ function getVerifiedStorageContext(file: UserFile): StorageContext { } export async function assertUserFileContentAccess( - file: UserFile, + file: Pick, options: ExecutionMaterializationContext ): Promise { const context = getVerifiedStorageContext(file) if (context === 'execution') { assertExecutionFileScope(file.key, options) + return + } + + if (isPublicStorageContext(context)) { + return + } + + if (context === 'workspace' && options.principal && options.workspaceId) { + const principal = + options.principal.kind === 'delegated' + ? rebindWorkspaceFileDelegatedPrincipal({ + principal: options.principal, + workspaceId: options.workspaceId, + delegationId: `execution-file-read:${options.requestId ?? 'unknown'}`, + ...(options.principal.resourceScope?.fileId + ? { fileId: options.principal.resourceScope.fileId } + : {}), + ...(options.principal.resourceScope?.chatId + ? { chatId: options.principal.resourceScope.chatId } + : {}), + ...(options.executionId ? { executionId: options.executionId } : {}), + }) + : options.principal + try { + await readWorkspaceFileRecordByKey.execute({ + principal, + input: { + key: file.key, + assertedWorkspaceId: options.workspaceId, + }, + }) + return + } catch (error) { + if (!(error instanceof OrchestrationError && error.code === 'not_found')) throw error + } } if (!options.userId) { diff --git a/apps/sim/lib/function-execution/application/execute-function.test.ts b/apps/sim/lib/function-execution/application/execute-function.test.ts new file mode 100644 index 00000000000..156ee2d7542 --- /dev/null +++ b/apps/sim/lib/function-execution/application/execute-function.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + executeRequest: vi.fn(), + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@/lib/function-execution/execute-request', () => ({ + executeFunctionRequest: mocks.executeRequest, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { FUNCTION_EXECUTION_DELEGATION_AUDIENCE } from '@/lib/function-execution/application/authorization' +import { executeFunction } from '@/lib/function-execution/application/execute-function' + +const principal: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { executionId: 'execution-1' }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + }, +} + +describe('executeFunction', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + billedAccountUserId: 'workspace-owner', + allowPersonalApiKeys: true, + }) + mocks.executeRequest.mockResolvedValue(Response.json({ success: true })) + mocks.resolvePermission.mockResolvedValue('write') + }) + + it('uses only the real workflow subject for legacy file contexts', async () => { + const humanPrincipal: WorkflowExecutionDelegatedPrincipal = { + ...principal, + subjectUserId: 'invoking-user', + delegationContext: { + ...principal.delegationContext!, + principal: { + kind: 'session', + userId: 'invoking-user', + sessionId: 'session-1', + }, + }, + } + + await executeFunction.execute({ + principal: humanPrincipal, + input: { + workspaceId: 'workspace-1', + body: { + code: 'return 1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + }, + headers: new Headers(), + }, + }) + + expect(mocks.executeRequest).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + attributedUserId: 'invoking-user', + fileAccessUserId: 'invoking-user', + principal: humanPrincipal, + }) + ) + }) + + it('keeps an actorless deployed principal authoritative and attributes legacy work afterward', async () => { + const headers = new Headers() + const signal = new AbortController().signal + const response = await executeFunction.execute({ + principal, + input: { + workspaceId: 'workspace-1', + body: { + code: 'return 1', + workflowId: 'workflow-1', + executionId: 'execution-1', + workspaceId: 'workspace-1', + }, + headers, + signal, + }, + }) + + expect(response.status).toBe(200) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.executeRequest).toHaveBeenCalledWith( + { headers, signal }, + expect.objectContaining({ + code: 'return 1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }), + { + attributedUserId: 'workspace-owner', + principal, + } + ) + }) + + it('rejects a body workspace that differs from the trusted operation scope', async () => { + await expect( + executeFunction.execute({ + principal, + input: { + workspaceId: 'workspace-1', + body: { code: 'return 1', workspaceId: 'workspace-victim' }, + headers: new Headers(), + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.executeRequest).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/function-execution/application/execute-function.ts b/apps/sim/lib/function-execution/application/execute-function.ts index 6baf2a234ae..da18f303abe 100644 --- a/apps/sim/lib/function-execution/application/execute-function.ts +++ b/apps/sim/lib/function-execution/application/execute-function.ts @@ -1,4 +1,4 @@ -import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { resolvePrincipalAttribution, resolvePrincipalSubject } from '@sim/auth/principal' import { type FunctionExecuteBody, functionExecuteBodySchema } from '@/lib/api/contracts' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -35,7 +35,7 @@ export const executeFunction = defineAuthorizedWorkspaceUseCase({ authorizationOptions: { delegation: functionExecutionDelegationPolicy, }, - execute: async ({ principal, input }): Promise => { + execute: async ({ principal, input, context }): Promise => { const parsedBody = functionExecuteBodySchema.safeParse(input.body) if (!parsedBody.success) { throw new OrchestrationError( @@ -44,6 +44,10 @@ export const executeFunction = defineAuthorizedWorkspaceUseCase({ ) } const { executeFunctionRequest } = await import('@/lib/function-execution/execute-request') + const { attributedUserId } = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const subject = resolvePrincipalSubject(principal) return executeFunctionRequest( { headers: input.headers, @@ -51,7 +55,9 @@ export const executeFunction = defineAuthorizedWorkspaceUseCase({ }, parsedBody.data, { - userId: requirePrincipalSubjectUserId(principal), + attributedUserId, + principal, + ...(subject?.kind === 'sim_user' ? { fileAccessUserId: subject.userId } : {}), ...(input.sandboxProfile ? { sandboxProfile: input.sandboxProfile } : {}), } ) diff --git a/apps/sim/lib/function-execution/execute-request.test.ts b/apps/sim/lib/function-execution/execute-request.test.ts index d8c6525f967..2fd1b35eaa5 100644 --- a/apps/sim/lib/function-execution/execute-request.test.ts +++ b/apps/sim/lib/function-execution/execute-request.test.ts @@ -14,6 +14,7 @@ import { import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { functionExecuteBodySchema } from '@/lib/api/contracts' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { INTERNAL_EXECUTION_DEADLINE_HEADER } from '@/lib/execution/execution-deadline-header' import { MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, @@ -27,17 +28,6 @@ import { SandboxOutputLimitError, } from '@/lib/execution/remote-sandbox/output-limits' -function grantedAccess(workspaceId: string) { - return { - exists: true, - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: workspaceId }, - permission: 'admin', - } -} - const { mockExecuteInSandbox, mockExecuteInIsolatedVM, @@ -51,8 +41,6 @@ const { mockUploadFile, mockValidateWorkspaceFileWriteTarget, mockWriteWorkspaceFileByPath, - mockCheckWorkspaceAccess, - mockResolveWorkspaceAccess, } = vi.hoisted(() => ({ mockExecuteInSandbox: vi.fn(), mockExecuteInIsolatedVM: vi.fn(), @@ -71,13 +59,6 @@ const { mockUploadFile: vi.fn(), mockValidateWorkspaceFileWriteTarget: vi.fn(), mockWriteWorkspaceFileByPath: vi.fn(), - mockCheckWorkspaceAccess: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mockCheckWorkspaceAccess, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, })) vi.mock('@/lib/core/security/encryption', () => ({ @@ -189,7 +170,22 @@ async function POST(request: NextRequest): Promise { } return executeFunctionRequest({ headers: request.headers, signal: request.signal }, parsed.data, { - userId: auth.userId, + attributedUserId: auth.userId, + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: auth.userId, + workspaceId: parsed.data.workspaceId ?? 'workspace-test', + delegationId: 'function-test', + audience: 'sim:function-executions', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { + kind: 'workflow_execution', + workflowId: parsed.data.workflowId ?? 'workflow-test', + ...(parsed.data.executionId ? { executionId: parsed.data.executionId } : {}), + }, + }, ...(auth.sandboxProfile === 'mothership' ? { sandboxProfile: 'mothership' } : {}), }) } @@ -208,9 +204,6 @@ describe('Function execution request', () => { authType: 'internal_jwt', }) - mockCheckWorkspaceAccess.mockImplementation(async (id: string) => grantedAccess(id)) - mockResolveWorkspaceAccess.mockImplementation(async (id: string) => grantedAccess(id)) - mockExecuteInIsolatedVM.mockResolvedValue({ result: 'test', stdout: '' }) mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey })) clearLargeValueCacheForTests() @@ -282,31 +275,7 @@ describe('Function execution request', () => { expect(data).toHaveProperty('error', 'Unauthorized') }) - it('rejects a body-supplied workspaceId the acting user is not a member of', async () => { - mockCheckWorkspaceAccess.mockResolvedValue({ - exists: true, - hasAccess: false, - canWrite: false, - canAdmin: false, - workspace: { id: 'workspace-victim' }, - permission: null, - }) - - const req = createMockRequest('POST', { - code: 'return "test"', - workspaceId: 'workspace-victim', - }) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(403) - expect(data).toHaveProperty('error', 'Workspace access denied') - expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith('workspace-victim', 'user-123') - expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() - }) - - it('rejects a sandbox output export into a workspace the acting user cannot write to', async () => { + it('rejects a sandbox output export through the workspace-file application policy', async () => { envFlagsMock.isRemoteSandboxEnabled = true mockExecuteInSandbox.mockResolvedValueOnce({ result: 'done', @@ -314,16 +283,9 @@ describe('Function execution request', () => { sandboxId: 'sandbox-123', exportedFiles: { '/tmp/out.txt': 'owned by attacker' }, }) - const readOnly = { - exists: true, - hasAccess: true, - canWrite: false, - canAdmin: false, - workspace: { id: 'workspace-victim' }, - permission: 'read', - } - mockCheckWorkspaceAccess.mockResolvedValue(readOnly) - mockResolveWorkspaceAccess.mockResolvedValue(readOnly) + mockWriteWorkspaceFileByPath.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) const req = createMockRequest('POST', { code: 'print("done")', @@ -338,45 +300,8 @@ describe('Function execution request', () => { const data = await response.json() expect(response.status).toBe(403) - expect(data).toHaveProperty('error', 'Workspace access denied') - expect(mockValidateWorkspaceFileWriteTarget).not.toHaveBeenCalled() - expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() - }) - - it('rejects an export whose workspace is derived from a body-supplied workflowId', async () => { - envFlagsMock.isRemoteSandboxEnabled = true - mockExecuteInSandbox.mockResolvedValueOnce({ - result: 'done', - stdout: 'ok', - sandboxId: 'sandbox-123', - exportedFiles: { '/tmp/out.txt': 'owned by attacker' }, - }) - workflowsUtilsMock.getWorkflowById.mockResolvedValueOnce({ - id: 'workflow-victim', - workspaceId: 'workspace-victim', - }) - mockResolveWorkspaceAccess.mockResolvedValue({ - exists: true, - hasAccess: false, - canWrite: false, - canAdmin: false, - workspace: { id: 'workspace-victim' }, - permission: null, - }) - - const req = createMockRequest('POST', { - code: 'print("done")', - language: 'python', - workflowId: 'workflow-victim', - outputs: { - files: [{ path: 'files/README.md', mode: 'overwrite', sandboxPath: '/tmp/out.txt' }], - }, - }) - - const response = await POST(req) - - expect(response.status).toBe(403) - expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + expect(data).toHaveProperty('error', 'Insufficient workspace permissions') + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) }) it('runs import-free JavaScript in isolated-vm without a remote provider', async () => { diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index f92c3d10d38..443a3fdc0e1 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -1,4 +1,4 @@ -import type { Principal } from '@sim/auth/principal' +import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' @@ -22,6 +22,7 @@ import { isTimeoutAbortReason, type TimeoutAbortController, } from '@/lib/core/execution-limits' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { encryptSecret } from '@/lib/core/security/encryption' import { setRecordValue } from '@/lib/core/utils/records' import { generateRequestId } from '@/lib/core/utils/request' @@ -84,15 +85,10 @@ import { type WorkspaceFileSecretProvenance, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { getWorkflowById } from '@/lib/workflows/utils' -import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { rebindWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' -import { - checkWorkspaceAccess, - resolveWorkspaceAccess, - type WorkspaceAccess, -} from '@/lib/workspaces/permissions/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { @@ -970,6 +966,7 @@ function serializeForShellEnv(value: unknown, nullValue = ''): string { } interface FunctionRouteExecutionContext { + principal: DelegatedPrincipal workflowId?: string workspaceId?: string executionId?: string @@ -977,7 +974,8 @@ interface FunctionRouteExecutionContext { largeValueKeys?: string[] fileKeys?: string[] allowLargeValueWorkflowScope?: boolean - userId?: string + attributedUserId: string + fileAccessUserId?: string requestId: string resolvedSecretNames: Set includePrivateResolvedSecretNames: boolean @@ -1078,6 +1076,7 @@ function createFunctionRuntimeBrokers( const largeValueKeys = context.largeValueKeys const fileKeys = context.fileKeys const base = { + principal: context.principal, requestId: context.requestId, workflowId: context.workflowId, workspaceId: context.workspaceId, @@ -1086,7 +1085,7 @@ function createFunctionRuntimeBrokers( largeValueKeys, fileKeys, allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope, - userId: context.userId, + userId: context.fileAccessUserId, logger, } @@ -1158,7 +1157,7 @@ async function compactFunctionRouteBody( workflowId: context.workflowId, workspaceId: context.workspaceId, executionId: context.executionId, - userId: context.userId, + userId: context.attributedUserId, preserveRoot: true, requireDurable: Boolean(context.workspaceId && context.workflowId && context.executionId), }) @@ -1411,21 +1410,8 @@ function exportFailure( ) } -/** - * Both `workspaceId` and `workflowId` arrive in the request body, so the workspace an export - * resolves to is caller-controlled either way. Returns null when the acting user cannot write to - * it, gating the secret-provenance scan and overwrite probe that run before the write itself. - */ -async function authorizeExportWorkspace( - workspaceId: string, - authUserId: string, - provided?: WorkspaceAccess -): Promise { - const access = await resolveWorkspaceAccess(workspaceId, authUserId, provided) - if (access.exists && access.canWrite) return access - - logger.warn('Sandbox file export denied for workspace', { workspaceId, userId: authUserId }) - return null +function workspaceFileExportErrorStatus(error: unknown): number { + return asOrchestrationError(error)?.code === 'forbidden' ? 403 : 400 } async function maybeExportSandboxFileToWorkspace(args: { @@ -1433,7 +1419,6 @@ async function maybeExportSandboxFileToWorkspace(args: { authUserId: string workflowId?: string workspaceId?: string - workspaceAccess?: WorkspaceAccess outputPath?: string outputFormat?: string outputMimeType?: string @@ -1449,7 +1434,6 @@ async function maybeExportSandboxFileToWorkspace(args: { authUserId, workflowId, workspaceId, - workspaceAccess, outputPath, outputFormat, outputMimeType, @@ -1484,9 +1468,6 @@ async function maybeExportSandboxFileToWorkspace(args: { ) } - const access = await authorizeExportWorkspace(resolvedWorkspaceId, authUserId, workspaceAccess) - if (!access) return exportFailure('Workspace access denied', 403, stdout, executionTime) - if (exportedFileContent === undefined) { return exportFailure( `Sandbox file "${outputSandboxPath}" was not found or could not be read`, @@ -1523,9 +1504,8 @@ async function maybeExportSandboxFileToWorkspace(args: { const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create') const targetPath = mode === 'create' ? outputPath : overwriteFileId || outputPath - const principal = createWorkspaceFileDelegatedPrincipal({ - serviceId: 'executor', - subjectUserId: authUserId, + const principal = rebindWorkspaceFileDelegatedPrincipal({ + principal: routeContext.principal, workspaceId: resolvedWorkspaceId, delegationId: `function-execute:${routeContext.requestId}`, executionId: routeContext.executionId, @@ -1591,7 +1571,7 @@ async function maybeExportSandboxFileToWorkspace(args: { } catch (error) { return exportFailure( getErrorMessage(error, 'Failed to export sandbox file'), - 400, + workspaceFileExportErrorStatus(error), stdout, executionTime ) @@ -1603,7 +1583,6 @@ async function maybeExportSandboxFilesToWorkspace(args: { authUserId: string workflowId?: string workspaceId?: string - workspaceAccess?: WorkspaceAccess outputFiles: OutputFileDeclaration[] exportedFiles?: Record exportedFileContent?: string @@ -1628,7 +1607,6 @@ async function maybeExportSandboxFilesToWorkspace(args: { authUserId: args.authUserId, workflowId: args.workflowId, workspaceId: args.workspaceId, - workspaceAccess: args.workspaceAccess, outputPath: file.formatPath ?? file.path, outputFormat: file.format, outputMimeType: file.mimeType, @@ -1654,15 +1632,6 @@ async function maybeExportSandboxFilesToWorkspace(args: { ) } - const access = await authorizeExportWorkspace( - resolvedWorkspaceId, - args.authUserId, - args.workspaceAccess - ) - if (!access) { - return exportFailure('Workspace access denied', 403, args.stdout, args.executionTime) - } - const preparedFiles = [] let totalOutputBytes = 0 for (const file of sandboxFiles) { @@ -1716,9 +1685,8 @@ async function maybeExportSandboxFilesToWorkspace(args: { }) } - const principal = createWorkspaceFileDelegatedPrincipal({ - serviceId: 'executor', - subjectUserId: args.authUserId, + const principal = rebindWorkspaceFileDelegatedPrincipal({ + principal: args.routeContext.principal, workspaceId: resolvedWorkspaceId, delegationId: `function-execute:${args.routeContext.requestId}`, executionId: args.routeContext.executionId, @@ -1738,7 +1706,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { } catch (error) { return exportFailure( getErrorMessage(error, 'Invalid sandbox output destination'), - 400, + workspaceFileExportErrorStatus(error), args.stdout, args.executionTime ) @@ -1805,7 +1773,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { } catch (error) { return exportFailure( getErrorMessage(error, 'Failed to export sandbox files'), - 400, + workspaceFileExportErrorStatus(error), args.stdout, args.executionTime ) @@ -1857,17 +1825,13 @@ async function maybeExportSandboxFilesToWorkspace(args: { } export interface TrustedFunctionExecutionAuth { - userId: string + attributedUserId: string + fileAccessUserId?: string + principal: DelegatedPrincipal sandboxProfile?: 'mothership' } -/** - * Executes the Function protocol after the caller has authenticated the human subject. - * - * The public route uses the legacy internal-token adapter below. Trusted in-process callers use - * the authorized Function application operation, which supplies this subject without creating a - * second Sim-to-Sim HTTP request. - */ +/** Executes the Function protocol after the application operation authorizes its principal. */ export async function executeFunctionRequest( req: FunctionExecutionRequestContext, body: ParsedFunctionExecuteBody, @@ -1963,24 +1927,6 @@ export async function executeFunctionRequest( _sandboxFiles, } = body - // The internal JWT carries no workspace scope, so a body-supplied workspaceId would - // otherwise be the sole authorization input for sandbox selection and file exports. - // Denial is returned rather than thrown: this handler's catch-all would turn a thrown - // WorkspaceAccessDeniedError into a 500 before withRouteHandler could map it. - const workspaceAccess = workspaceId - ? await checkWorkspaceAccess(workspaceId, auth.userId) - : undefined - if (workspaceAccess && (!workspaceAccess.exists || !workspaceAccess.hasAccess)) { - logger.warn(`[${requestId}] Function execution denied for workspace`, { - workspaceId, - userId: auth.userId, - }) - return NextResponse.json( - { success: false, error: 'Workspace access denied' }, - { status: 403 } - ) - } - if (selectedSandboxId && !isRemoteSandboxEnabled) { return NextResponse.json( { success: false, error: 'The Function code sandbox is not configured' }, @@ -2053,6 +1999,7 @@ export async function executeFunctionRequest( }) routeContext = { + principal: auth.principal, workflowId, workspaceId, executionId, @@ -2060,7 +2007,8 @@ export async function executeFunctionRequest( largeValueKeys, fileKeys, allowLargeValueWorkflowScope, - userId: auth.userId, + attributedUserId: auth.attributedUserId, + fileAccessUserId: auth.fileAccessUserId, requestId, resolvedSecretNames: new Set(), includePrivateResolvedSecretNames, @@ -2223,10 +2171,9 @@ export async function executeFunctionRequest( if (outputSandboxPaths.length > 0 || outputSandboxPath) { const fileExportResponse = await maybeExportSandboxFilesToWorkspace({ routeContext, - authUserId: auth.userId, + authUserId: auth.attributedUserId, workflowId, workspaceId, - workspaceAccess, outputFiles, exportedFiles, exportedFileContent, @@ -2403,10 +2350,9 @@ export async function executeFunctionRequest( if (outputSandboxPaths.length > 0 || outputSandboxPath) { const fileExportResponse = await maybeExportSandboxFilesToWorkspace({ routeContext, - authUserId: auth.userId, + authUserId: auth.attributedUserId, workflowId, workspaceId, - workspaceAccess, outputFiles, exportedFiles, exportedFileContent, @@ -2494,10 +2440,9 @@ export async function executeFunctionRequest( if (outputSandboxPaths.length > 0 || outputSandboxPath) { const fileExportResponse = await maybeExportSandboxFilesToWorkspace({ routeContext, - authUserId: auth.userId, + authUserId: auth.attributedUserId, workflowId, workspaceId, - workspaceAccess, outputFiles, exportedFiles, exportedFileContent, @@ -2550,7 +2495,7 @@ export async function executeFunctionRequest( runtimeBindings: compilerRuntimeBindings, timeoutMs: timeout, requestId, - ownerKey: `user:${auth.userId}`, + ownerKey: `user:${auth.attributedUserId}`, ownerWeight: 1, }, { brokers: createFunctionRuntimeBrokers(routeContext), signal: executionSignal } diff --git a/apps/sim/lib/guardrails/validate_hallucination.test.ts b/apps/sim/lib/guardrails/validate_hallucination.test.ts index 86b0028d72e..50c731d8715 100644 --- a/apps/sim/lib/guardrails/validate_hallucination.test.ts +++ b/apps/sim/lib/guardrails/validate_hallucination.test.ts @@ -45,6 +45,17 @@ const BILLING_ATTRIBUTION: BillingAttributionSnapshot = { } function createInput(registry: ResolvedSecretTraceRegistry) { + const executionContext = { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + executorDelegationOrigin: { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + } return { userInput: 'secret-value __var_FOREIGN', knowledgeBaseId: 'kb-1', @@ -54,6 +65,7 @@ function createInput(registry: ResolvedSecretTraceRegistry) { workflowId: 'workflow-1', workspaceId: 'workspace-1', actorUserId: 'user-1', + executionContext, billingAttribution: BILLING_ATTRIBUTION, requestId: 'request-1', resolvedSecretTraceRegistry: registry, @@ -117,9 +129,11 @@ describe('validateHallucination', () => { knowledgeBaseIds: ['kb-1'], query: 'secret-value __var_FOREIGN', topK: 10, - userId: 'user-1', workspaceId: 'workspace-1', - workflowId: 'workflow-1', + context: expect.objectContaining({ + workflowId: 'workflow-1', + executorDelegationOrigin: expect.objectContaining({ workflowId: 'workflow-1' }), + }), billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, modelInputPaths: [['input']], diff --git a/apps/sim/lib/guardrails/validate_hallucination.ts b/apps/sim/lib/guardrails/validate_hallucination.ts index 0687073c777..0e25c657a5b 100644 --- a/apps/sim/lib/guardrails/validate_hallucination.ts +++ b/apps/sim/lib/guardrails/validate_hallucination.ts @@ -6,6 +6,7 @@ import { isPlainRecord } from '@sim/utils/object' import { eq } from 'drizzle-orm' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { searchKnowledgeAsExecutor } from '@/lib/internal/knowledge/search' +import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' import { refreshTokenIfNeeded } from '@/lib/oauth/credential-service' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' @@ -46,6 +47,7 @@ export interface HallucinationValidationInput { workflowId?: string workspaceId?: string actorUserId: string + executionContext: InternalToolOperationContext billingAttribution: BillingAttributionSnapshot requestId: string resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry @@ -65,7 +67,7 @@ async function queryKnowledgeBase( query: string, topK: number, requestId: string, - actorUserId: string, + executionContext: InternalToolOperationContext, billingAttribution: BillingAttributionSnapshot, workflowId: string | undefined, workspaceId: string | undefined, @@ -80,9 +82,8 @@ async function queryKnowledgeBase( knowledgeBaseIds: [knowledgeBaseId], query, topK, - userId: actorUserId, workspaceId, - workflowId, + context: executionContext, billingAttribution, resolvedSecretTraceRegistry, modelInputPaths: HALLUCINATION_INPUT_PATHS, @@ -266,6 +267,7 @@ export async function validateHallucination( workflowId, workspaceId, actorUserId, + executionContext, billingAttribution, requestId, resolvedSecretTraceRegistry, @@ -292,7 +294,7 @@ export async function validateHallucination( userInput, topK, requestId, - actorUserId, + executionContext, billingAttribution, workflowId, workspaceId, diff --git a/apps/sim/lib/internal/file/execute-tool.test.ts b/apps/sim/lib/internal/file/execute-tool.test.ts index 6192c901710..9079b2b8aaa 100644 --- a/apps/sim/lib/internal/file/execute-tool.test.ts +++ b/apps/sim/lib/internal/file/execute-tool.test.ts @@ -3,6 +3,7 @@ */ import { createExecutionContext } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' const mocks = vi.hoisted(() => ({ createPrincipal: vi.fn(), @@ -39,6 +40,19 @@ const MANAGE_INPUTS = { const PARSER_TOOL_IDS = ['file_fetch', 'file_parser', 'file_parser_v2', 'file_parser_v3'] as const +const BILLING_ATTRIBUTION = { + actorUserId: 'user-1', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'workspace-owner', + billingEntity: { type: 'user', id: 'workspace-owner' }, + billingPeriod: { + start: '2026-08-01T00:00:00.000Z', + end: '2026-09-01T00:00:00.000Z', + }, + payerSubscription: null, +} satisfies BillingAttributionSnapshot + function request( toolId: string, input: unknown, @@ -53,6 +67,14 @@ function request( executionId: 'execution-1', userId: 'user-1', workspaceId: 'workspace-1', + billingAttribution: BILLING_ATTRIBUTION, + executorDelegationOrigin: { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, + }, }, requestId: 'request-1', ...overrides, @@ -80,7 +102,8 @@ describe('executeFileTool', () => { expect.objectContaining(input), expect.objectContaining({ workspaceId: 'workspace-1', - userId: 'user-1', + attributedUserId: 'user-1', + fileAccessUserId: 'user-1', requestId: 'request-1', }) ) @@ -99,7 +122,8 @@ describe('executeFileTool', () => { workspaceId: 'workspace-1', workflowId: 'workflow-1', executionId: 'execution-1', - userId: 'user-1', + attributedUserId: 'user-1', + fileAccessUserId: 'user-1', }) ) expect(mocks.executeManage).not.toHaveBeenCalled() @@ -117,6 +141,12 @@ describe('executeFileTool', () => { }) it('uses the delegation origin as the file authorization subject in child workflows', async () => { + mocks.createPrincipal.mockResolvedValueOnce({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'invoking-user', + workspaceId: 'workspace-1', + }) await executeFileTool( request('file_get', MANAGE_INPUTS.file_get, { context: { @@ -135,14 +165,79 @@ describe('executeFileTool', () => { expect(mocks.executeManage).toHaveBeenCalledWith( expect.objectContaining(MANAGE_INPUTS.file_get), - expect.objectContaining({ userId: 'invoking-user' }) + expect.objectContaining({ + attributedUserId: 'invoking-user', + fileAccessUserId: 'invoking-user', + }) + ) + }) + + it('uses compatibility attribution without replacing an actorless deployed principal', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + }, + }, + } + mocks.createPrincipal.mockResolvedValueOnce(principal) + + await executeFileTool( + request('file_decompress', MANAGE_INPUTS.file_decompress, { + context: { + ...createExecutionContext({ workflowId: 'workflow-1' }), + executionId: 'execution-1', + userId: 'legacy-actor', + workspaceId: 'workspace-1', + billingAttribution: BILLING_ATTRIBUTION, + executorDelegationOrigin: { + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: principal.delegationContext.principal, + currentWorkflow: principal.delegationContext.currentWorkflow, + }, + }, + }) + ) + + expect(mocks.executeManage).toHaveBeenCalledWith( + expect.objectContaining(MANAGE_INPUTS.file_decompress), + expect.objectContaining({ + principal, + attributedUserId: 'workspace-owner', + fileAccessUserId: undefined, + workspaceId: 'workspace-1', + }) ) }) - it('rejects missing trusted identity before principal construction', async () => { + it('rejects missing trusted identity during principal construction', async () => { const response = await executeFileTool( request('file_get', MANAGE_INPUTS.file_get, { - context: { ...createExecutionContext({ workflowId: 'workflow-1' }), userId: undefined }, + context: { + ...createExecutionContext({ workflowId: 'workflow-1' }), + workspaceId: 'workspace-1', + userId: undefined, + executorDelegationOrigin: undefined, + }, }) ) diff --git a/apps/sim/lib/internal/file/execute-tool.ts b/apps/sim/lib/internal/file/execute-tool.ts index ac509a621d1..b709572e237 100644 --- a/apps/sim/lib/internal/file/execute-tool.ts +++ b/apps/sim/lib/internal/file/execute-tool.ts @@ -1,3 +1,8 @@ +import { + PrincipalSubjectUserRequiredError, + resolvePrincipalAttribution, + resolvePrincipalSubject, +} from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { fileParseContract } from '@/lib/api/contracts/storage-transfer' @@ -37,8 +42,7 @@ export const executeFileTool: InternalToolOperationHandler = async (request) => } const workspaceId = request.context.workspaceId - const userId = request.context.executorDelegationOrigin?.subjectUserId ?? request.context.userId - if (!workspaceId || !userId) { + if (!workspaceId || !request.context.executorDelegationOrigin) { return Response.json({ success: false, error: 'Authentication required' }, { status: 401 }) } @@ -58,6 +62,11 @@ export const executeFileTool: InternalToolOperationHandler = async (request) => context: request.context, audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, }) + const { attributedUserId } = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: request.context.billingAttribution?.billedAccountUserId, + }) + const subject = resolvePrincipalSubject(principal) + const fileAccessUserId = subject?.kind === 'sim_user' ? subject.userId : undefined request.signal?.throwIfAborted() let response: Response if (parserInput) { @@ -66,7 +75,12 @@ export const executeFileTool: InternalToolOperationHandler = async (request) => workspaceId, workflowId: request.context.workflowId, executionId: request.context.executionId, - userId, + attributedUserId, + fileAccessUserId, + largeValueExecutionIds: request.context.largeValueExecutionIds, + fileKeys: request.context.fileKeys, + allowLargeValueWorkflowScope: request.context.allowLargeValueWorkflowScope, + requestId: request.requestId, signal: request.signal, }) } else { @@ -74,7 +88,13 @@ export const executeFileTool: InternalToolOperationHandler = async (request) => response = await executeFileManageOperation(manageInput.data, { principal, workspaceId, - userId, + attributedUserId, + fileAccessUserId, + workflowId: request.context.workflowId, + executionId: request.context.executionId, + largeValueExecutionIds: request.context.largeValueExecutionIds, + fileKeys: request.context.fileKeys, + allowLargeValueWorkflowScope: request.context.allowLargeValueWorkflowScope, headers: request.headers, requestId: request.requestId, signal: request.signal, @@ -86,6 +106,7 @@ export const executeFileTool: InternalToolOperationHandler = async (request) => request.signal?.throwIfAborted() if ( error instanceof InvalidInternalDelegationBindingError || + error instanceof PrincipalSubjectUserRequiredError || (error instanceof Error && error.message === 'Authentication required') ) { return Response.json({ success: false, error: 'Authentication required' }, { status: 401 }) diff --git a/apps/sim/lib/internal/file/operations.test.ts b/apps/sim/lib/internal/file/operations.test.ts index 49bbd47dae6..57413837212 100644 --- a/apps/sim/lib/internal/file/operations.test.ts +++ b/apps/sim/lib/internal/file/operations.test.ts @@ -7,7 +7,6 @@ import { MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' const { mockAssertActiveWorkspaceAccess, - mockAssertToolFileAccess, mockDownloadServableFileFromStorage, mockDownloadFileFromStorage, mockDecompressArchiveBufferToWorkspaceFiles, @@ -20,12 +19,12 @@ const { mockResolveEffectiveWorkspacePermission, mockGetFileMetadataByKey, mockGetWorkspaceFile, + mockVerifyFileAccess, mockResolveWorkspaceFileReference, mockUpdateWorkspaceFileContent, mockUploadWorkspaceFile, } = vi.hoisted(() => ({ mockAssertActiveWorkspaceAccess: vi.fn(), - mockAssertToolFileAccess: vi.fn(), mockDownloadServableFileFromStorage: vi.fn(), mockDownloadFileFromStorage: vi.fn(), mockDecompressArchiveBufferToWorkspaceFiles: vi.fn(), @@ -38,6 +37,7 @@ const { mockResolveEffectiveWorkspacePermission: vi.fn(), mockGetFileMetadataByKey: vi.fn(), mockGetWorkspaceFile: vi.fn(), + mockVerifyFileAccess: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), mockUpdateWorkspaceFileContent: vi.fn(), mockUploadWorkspaceFile: vi.fn(), @@ -159,7 +159,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ })) vi.mock('@/app/api/files/authorization', () => ({ - assertToolFileAccess: (...args: unknown[]) => mockAssertToolFileAccess(...args), + verifyFileAccess: (...args: unknown[]) => mockVerifyFileAccess(...args), })) import { fileManageBodySchema } from '@/lib/api/contracts/tools/file' @@ -183,7 +183,9 @@ async function POST(request: Request): Promise { delegationId: 'test-file-operation', }), workspaceId, - userId: 'user-1', + attributedUserId: 'user-1', + fileAccessUserId: 'user-1', + workflowId: 'workflow-1', headers: request.headers, requestId: 'request-1', signal: request.signal, @@ -214,6 +216,34 @@ function workspaceFile(id: string, ownerUserId = 'user-1') { } } +function actorlessDeploymentPrincipal(workspaceId = 'workspace-1') { + return { + kind: 'delegated' as const, + serviceId: 'executor' as const, + workspaceId, + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId, + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + }, + }, + } +} + describe('file manage operations', () => { beforeEach(() => { vi.clearAllMocks() @@ -224,6 +254,7 @@ describe('file manage operations', () => { }) mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) mockResolveEffectiveWorkspacePermission.mockResolvedValue('write') + mockVerifyFileAccess.mockResolvedValue(true) mockGetWorkspaceFile.mockImplementation(async (_workspaceId: string, fileId: string) => workspaceFile(fileId) ) @@ -240,7 +271,6 @@ describe('file manage operations', () => { allowPersonalApiKeys: true, billedAccountUserId: 'user-1', })) - mockAssertToolFileAccess.mockResolvedValue(undefined) mockEnsureWorkspaceFileFolderPath.mockImplementation( async ({ input }: { input: { pathSegments: string[] } }) => ({ folderId: input.pathSegments.length === 0 ? null : 'folder-1', @@ -742,6 +772,83 @@ describe('file manage operations', () => { ) }) + it('decompresses a canonical workspace archive for an actorless deployed execution', async () => { + const archiveBuffer = Buffer.from('archive-bytes') + const principal = actorlessDeploymentPrincipal() + mockDownloadFileFromStorage.mockResolvedValue(archiveBuffer) + mockGetWorkspaceFile.mockResolvedValue({ + ...workspaceFile('archive'), + name: 'archive.zip', + type: 'application/zip', + }) + mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue({ + status: 'exact', + entries: [], + }) + mockDecompressArchiveBufferToWorkspaceFiles.mockResolvedValue({ + extracted: [ + { + ...workspaceFile('child'), + url: '/api/files/serve/child', + context: 'workspace', + }, + ], + skipped: 0, + skippedUnsafePaths: [], + }) + + const response = await executeFileManageOperation( + fileManageBodySchema.parse({ + operation: 'decompress', + workspaceId: 'workspace-1', + fileId: 'archive', + }), + { + principal, + workspaceId: 'workspace-1', + attributedUserId: 'workspace-owner', + workflowId: 'workflow-1', + executionId: 'execution-1', + headers: new Headers(), + requestId: 'request-actorless', + } + ) + + expect(response.status).toBe(200) + expect(mockResolveEffectiveWorkspacePermission).not.toHaveBeenCalled() + expect(mockGetWorkspaceFile).toHaveBeenCalledWith('workspace-1', 'archive', { + throwOnError: true, + }) + expect(mockDecompressArchiveBufferToWorkspaceFiles).toHaveBeenCalledWith( + archiveBuffer, + expect.objectContaining({ principal, workspaceId: 'workspace-1' }) + ) + }) + + it('rejects an actorless deployment principal bound to a different workspace', async () => { + const response = await executeFileManageOperation( + fileManageBodySchema.parse({ + operation: 'decompress', + workspaceId: 'workspace-1', + fileId: 'archive', + }), + { + principal: actorlessDeploymentPrincipal('workspace-2'), + workspaceId: 'workspace-1', + attributedUserId: 'workspace-owner', + workflowId: 'workflow-1', + executionId: 'execution-1', + headers: new Headers(), + requestId: 'request-cross-workspace', + } + ) + + expect(response.status).toBe(403) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + expect(mockDownloadFileFromStorage).not.toHaveBeenCalled() + expect(mockDecompressArchiveBufferToWorkspaceFiles).not.toHaveBeenCalled() + }) + it('omits source scope when canonical files have different owners', async () => { mockGetWorkspaceFile.mockImplementation(async (_workspaceId: string, fileId: string) => workspaceFile(fileId, fileId === 'file-1' ? 'user-1' : 'user-2') diff --git a/apps/sim/lib/internal/file/operations.ts b/apps/sim/lib/internal/file/operations.ts index 5775dbdbfad..190592076f8 100644 --- a/apps/sim/lib/internal/file/operations.ts +++ b/apps/sim/lib/internal/file/operations.ts @@ -17,6 +17,7 @@ import { inspectPrivateSecretProvenanceRequest, isPrivateSecretProvenanceBundleV1, } from '@/lib/execution/model-input-provenance' +import { assertUserFileContentAccess } from '@/lib/execution/payloads/materialization.server' import { PRIVATE_TOOL_METADATA_RESPONSE_HEADER, RESOLVED_SECRET_PROVENANCE_FIELD, @@ -70,7 +71,6 @@ import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/up import { ensureWorkspaceFileFolderPathOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' import { isWorkspaceAccessDeniedError } from '@/lib/workspaces/permissions/utils' -import { assertToolFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' import { ResolvedSecretTraceProvenanceAccumulator, @@ -85,12 +85,42 @@ export type FileManageOperationInput = ContractBody export interface FileManageOperationContext { principal: Principal workspaceId: string - userId: string + attributedUserId: string + fileAccessUserId?: string + workflowId: string + executionId?: string + largeValueExecutionIds?: string[] + fileKeys?: string[] + allowLargeValueWorkflowScope?: boolean headers: Headers requestId: string signal?: AbortSignal } +async function assertOperationFileAccess( + file: Pick, + context: FileManageOperationContext +): Promise { + try { + await assertUserFileContentAccess(file, { + principal: context.principal, + workspaceId: context.workspaceId, + workflowId: context.workflowId, + executionId: context.executionId, + largeValueExecutionIds: context.largeValueExecutionIds, + fileKeys: context.fileKeys, + allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope, + userId: context.fileAccessUserId, + requestId: context.requestId, + logger, + }) + return null + } catch { + logger.warn('File access denied', { key: file.key, requestId: context.requestId }) + return Response.json({ success: false, error: 'File not found' }, { status: 404 }) + } +} + const workspaceFileToUserFile = (file: Awaited>) => { if (!file) return null @@ -467,7 +497,7 @@ export async function executeFileManageOperation( body: FileManageOperationInput, context: FileManageOperationContext ): Promise { - const { headers, principal, requestId, signal, userId, workspaceId } = context + const { attributedUserId: userId, headers, principal, requestId, signal, workspaceId } = context signal?.throwIfAborted() if (body.workspaceId && body.workspaceId !== workspaceId) { return Response.json({ success: false, error: 'Workspace access denied' }, { status: 403 }) @@ -682,7 +712,9 @@ export async function executeFileManageOperation( let totalBytes = 0 for (const source of sources) { signal?.throwIfAborted() - const denied = await assertToolFileAccess(source.file.key, userId, requestId, logger) + const denied = source.identity + ? null + : await assertOperationFileAccess(source.file, context) if (denied) { const deniedBody = (await denied.clone().json()) as Record return contentResponse(deniedBody, { @@ -1019,11 +1051,12 @@ export async function executeFileManageOperation( const selectedArchiveSources = await Promise.all( selectedInputFiles.map((file) => bindSelectedContentFile(principal, workspaceId, file)) ) + const archiveSources = canonicalArchiveSources.concat(selectedArchiveSources) const archiveProvenance = await deriveWorkspaceFileSecretProvenance({ principal, workspaceId, targetOwnerUserId: userId, - sources: canonicalArchiveSources.concat(selectedArchiveSources), + sources: archiveSources, }) // Mirror the workspace folder layout, dropping the ancestor chain the whole @@ -1037,7 +1070,9 @@ export async function executeFileManageOperation( let totalBytes = 0 for (const [index, userFile] of userFiles.entries()) { signal?.throwIfAborted() - const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + const denied = archiveSources[index]?.identity + ? null + : await assertOperationFileAccess(userFile, context) if (denied) return denied // Generated docs store their generation source, not the rendered binary, so @@ -1168,9 +1203,6 @@ export async function executeFileManageOperation( return Response.json({ success: false, error: 'File is required' }, { status: 400 }) } - const denied = await assertToolFileAccess(archive.key, userId, requestId, logger) - if (denied) return denied - const canonicalArchiveSource: FileContentSource[] = workspaceFiles.flatMap((file) => { const userFile = workspaceFileToUserFile(file) if (!file || !userFile) return [] @@ -1185,11 +1217,16 @@ export async function executeFileManageOperation( const selectedArchiveSource = await Promise.all( selectedInputFiles.map((file) => bindSelectedContentFile(principal, workspaceId, file)) ) + const archiveSource = canonicalArchiveSource.concat(selectedArchiveSource)[0] + if (!archiveSource?.identity) { + const denied = await assertOperationFileAccess(archive, context) + if (denied) return denied + } const archiveProvenance = await deriveWorkspaceFileSecretProvenance({ principal, workspaceId, targetOwnerUserId: userId, - sources: canonicalArchiveSource.concat(selectedArchiveSource), + sources: archiveSource ? [archiveSource] : [], }) const archiveBuffer = await downloadFileFromStorage(archive, requestId, logger, { diff --git a/apps/sim/lib/internal/file/parser.test.ts b/apps/sim/lib/internal/file/parser.test.ts index 450b62c4580..0b69bfe7332 100644 --- a/apps/sim/lib/internal/file/parser.test.ts +++ b/apps/sim/lib/internal/file/parser.test.ts @@ -79,9 +79,10 @@ const { } }) -vi.mock('@/app/api/files/authorization', () => ({ - verifyFileAccess: mockVerifyFileAccess, - verifyWorkspaceFileAccess: mockVerifyWorkspaceFileAccess, +vi.mock('@/lib/execution/payloads/materialization.server', () => ({ + assertUserFileContentAccess: async (file: { key: string }) => { + if (!(await mockVerifyFileAccess(file.key))) throw new Error('File not found') + }, })) vi.mock('@/lib/uploads', () => ({ @@ -170,7 +171,8 @@ async function POST(request: NextRequest): Promise { workspaceId: parsed.data.workspaceId || 'workspace-id', workflowId: parsed.data.workflowId || 'workflow-id', executionId: parsed.data.executionId || 'execution-id', - userId: 'test-user-id', + attributedUserId: 'test-user-id', + fileAccessUserId: 'test-user-id', signal: request.signal, }) } diff --git a/apps/sim/lib/internal/file/parser.ts b/apps/sim/lib/internal/file/parser.ts index 987acb73571..20be1fd610d 100644 --- a/apps/sim/lib/internal/file/parser.ts +++ b/apps/sim/lib/internal/file/parser.ts @@ -11,6 +11,10 @@ import type { ContractBody } from '@/lib/api/contracts' import type { fileParseContract } from '@/lib/api/contracts/storage-transfer' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { + assertUserFileContentAccess, + type ExecutionMaterializationContext, +} from '@/lib/execution/payloads/materialization.server' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' import { isFileParserError } from '@/lib/file-parsers/errors' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' @@ -31,7 +35,6 @@ import { isInternalFileUrl, } from '@/lib/uploads/utils/file-utils' import { readWorkspaceFileNameByKey } from '@/lib/workspace-files/application/read-workspace-file-name-by-key' -import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' import '@/lib/uploads/core/setup.server' @@ -60,10 +63,21 @@ export interface FileParserOperationContext { workspaceId: string workflowId: string executionId?: string - userId: string + attributedUserId: string + fileAccessUserId?: string + largeValueExecutionIds?: string[] + fileKeys?: string[] + allowLargeValueWorkflowScope?: boolean + requestId?: string signal?: AbortSignal } +type FileReadAccessContext = ExecutionMaterializationContext & { + principal: Principal + workspaceId: string + workflowId: string +} + interface ParseResult { success: boolean content?: string @@ -102,7 +116,18 @@ export async function executeFileParserOperation( if (input.executionId && input.executionId !== context.executionId) { return Response.json({ success: false, error: 'Execution access denied' }, { status: 403 }) } - const { userId, workspaceId } = context + const { attributedUserId, workspaceId } = context + const fileReadAccess: FileReadAccessContext = { + principal: context.principal, + workspaceId, + workflowId: context.workflowId, + executionId: context.executionId, + largeValueExecutionIds: context.largeValueExecutionIds, + fileKeys: context.fileKeys, + allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope, + userId: context.fileAccessUserId, + requestId: context.requestId, + } if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) { return Response.json({ success: false, error: 'No file path provided' }, { status: 400 }) @@ -116,7 +141,7 @@ export async function executeFileParserOperation( filePath, fileType, workspaceId, - userId, + userId: attributedUserId, hasExecutionContext: !!executionContext, hasHeaders: Boolean(headers && Object.keys(headers).length > 0), }) @@ -145,7 +170,8 @@ export async function executeFileParserOperation( singlePath, fileType, workspaceId, - userId, + attributedUserId, + fileReadAccess, context.principal, executionContext, headers, @@ -198,7 +224,8 @@ export async function executeFileParserOperation( filePath, fileType, workspaceId, - userId, + attributedUserId, + fileReadAccess, context.principal, executionContext, headers, @@ -247,7 +274,8 @@ async function parseFileSingle( filePath: string, fileType: string, workspaceId: string, - userId: string, + attributedUserId: string, + fileReadAccess: FileReadAccessContext, principal: Principal, executionContext?: ExecutionContext, headers?: Record, @@ -287,7 +315,8 @@ async function parseFileSingle( return handleCloudFile( filePath, fileType, - userId, + attributedUserId, + fileReadAccess, principal, workspaceId, executionContext, @@ -302,7 +331,7 @@ async function parseFileSingle( filePath, fileType, workspaceId, - userId, + attributedUserId, executionContext, headers, signal, @@ -315,7 +344,8 @@ async function parseFileSingle( return handleCloudFile( filePath, fileType, - userId, + attributedUserId, + fileReadAccess, principal, workspaceId, executionContext, @@ -328,7 +358,8 @@ async function parseFileSingle( return handleLocalFile( filePath, fileType, - userId, + attributedUserId, + fileReadAccess, executionContext, signal, maxDownloadBytes, @@ -599,7 +630,8 @@ async function handleExternalUrl( async function handleCloudFile( filePath: string, fileType: string, - userId: string, + attributedUserId: string, + fileReadAccess: FileReadAccessContext, principal: Principal, workspaceId: string, executionContext?: ExecutionContext, @@ -615,16 +647,10 @@ async function handleCloudFile( const context = inferContextFromKey(cloudKey) - const hasAccess = await verifyFileAccess( - cloudKey, - userId, - undefined, // customConfig - context, // context - false // isLocal - ) - - if (!hasAccess) { - logger.warn('Unauthorized cloud file parse attempt', { userId, key: cloudKey, context }) + try { + await assertUserFileContentAccess({ key: cloudKey, context }, fileReadAccess) + } catch { + logger.warn('Unauthorized cloud file parse attempt', { key: cloudKey, context }) return { success: false, error: 'File not found', @@ -705,7 +731,7 @@ async function handleCloudFile( fileBuffer, filename, mimeType, - userId + attributedUserId ) logger.info(`Copied file to execution storage: ${filename}`, { key: userFile.key }) } catch (uploadError) { @@ -806,7 +832,8 @@ async function handleCloudFile( async function handleLocalFile( filePath: string, fileType: string, - userId: string, + attributedUserId: string, + fileReadAccess: FileReadAccessContext, executionContext?: ExecutionContext, signal?: AbortSignal, maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, @@ -818,16 +845,10 @@ async function handleLocalFile( const filename = storageKey.split('/').pop() || storageKey const context = inferContextFromKey(storageKey) - const hasAccess = await verifyFileAccess( - storageKey, - userId, - undefined, // customConfig - context, // context - true // isLocal - ) - - if (!hasAccess) { - logger.warn('Unauthorized local file parse attempt', { userId, filename }) + try { + await assertUserFileContentAccess({ key: storageKey, context }, fileReadAccess) + } catch { + logger.warn('Unauthorized local file parse attempt', { filename }) return { success: false, error: 'File not found', @@ -867,7 +888,7 @@ async function handleLocalFile( fileBuffer, filename, mimeType, - userId + attributedUserId ) logger.info(`Stored local file in execution storage: ${filename}`, { key: userFile.key }) } catch (uploadError) { diff --git a/apps/sim/lib/internal/function/execute.test.ts b/apps/sim/lib/internal/function/execute.test.ts new file mode 100644 index 00000000000..a5ed37edef3 --- /dev/null +++ b/apps/sim/lib/internal/function/execute.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createPrincipal: vi.fn(), + execute: vi.fn(), +})) + +vi.mock('@/lib/internal/principals/executor', () => ({ + createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, +})) + +vi.mock('@/lib/function-execution/application/execute-function', () => ({ + executeFunction: { execute: mocks.execute }, +})) + +import { FUNCTION_EXECUTION_DELEGATION_AUDIENCE } from '@/lib/function-execution/application/authorization' +import { executeFunctionTool } from '@/lib/internal/function/execute' + +describe('executeFunctionTool', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.execute.mockResolvedValue(Response.json({ success: true })) + }) + + it('binds executor calls from the canonical origin instead of the compatibility user ID', async () => { + const startedAt = Date.now() + const origin = { + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + }, + } + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { kind: 'workflow_execution' as const, ...origin }, + } + mocks.createPrincipal.mockResolvedValue(principal) + const context = { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'workspace-owner', + executorDelegationOrigin: origin, + } + const headers = new Headers() + + await executeFunctionTool({ + body: { + code: 'return 1', + timeout: 60_000, + userId: 'forged-user', + workspaceId: 'forged-workspace', + }, + headers, + context, + requestId: 'request-1', + }) + + expect(mocks.createPrincipal).toHaveBeenCalledWith({ + context, + audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, + expiresAt: expect.any(Date), + resourceScope: { executionId: 'execution-1' }, + }) + const delegatedExpiry = mocks.createPrincipal.mock.calls[0]?.[0].expiresAt as Date + expect(delegatedExpiry.getTime()).toBeGreaterThanOrEqual(startedAt + 60_000) + expect(delegatedExpiry.getTime()).toBeLessThanOrEqual(Date.now() + 60_000) + expect(mocks.execute).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ + workspaceId: 'workspace-1', + body: expect.objectContaining({ + workspaceId: 'workspace-1', + userId: undefined, + }), + headers, + }), + }) + }) +}) diff --git a/apps/sim/lib/internal/function/execute.ts b/apps/sim/lib/internal/function/execute.ts index 0ffc47388e8..4d68ba712a4 100644 --- a/apps/sim/lib/internal/function/execute.ts +++ b/apps/sim/lib/internal/function/execute.ts @@ -5,17 +5,11 @@ import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' import { serializeExecutionDeadlineHeader } from '@/lib/execution/execution-deadline-header' import { FUNCTION_EXECUTION_DELEGATION_AUDIENCE } from '@/lib/function-execution/application/authorization' import { executeFunction } from '@/lib/function-execution/application/execute-function' +import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' -export interface TrustedFunctionToolExecutionContext { - userId: string +export type TrustedFunctionToolExecutionContext = InternalToolOperationContext & { workspaceId: string - workflowId?: string - executionId?: string - largeValueExecutionIds?: string[] - largeValueKeys?: string[] - fileKeys?: string[] - allowLargeValueWorkflowScope?: boolean - copilotToolExecution?: boolean } export interface ExecuteFunctionToolInput { @@ -41,23 +35,34 @@ export async function executeFunctionTool(input: ExecuteFunctionToolInput): Prom ...body, workflowId: context.workflowId, executionId: context.executionId, - userId: context.userId, + userId: undefined, workspaceId: context.workspaceId, largeValueExecutionIds: context.largeValueExecutionIds, largeValueKeys: context.largeValueKeys, fileKeys: context.fileKeys, allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope, } - const principal: DelegatedPrincipal = { - kind: 'delegated', - serviceId: context.copilotToolExecution === true ? 'copilot' : 'executor', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `function-execute:${requestId}`, - audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, - issuedAt, - expiresAt, - ...(context.executionId ? { resourceScope: { executionId: context.executionId } } : {}), + let principal: DelegatedPrincipal + if (context.copilotToolExecution === true) { + if (!context.userId) throw new Error('Copilot Function execution requires a user') + principal = { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `function-execute:${requestId}`, + audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, + issuedAt, + expiresAt, + ...(context.executionId ? { resourceScope: { executionId: context.executionId } } : {}), + } + } else { + principal = await createExecutorPrincipalFromExecutionContext({ + context, + audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, + expiresAt, + ...(context.executionId ? { resourceScope: { executionId: context.executionId } } : {}), + }) } return executeFunction.execute({ diff --git a/apps/sim/lib/internal/guardrails/execute-tool.ts b/apps/sim/lib/internal/guardrails/execute-tool.ts index ea991a50674..17fd2dfabe2 100644 --- a/apps/sim/lib/internal/guardrails/execute-tool.ts +++ b/apps/sim/lib/internal/guardrails/execute-tool.ts @@ -68,6 +68,7 @@ export const executeGuardrailsTool: InternalToolOperationHandler = async (reques try { const result = await executeGuardrailsValidation(parsed.data, { actorUserId: request.context.userId, + executionContext: request.context, headers: request.headers, requestId: request.requestId, signal: request.signal, diff --git a/apps/sim/lib/internal/guardrails/operations.ts b/apps/sim/lib/internal/guardrails/operations.ts index 5c81f55647c..18eda1d2461 100644 --- a/apps/sim/lib/internal/guardrails/operations.ts +++ b/apps/sim/lib/internal/guardrails/operations.ts @@ -19,6 +19,7 @@ import { validatePII } from '@/lib/guardrails/validate_pii' import { validateRegex } from '@/lib/guardrails/validate_regex' import { GuardrailsOperationError } from '@/lib/internal/guardrails/errors' import type { GuardrailsValidationInput } from '@/lib/internal/guardrails/input' +import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' import { assertPermissionsAllowed, ModelNotAllowedError, @@ -56,6 +57,7 @@ export interface GuardrailsOperationOutput { export interface GuardrailsOperationContext { actorUserId: string + executionContext: InternalToolOperationContext headers: Headers requestId: string signal?: AbortSignal @@ -240,6 +242,7 @@ async function executeValidation( workflowId: input.workflowId, workspaceId: hallucinationContext.workspaceId, actorUserId: context.actorUserId, + executionContext: context.executionContext, billingAttribution: hallucinationContext.billingAttribution, requestId: context.requestId, resolvedSecretTraceRegistry: hallucinationContext.resolvedSecretTraceRegistry, diff --git a/apps/sim/lib/internal/knowledge/list-tags.ts b/apps/sim/lib/internal/knowledge/list-tags.ts index 3c75a5a7ea5..f2db525ceac 100644 --- a/apps/sim/lib/internal/knowledge/list-tags.ts +++ b/apps/sim/lib/internal/knowledge/list-tags.ts @@ -1,26 +1,21 @@ -import { createExecutorPrincipal } from '@/lib/internal/principals/executor' +import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' import { listKnowledgeTags } from '@/lib/knowledge/application/tags' export interface ListKnowledgeTagsAsExecutorInput { knowledgeBaseId: string - userId: string workspaceId: string - workflowId: string - executionId?: string + context: InternalToolOperationContext } export async function listKnowledgeTagsAsExecutor({ knowledgeBaseId, - userId, workspaceId, - workflowId, - executionId, + context, }: ListKnowledgeTagsAsExecutorInput) { - const principal = await createExecutorPrincipal({ - userId, - workflowId, - ...(executionId ? { executionId } : {}), + const principal = await createExecutorPrincipalFromExecutionContext({ + context, audience: KNOWLEDGE_DELEGATION_AUDIENCE, }) const result = await listKnowledgeTags.execute({ diff --git a/apps/sim/lib/internal/knowledge/operations.test.ts b/apps/sim/lib/internal/knowledge/operations.test.ts index 60b84be888d..36e1db99a6e 100644 --- a/apps/sim/lib/internal/knowledge/operations.test.ts +++ b/apps/sim/lib/internal/knowledge/operations.test.ts @@ -4,18 +4,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - requireBillingAttributionHeader: vi.fn(), + requireWorkspaceBillingAttributionHeader: vi.fn(), listKnowledgeTags: { execute: vi.fn() }, syncKnowledgeConnector: { execute: vi.fn() }, connectorSynced: vi.fn(), })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ - requireBillingAttributionHeader: mocks.requireBillingAttributionHeader, + requireWorkspaceBillingAttributionHeader: mocks.requireWorkspaceBillingAttributionHeader, })) vi.mock('@/lib/knowledge/api/internal-route', () => ({ - internalKnowledgeActorUserId: (principal: { subjectUserId: string }) => principal.subjectUserId, + internalKnowledgeProvenanceUserId: (_headers: Headers, principal: { subjectUserId?: string }) => + principal.subjectUserId ?? 'billing-owner', internalKnowledgeAnalytics: { connectorSynced: mocks.connectorSynced, documentDeleted: vi.fn(), @@ -125,7 +126,7 @@ describe('Knowledge direct operations', () => { it('restores exact billing attribution before the canonical connector sync use case', async () => { const attribution = { actorUserId: 'trusted-user', workspaceId: 'workspace-1' } - mocks.requireBillingAttributionHeader.mockReturnValue(attribution) + mocks.requireWorkspaceBillingAttributionHeader.mockReturnValue(attribution) mocks.syncKnowledgeConnector.execute.mockImplementation(async ({ input }) => { await expect(input.resolveBillingAttribution('workspace-1')).resolves.toBe(attribution) return { @@ -139,8 +140,7 @@ describe('Knowledge direct operations', () => { const result = await syncConnectorOperation('kb-1', 'connector-1', false, context) - expect(mocks.requireBillingAttributionHeader).toHaveBeenCalledWith(context.headers, { - actorUserId: 'trusted-user', + expect(mocks.requireWorkspaceBillingAttributionHeader).toHaveBeenCalledWith(context.headers, { workspaceId: 'workspace-1', }) expect(mocks.syncKnowledgeConnector.execute).toHaveBeenCalledWith({ diff --git a/apps/sim/lib/internal/knowledge/operations.ts b/apps/sim/lib/internal/knowledge/operations.ts index 8b592ba762d..10d179bdece 100644 --- a/apps/sim/lib/internal/knowledge/operations.ts +++ b/apps/sim/lib/internal/knowledge/operations.ts @@ -11,11 +11,11 @@ import { } from '@/lib/api/contracts/knowledge' import type { KnowledgeSearchBody } from '@/lib/api/contracts/knowledge/search' import { AuthType } from '@/lib/auth/hybrid' -import { requireBillingAttributionHeader } from '@/lib/billing/core/billing-attribution' +import { requireWorkspaceBillingAttributionHeader } from '@/lib/billing/core/billing-attribution' import { OrchestrationError } from '@/lib/core/orchestration/types' import { - internalKnowledgeActorUserId, internalKnowledgeAnalytics, + internalKnowledgeProvenanceUserId, toInternalKnowledgeChunk, toInternalKnowledgeConnector, toInternalKnowledgeConnectorDetail, @@ -76,10 +76,7 @@ function throwIfAborted(context: KnowledgeOperationContext): void { } function billingAttribution(context: KnowledgeOperationContext, workspaceId: string) { - return requireBillingAttributionHeader(context.headers, { - actorUserId: internalKnowledgeActorUserId(context.principal), - workspaceId, - }) + return requireWorkspaceBillingAttributionHeader(context.headers, { workspaceId }) } function resolveChunkContentProvenance( @@ -92,7 +89,7 @@ function resolveChunkContentProvenance( headers: context.headers, payload, authType: AuthType.INTERNAL_JWT, - userId: internalKnowledgeActorUserId(context.principal), + userId: internalKnowledgeProvenanceUserId(context.headers, context.principal, workspaceId), ...(workspaceId ? { workspaceId } : {}), selectionKeys: includeContent ? ['chunk-content'] : [], }) @@ -140,7 +137,11 @@ export async function listDocumentsOperation( const finalization = await finalizeKnowledgePersistedResponse({ headers: context.headers, authType: AuthType.INTERNAL_JWT, - userId: internalKnowledgeActorUserId(context.principal), + userId: internalKnowledgeProvenanceUserId( + context.headers, + context.principal, + result.workspaceId + ), workspaceId: result.workspaceId, body, documents: result.documents.map((document) => ({ @@ -235,7 +236,11 @@ export async function readDocumentOperation( const finalization = await finalizeKnowledgePersistedResponse({ headers: context.headers, authType: AuthType.INTERNAL_JWT, - userId: internalKnowledgeActorUserId(context.principal), + userId: internalKnowledgeProvenanceUserId( + context.headers, + context.principal, + result.workspaceId + ), workspaceId: result.workspaceId, body, documents: [ @@ -379,7 +384,11 @@ export async function listChunksOperation( const finalization = await finalizeKnowledgePersistedResponse({ headers: context.headers, authType: AuthType.INTERNAL_JWT, - userId: internalKnowledgeActorUserId(context.principal), + userId: internalKnowledgeProvenanceUserId( + context.headers, + context.principal, + result.workspaceId + ), workspaceId: result.workspaceId, body, chunks: result.chunks.map((chunk) => ({ @@ -457,7 +466,11 @@ export async function updateChunkOperation( const finalization = await finalizeKnowledgePersistedResponse({ headers: context.headers, authType: AuthType.INTERNAL_JWT, - userId: internalKnowledgeActorUserId(context.principal), + userId: internalKnowledgeProvenanceUserId( + context.headers, + context.principal, + result.workspaceId + ), workspaceId: result.workspaceId, body, chunks: [ diff --git a/apps/sim/lib/internal/knowledge/search.ts b/apps/sim/lib/internal/knowledge/search.ts index b81e779df33..3ac1d3042c3 100644 --- a/apps/sim/lib/internal/knowledge/search.ts +++ b/apps/sim/lib/internal/knowledge/search.ts @@ -1,5 +1,6 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' -import { createExecutorPrincipal } from '@/lib/internal/principals/executor' +import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' import { searchKnowledge } from '@/lib/knowledge/application/search' import type { @@ -11,10 +12,8 @@ export interface SearchKnowledgeAsExecutorInput { knowledgeBaseIds: string[] query: string topK: number - userId: string workspaceId: string - workflowId: string - executionId?: string + context: InternalToolOperationContext billingAttribution: BillingAttributionSnapshot resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry modelInputPaths: readonly ResolvedSecretInputPath[] @@ -25,20 +24,16 @@ export async function searchKnowledgeAsExecutor({ knowledgeBaseIds, query, topK, - userId, workspaceId, - workflowId, - executionId, + context, billingAttribution, resolvedSecretTraceRegistry, modelInputPaths, signal, }: SearchKnowledgeAsExecutorInput) { signal?.throwIfAborted() - const principal = await createExecutorPrincipal({ - userId, - workflowId, - ...(executionId ? { executionId } : {}), + const principal = await createExecutorPrincipalFromExecutionContext({ + context, audience: KNOWLEDGE_DELEGATION_AUDIENCE, }) const resultSecretRegistry = resolvedSecretTraceRegistry.forkForInputPaths(modelInputPaths) diff --git a/apps/sim/lib/internal/mcp/discover-tools.ts b/apps/sim/lib/internal/mcp/discover-tools.ts index efdfe25a054..78050f11e9c 100644 --- a/apps/sim/lib/internal/mcp/discover-tools.ts +++ b/apps/sim/lib/internal/mcp/discover-tools.ts @@ -1,29 +1,24 @@ -import { createExecutorPrincipal } from '@/lib/internal/principals/executor' +import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' import { discoverMcpServerToolsUseCase } from '@/lib/mcp/application/use-cases' export interface DiscoverMcpServerToolsAsExecutorInput { - userId: string workspaceId: string - workflowId: string - executionId?: string + context: InternalToolOperationContext serverId: string signal?: AbortSignal } export async function discoverMcpServerToolsAsExecutor({ - userId, workspaceId, - workflowId, - executionId, + context, serverId, signal, }: DiscoverMcpServerToolsAsExecutorInput) { signal?.throwIfAborted() - const principal = await createExecutorPrincipal({ - userId, - workflowId, - ...(executionId ? { executionId } : {}), + const principal = await createExecutorPrincipalFromExecutionContext({ + context, audience: MCP_SERVER_DELEGATION_AUDIENCE, }) diff --git a/apps/sim/lib/internal/memory/execute-tool.test.ts b/apps/sim/lib/internal/memory/execute-tool.test.ts index 8120a1e7ce9..9f5eab78047 100644 --- a/apps/sim/lib/internal/memory/execute-tool.test.ts +++ b/apps/sim/lib/internal/memory/execute-tool.test.ts @@ -45,6 +45,32 @@ const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, } +const ACTORLESS_DEPLOYED_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + workspaceId: 'workspace-canonical', + delegationId: 'delegation-actorless', + audience: 'sim:memory', + issuedAt: new Date('2026-08-27T00:00:00.000Z'), + expiresAt: new Date('2026-08-27T00:05:00.000Z'), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-canonical', + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + }, +} + const CONTEXT = { userId: 'user-1', workflowId: 'workflow-1' } as ExecutionContext const MEMORY = { @@ -131,6 +157,45 @@ describe('executeMemoryTool', () => { expect(mocks.add).not.toHaveBeenCalled() }) + it('preserves actorless deployed authority and uses only post-authorization provenance scope', async () => { + const provenanceScope = { + userId: 'billing-owner', + workspaceId: 'workspace-canonical', + } + const actorlessContext = { + workflowId: 'workflow-1', + workspaceId: 'workspace-canonical', + executionId: 'execution-1', + executorDelegationOrigin: { + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: ACTORLESS_DEPLOYED_PRINCIPAL.delegationContext?.principal, + currentWorkflow: ACTORLESS_DEPLOYED_PRINCIPAL.delegationContext?.currentWorkflow, + }, + } + mocks.createPrincipal.mockResolvedValueOnce(ACTORLESS_DEPLOYED_PRINCIPAL) + mocks.list.mockResolvedValueOnce({ + body: { success: true, data: { memories: [MEMORY] } }, + provenance: [], + provenanceScope, + }) + + const response = await executeMemoryTool({ + toolId: 'memory_get_all', + input: {}, + headers: new Headers(), + context: actorlessContext, + requestId: 'request-1', + }) + + expect(response.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ principal: ACTORLESS_DEPLOYED_PRINCIPAL }) + ) + expect(mocks.createResponse).toHaveBeenCalledWith(expect.any(Object), [], provenanceScope) + }) + it('rejects invalid input and invalid operation responses', async () => { const invalidInput = await executeMemoryTool({ toolId: 'memory_get', diff --git a/apps/sim/lib/internal/memory/execute-tool.ts b/apps/sim/lib/internal/memory/execute-tool.ts index a3b443a2e18..19374b80108 100644 --- a/apps/sim/lib/internal/memory/execute-tool.ts +++ b/apps/sim/lib/internal/memory/execute-tool.ts @@ -135,7 +135,11 @@ export const executeMemoryTool: InternalToolOperationHandler = async (request) = string, unknown > - return createMemoryToolResponse(body, dispatched.result.provenance, principal) + return createMemoryToolResponse( + body, + dispatched.result.provenance, + dispatched.result.provenanceScope + ) } catch (error) { request.signal?.throwIfAborted() if ( diff --git a/apps/sim/lib/internal/memory/operations.test.ts b/apps/sim/lib/internal/memory/operations.test.ts index 1674eeb7233..8a202a47365 100644 --- a/apps/sim/lib/internal/memory/operations.test.ts +++ b/apps/sim/lib/internal/memory/operations.test.ts @@ -11,7 +11,13 @@ const mocks = vi.hoisted(() => ({ read: vi.fn(), remove: vi.fn(), requestsProvenance: vi.fn(), + suppliesWriteProvenance: vi.fn(), readWriteProvenance: vi.fn(), + requireBillingAttribution: vi.fn(), +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + requireWorkspaceBillingAttributionHeader: mocks.requireBillingAttribution, })) vi.mock('@/lib/memory/application/use-cases', () => ({ @@ -23,6 +29,7 @@ vi.mock('@/lib/memory/application/use-cases', () => ({ vi.mock('@/lib/internal/memory/provenance', () => ({ memoryToolRequestsProvenance: mocks.requestsProvenance, + memoryToolSuppliesWriteProvenance: mocks.suppliesWriteProvenance, readMemoryWriteProvenance: mocks.readWriteProvenance, })) @@ -61,7 +68,12 @@ describe('Memory direct operations', () => { beforeEach(() => { vi.clearAllMocks() mocks.requestsProvenance.mockReturnValue(false) + mocks.suppliesWriteProvenance.mockReturnValue(false) mocks.readWriteProvenance.mockReturnValue(undefined) + mocks.requireBillingAttribution.mockReturnValue({ + billedAccountUserId: 'billing-owner', + workspaceId: 'workspace-canonical', + }) mocks.append.mockResolvedValue({ record: RECORD }) mocks.list.mockResolvedValue({ records: [RECORD] }) mocks.read.mockResolvedValue({ record: RECORD }) @@ -71,6 +83,7 @@ describe('Memory direct operations', () => { it('binds append authority and provenance to the canonical delegated workspace', async () => { const writeProvenance = { status: 'exact', entries: [] } mocks.requestsProvenance.mockReturnValue(true) + mocks.suppliesWriteProvenance.mockReturnValue(true) mocks.readWriteProvenance.mockReturnValue(writeProvenance) await executeMemoryAdd( @@ -87,10 +100,25 @@ describe('Memory direct operations', () => { input: expect.objectContaining({ workspaceId: 'workspace-canonical', key: 'conversation-1', - writeProvenance, + resolveWriteProvenance: expect.any(Function), + resolveBillingAttribution: expect.any(Function), includePersistedSecretProvenance: true, }), }) + const input = mocks.append.mock.calls[0]?.[0].input + const scope = { userId: 'billing-owner', workspaceId: 'workspace-canonical' } + expect(input.resolveWriteProvenance(scope)).toBe(writeProvenance) + expect(mocks.readWriteProvenance).toHaveBeenCalledWith( + expect.any(Headers), + expect.objectContaining({ key: 'conversation-1' }), + scope + ) + await expect(input.resolveBillingAttribution('workspace-canonical')).resolves.toMatchObject({ + billedAccountUserId: 'billing-owner', + }) + expect(mocks.requireBillingAttribution).toHaveBeenCalledWith(expect.any(Headers), { + workspaceId: 'workspace-canonical', + }) }) it('preserves list, read, and delete semantics without trusting workspace parameters', async () => { diff --git a/apps/sim/lib/internal/memory/operations.ts b/apps/sim/lib/internal/memory/operations.ts index 23914f8a9b6..29103641ced 100644 --- a/apps/sim/lib/internal/memory/operations.ts +++ b/apps/sim/lib/internal/memory/operations.ts @@ -5,14 +5,17 @@ import type { deleteMemoryByQueryContract, listMemoriesContract, } from '@/lib/api/contracts/memory' +import { requireWorkspaceBillingAttributionHeader } from '@/lib/billing/core/billing-attribution' import { memoryToolRequestsProvenance, + memoryToolSuppliesWriteProvenance, readMemoryWriteProvenance, } from '@/lib/internal/memory/provenance' import { appendMemoryUseCase, deleteMemoryUseCase, listMemoriesUseCase, + type MemoryLegacyProvenanceScope, type MemoryReadProvenance, readMemoryUseCase, } from '@/lib/memory/application/use-cases' @@ -26,6 +29,7 @@ export interface MemoryToolOperationContext { export interface MemoryToolOperationResult { body: Record provenance?: MemoryReadProvenance[] + provenanceScope?: MemoryLegacyProvenanceScope } function complete(context: MemoryToolOperationContext, value: T): T { @@ -38,14 +42,20 @@ export async function executeMemoryAdd( context: MemoryToolOperationContext ): Promise { const includePersistedSecretProvenance = memoryToolRequestsProvenance(context.headers) + const resolveWriteProvenance = memoryToolSuppliesWriteProvenance(context.headers, body) + ? (scope: MemoryLegacyProvenanceScope) => + readMemoryWriteProvenance(context.headers, body, scope) + : undefined const result = await appendMemoryUseCase.execute({ principal: context.principal, input: { workspaceId: context.principal.workspaceId, key: body.key ?? '', data: body.data, - writeProvenance: readMemoryWriteProvenance(context.headers, body, context.principal), + ...(resolveWriteProvenance ? { resolveWriteProvenance } : {}), includePersistedSecretProvenance, + resolveBillingAttribution: async (workspaceId) => + requireWorkspaceBillingAttributionHeader(context.headers, { workspaceId }), signal: context.signal, }, }) @@ -55,6 +65,7 @@ export async function executeMemoryAdd( data: { conversationId: result.record.key, data: result.record.data }, }, provenance: result.readProvenance, + provenanceScope: result.provenanceScope, }) } @@ -69,6 +80,8 @@ export async function executeMemoryList( query: query.query, limit: query.limit, includePersistedSecretProvenance: memoryToolRequestsProvenance(context.headers), + resolveBillingAttribution: async (workspaceId) => + requireWorkspaceBillingAttributionHeader(context.headers, { workspaceId }), signal: context.signal, }, }) @@ -83,6 +96,7 @@ export async function executeMemoryList( }, }, provenance: result.readProvenance, + provenanceScope: result.provenanceScope, }) } @@ -96,6 +110,8 @@ export async function executeMemoryGet( workspaceId: context.principal.workspaceId, key, includePersistedSecretProvenance: memoryToolRequestsProvenance(context.headers), + resolveBillingAttribution: async (workspaceId) => + requireWorkspaceBillingAttributionHeader(context.headers, { workspaceId }), signal: context.signal, }, }) @@ -105,6 +121,7 @@ export async function executeMemoryGet( data: result.record ? { conversationId: result.record.key, data: result.record.data } : null, }, provenance: result.readProvenance, + provenanceScope: result.provenanceScope, }) } diff --git a/apps/sim/lib/internal/memory/provenance.test.ts b/apps/sim/lib/internal/memory/provenance.test.ts index 6d4829d04a3..c84611b03d5 100644 --- a/apps/sim/lib/internal/memory/provenance.test.ts +++ b/apps/sim/lib/internal/memory/provenance.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' import { PRIVATE_SECRET_PROVENANCE_BUNDLE_V1, @@ -16,20 +15,11 @@ import { createMemoryToolResponse, MemoryProvenanceError, memoryToolRequestsProvenance, + memoryToolSuppliesWriteProvenance, readMemoryWriteProvenance, } from '@/lib/internal/memory/provenance' -const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'billing-actor', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:memory', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2026-08-27T00:05:00.000Z'), - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, -} +const PROVENANCE_SCOPE = { userId: 'billing-owner', workspaceId: 'workspace-1' } function privateWritePayload(workspaceId: string) { return { @@ -53,16 +43,20 @@ function privateWritePayload(workspaceId: string) { describe('Memory direct provenance', () => { it('keeps unsupported headerless executor writes on the legacy untracked path', () => { - expect(readMemoryWriteProvenance(new Headers(), {}, PRINCIPAL)).toBeUndefined() + expect(memoryToolSuppliesWriteProvenance(new Headers(), {})).toBe(false) + expect(readMemoryWriteProvenance(new Headers(), {}, PROVENANCE_SCOPE)).toBeUndefined() }) it('binds authenticated provenance to the canonical workspace and preserves its source owner', () => { const headers = new Headers({ [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1, }) + expect(memoryToolSuppliesWriteProvenance(headers, privateWritePayload('workspace-1'))).toBe( + true + ) expect( - readMemoryWriteProvenance(headers, privateWritePayload('workspace-1'), PRINCIPAL) + readMemoryWriteProvenance(headers, privateWritePayload('workspace-1'), PROVENANCE_SCOPE) ).toEqual({ status: 'exact', entries: [ @@ -75,7 +69,7 @@ describe('Memory direct provenance', () => { ], }) expect(() => - readMemoryWriteProvenance(headers, privateWritePayload('workspace-2'), PRINCIPAL) + readMemoryWriteProvenance(headers, privateWritePayload('workspace-2'), PROVENANCE_SCOPE) ).toThrow(MemoryProvenanceError) }) @@ -87,13 +81,13 @@ describe('Memory direct provenance', () => { expect(memoryToolRequestsProvenance(requestedHeaders)).toBe(true) const body = { success: true, data: { memories: [] } } - const ordinary = await createMemoryToolResponse(body, undefined, PRINCIPAL) + const ordinary = await createMemoryToolResponse(body, undefined, undefined) expect(await ordinary.json()).toEqual(body) const privateResponse = await createMemoryToolResponse( body, [{ data: [], provenance: { status: 'exact', entries: [] } }], - PRINCIPAL + PROVENANCE_SCOPE ) expect(await privateResponse.json()).toMatchObject({ ...body, diff --git a/apps/sim/lib/internal/memory/provenance.ts b/apps/sim/lib/internal/memory/provenance.ts index 0c0664f529b..788186ecef4 100644 --- a/apps/sim/lib/internal/memory/provenance.ts +++ b/apps/sim/lib/internal/memory/provenance.ts @@ -1,5 +1,3 @@ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' -import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { type DurableSecretProvenance, durableSecretProvenanceFromPrivateBundle, @@ -14,7 +12,10 @@ import { RESOLVED_SECRET_PROVENANCE_METADATA_V1, serializePrivateToolMetadataResponseEnvelope, } from '@/lib/execution/private-tool-metadata' -import type { MemoryReadProvenance } from '@/lib/memory/application/use-cases' +import type { + MemoryLegacyProvenanceScope, + MemoryReadProvenance, +} from '@/lib/memory/application/use-cases' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' export class MemoryProvenanceError extends Error { @@ -24,10 +25,14 @@ export class MemoryProvenanceError extends Error { } } +export function memoryToolSuppliesWriteProvenance(headers: Headers, payload: unknown): boolean { + return inspectPrivateSecretProvenanceRequest(headers, payload).status !== 'unsupported' +} + export function readMemoryWriteProvenance( headers: Headers, payload: unknown, - principal: WorkflowExecutionDelegatedPrincipal + scope: MemoryLegacyProvenanceScope ): DurableSecretProvenance | undefined { const inspection = inspectPrivateSecretProvenanceRequest(headers, payload) if (inspection.status === 'unsupported') return undefined @@ -37,10 +42,7 @@ export function readMemoryWriteProvenance( if (!inspection.value.complete) return { status: 'unknown' } if (inspection.value.selections.length !== 1) throw new MemoryProvenanceError() - const provenance = durableSecretProvenanceFromPrivateBundle(inspection.value, 'data', { - userId: requirePrincipalSubjectUserId(principal), - workspaceId: principal.workspaceId, - }) + const provenance = durableSecretProvenanceFromPrivateBundle(inspection.value, 'data', scope) if (!provenance) throw new MemoryProvenanceError() return provenance } @@ -58,14 +60,12 @@ export function memoryToolRequestsProvenance(headers: Headers): boolean { export async function createMemoryToolResponse( body: Record, provenance: MemoryReadProvenance[] | undefined, - principal: WorkflowExecutionDelegatedPrincipal + scope: MemoryLegacyProvenanceScope | undefined ): Promise { if (provenance === undefined) return Response.json(body) + if (!scope) throw new MemoryProvenanceError() - const registry = new ResolvedSecretTraceRegistry([], { - userId: requirePrincipalSubjectUserId(principal), - workspaceId: principal.workspaceId, - }) + const registry = new ResolvedSecretTraceRegistry([], scope) for (const item of provenance) { await importDurableSecretProvenance(registry, item.provenance, item.data, 'memory', { reportUnrecorded: false, diff --git a/apps/sim/lib/internal/principals/executor.test.ts b/apps/sim/lib/internal/principals/executor.test.ts index aa2b39e58e5..064b418b34c 100644 --- a/apps/sim/lib/internal/principals/executor.test.ts +++ b/apps/sim/lib/internal/principals/executor.test.ts @@ -30,7 +30,7 @@ describe('createExecutorPrincipalFromExecutionContext', () => { mockBindInternalExecutorDelegation.mockImplementation(async (claims, options) => ({ kind: 'delegated', serviceId: 'executor', - subjectUserId: claims.subjectUserId, + ...(claims.subjectUserId ? { subjectUserId: claims.subjectUserId } : {}), workspaceId: 'workspace-canonical', delegationId: claims.delegationId, audience: options.audience, @@ -40,7 +40,9 @@ describe('createExecutorPrincipalFromExecutionContext', () => { delegationContext: { kind: 'workflow_execution', workflowId: claims.workflowId, - executionId: claims.executionId, + ...(claims.executionId ? { executionId: claims.executionId } : {}), + ...(claims.principal ? { principal: claims.principal } : {}), + ...(claims.currentWorkflow ? { currentWorkflow: claims.currentWorkflow } : {}), }, })) }) @@ -68,29 +70,151 @@ describe('createExecutorPrincipalFromExecutionContext', () => { ) }) - it('falls back to the current trusted execution identity when no origin is present', async () => { + it('uses an explicit trusted execution deadline as the delegation expiry', async () => { + const expiresAt = new Date('2026-01-01T01:00:00.000Z') + await createExecutorPrincipalFromExecutionContext({ - context: executionContext(), + context: executionContext({ + executorDelegationOrigin: { + subjectUserId: 'user-origin', + workflowId: 'workflow-origin', + executionId: 'execution-origin', + }, + }), + audience: 'sim:function-executions', + expiresAt, + }) + + expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( + expect.objectContaining({ expiresAt }), + { audience: 'sim:function-executions' } + ) + }) + + it.each([ + { + name: 'schedule', + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-canonical', + workflowId: 'workflow-origin', + }, + }, + { + name: 'workspace API key', + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-canonical', + keyId: 'workspace-key-1', + }, + }, + { + name: 'webhook external subject', + principal: { + kind: 'system' as const, + serviceId: 'webhook' as const, + workspaceId: 'workspace-canonical', + workflowId: 'workflow-origin', + webhookId: 'webhook-1', + provider: 'slack', + subject: { + kind: 'external_user' as const, + provider: 'slack', + tenantId: 'team-1', + subjectId: 'external-user-1', + }, + }, + }, + ])('preserves an actorless $name principal and deployment authority', async ({ principal }) => { + const currentWorkflow = { + workflowId: 'workflow-origin', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + } + + await createExecutorPrincipalFromExecutionContext({ + context: executionContext({ + executorDelegationOrigin: { + workflowId: 'workflow-origin', + executionId: 'execution-origin', + principal, + currentWorkflow, + }, + }), audience: 'sim:tables', }) expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( expect.objectContaining({ - subjectUserId: 'user-current', - workflowId: 'workflow-current', - executionId: 'execution-current', + workflowId: 'workflow-origin', + executionId: 'execution-origin', + principal, + currentWorkflow, + }), + { audience: 'sim:tables' } + ) + expect(mockBindInternalExecutorDelegation.mock.calls[0]?.[0]).not.toHaveProperty( + 'subjectUserId' + ) + }) + + it('derives the subject from the preserved human principal', async () => { + const principal = { + kind: 'session' as const, + userId: 'user-origin', + sessionId: 'session-origin', + } + + await createExecutorPrincipalFromExecutionContext({ + context: executionContext({ + executorDelegationOrigin: { + workflowId: 'workflow-origin', + executionId: 'execution-origin', + principal, + currentWorkflow: { workflowId: 'workflow-origin', mode: 'draft' }, + }, + }), + audience: 'sim:tables', + }) + + expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( + expect.objectContaining({ + subjectUserId: 'user-origin', + principal, + currentWorkflow: { workflowId: 'workflow-origin', mode: 'draft' }, }), { audience: 'sim:tables' } ) }) - it('fails closed without an acting user in either trusted identity source', async () => { + it('rejects a supplied subject that disagrees with the preserved principal', async () => { + await expect( + createExecutorPrincipalFromExecutionContext({ + context: executionContext({ + executorDelegationOrigin: { + subjectUserId: 'forged-user', + workflowId: 'workflow-origin', + principal: { + kind: 'session', + userId: 'user-origin', + sessionId: 'session-origin', + }, + }, + }), + audience: 'sim:tables', + }) + ).rejects.toThrow('Executor subject does not match its workflow principal') + expect(mockBindInternalExecutorDelegation).not.toHaveBeenCalled() + }) + + it('fails closed without a canonical delegation origin', async () => { await expect( createExecutorPrincipalFromExecutionContext({ - context: executionContext({ userId: undefined }), + context: executionContext(), audience: 'sim:tables', }) - ).rejects.toThrow('Authentication required') + ).rejects.toThrow('Executor delegation origin is required') expect(mockBindInternalExecutorDelegation).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/internal/principals/executor.ts b/apps/sim/lib/internal/principals/executor.ts index 0807a8e1a08..9295ef88fb5 100644 --- a/apps/sim/lib/internal/principals/executor.ts +++ b/apps/sim/lib/internal/principals/executor.ts @@ -1,35 +1,53 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import { type DelegatedPrincipal, resolvePrincipalSubject } from '@sim/auth/principal' import { generateId } from '@sim/utils/id' import { bindInternalExecutorDelegation } from '@/lib/auth/internal-delegation' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' +import type { ExecutorDelegationOrigin } from '@/executor/types' const EXECUTOR_DELEGATION_TTL_MS = 5 * 60 * 1000 -export interface CreateExecutorPrincipalInput { - userId: string - workflowId: string - executionId?: string - audience: string - resourceScope?: DelegatedPrincipal['resourceScope'] +export function resolveExecutorOriginSubject(origin: ExecutorDelegationOrigin): string | undefined { + const principalSubject = origin.principal ? resolvePrincipalSubject(origin.principal) : null + if (principalSubject?.kind === 'external_user' && origin.subjectUserId) { + throw new Error('External workflow subjects cannot be represented as Sim users') + } + if (!principalSubject && origin.principal && origin.subjectUserId) { + throw new Error('Actorless workflow principals cannot be represented as Sim users') + } + if ( + principalSubject?.kind === 'sim_user' && + origin.subjectUserId && + origin.subjectUserId !== principalSubject.userId + ) { + throw new Error('Executor subject does not match its workflow principal') + } + + const subjectUserId = + principalSubject?.kind === 'sim_user' ? principalSubject.userId : origin.subjectUserId + if (!subjectUserId && !origin.principal) throw new Error('Authentication required') + return subjectUserId } -export async function createExecutorPrincipal({ - userId, - workflowId, - executionId, - audience, - resourceScope, -}: CreateExecutorPrincipalInput) { +async function bindExecutorPrincipal( + origin: ExecutorDelegationOrigin, + audience: string, + resourceScope?: DelegatedPrincipal['resourceScope'], + expiresAt?: Date +) { + if (!origin.workflowId.trim()) throw new Error('Authentication required') + const subjectUserId = resolveExecutorOriginSubject(origin) const issuedAt = new Date() return bindInternalExecutorDelegation( { serviceId: 'executor', - subjectUserId: userId, - workflowId, - ...(executionId ? { executionId } : {}), + ...(subjectUserId ? { subjectUserId } : {}), + workflowId: origin.workflowId, + ...(origin.executionId ? { executionId: origin.executionId } : {}), + ...(origin.principal ? { principal: origin.principal } : {}), + ...(origin.currentWorkflow ? { currentWorkflow: origin.currentWorkflow } : {}), delegationId: generateId(), issuedAt, - expiresAt: new Date(issuedAt.getTime() + EXECUTOR_DELEGATION_TTL_MS), + expiresAt: expiresAt ?? new Date(issuedAt.getTime() + EXECUTOR_DELEGATION_TTL_MS), }, { audience, @@ -42,25 +60,16 @@ export interface CreateExecutorPrincipalFromExecutionContextInput { context: InternalToolOperationContext audience: string resourceScope?: DelegatedPrincipal['resourceScope'] + expiresAt?: Date } export async function createExecutorPrincipalFromExecutionContext({ context, audience, resourceScope, + expiresAt, }: CreateExecutorPrincipalFromExecutionContextInput) { - const origin = context.executorDelegationOrigin ?? { - subjectUserId: context.userId, - workflowId: context.workflowId, - executionId: context.executionId, - } - if (!origin.subjectUserId || !origin.workflowId) throw new Error('Authentication required') - - return createExecutorPrincipal({ - userId: origin.subjectUserId, - workflowId: origin.workflowId, - ...(origin.executionId ? { executionId: origin.executionId } : {}), - audience, - ...(resourceScope ? { resourceScope } : {}), - }) + const origin = context.executorDelegationOrigin + if (!origin) throw new Error('Executor delegation origin is required') + return bindExecutorPrincipal(origin, audience, resourceScope, expiresAt) } diff --git a/apps/sim/lib/internal/table/read-schema.test.ts b/apps/sim/lib/internal/table/read-schema.test.ts index 1ea576f64a3..ff438f37705 100644 --- a/apps/sim/lib/internal/table/read-schema.test.ts +++ b/apps/sim/lib/internal/table/read-schema.test.ts @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/internal/principals/executor', () => ({ - createExecutorPrincipal: mocks.createPrincipal, + createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, })) vi.mock('@/lib/table/application/tables', () => ({ @@ -53,9 +53,14 @@ describe('readTableSchemaAsExecutor', () => { it('binds the read to the canonical delegated workspace', async () => { const result = await readTableSchemaAsExecutor({ tableId: 'table-1', - userId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', + context: { + workflowId: 'workflow-1', + executorDelegationOrigin: { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }, }) expect(mocks.readTable).toHaveBeenCalledWith({ @@ -79,8 +84,13 @@ describe('readTableSchemaAsExecutor', () => { await expect( readTableSchemaAsExecutor({ tableId: 'table-1', - userId: 'user-1', - workflowId: 'workflow-1', + context: { + workflowId: 'workflow-1', + executorDelegationOrigin: { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }, + }, }) ).rejects.toThrow('Invalid table column 0 while enriching schema for table-1') }) diff --git a/apps/sim/lib/internal/table/read-schema.ts b/apps/sim/lib/internal/table/read-schema.ts index eadf57a9d0d..b1559501e87 100644 --- a/apps/sim/lib/internal/table/read-schema.ts +++ b/apps/sim/lib/internal/table/read-schema.ts @@ -1,4 +1,5 @@ -import { createExecutorPrincipal } from '@/lib/internal/principals/executor' +import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' import { readTableDefinitionUseCase } from '@/lib/table/application/tables' import { isColumnType } from '@/lib/table/column-types' @@ -6,21 +7,15 @@ import type { TableSummary } from '@/lib/table/types' export interface ReadTableSchemaAsExecutorInput { tableId: string - userId: string - workflowId: string - executionId?: string + context: InternalToolOperationContext } export async function readTableSchemaAsExecutor({ tableId, - userId, - workflowId, - executionId, + context, }: ReadTableSchemaAsExecutorInput): Promise { - const principal = await createExecutorPrincipal({ - userId, - workflowId, - ...(executionId ? { executionId } : {}), + const principal = await createExecutorPrincipalFromExecutionContext({ + context, audience: TABLE_DELEGATION_AUDIENCE, resourceScope: { tableId }, }) diff --git a/apps/sim/lib/internal/workflows/read-tool-enrichment.test.ts b/apps/sim/lib/internal/workflows/read-tool-enrichment.test.ts new file mode 100644 index 00000000000..868714d5cc6 --- /dev/null +++ b/apps/sim/lib/internal/workflows/read-tool-enrichment.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockReadWorkflowDefinitionAsExecutor } = vi.hoisted(() => ({ + mockReadWorkflowDefinitionAsExecutor: vi.fn(), +})) + +vi.mock('@/lib/internal/workflows/read-definition', () => ({ + readWorkflowDefinitionAsExecutor: mockReadWorkflowDefinitionAsExecutor, +})) + +import { + readWorkflowInputFieldsForTool, + readWorkflowMetadataForTool, +} from '@/lib/internal/workflows/read-tool-enrichment' + +describe('workflow tool enrichment authority', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('derives target draft authority from the verified human execution principal', async () => { + mockReadWorkflowDefinitionAsExecutor.mockResolvedValue({ + workflow: { name: 'Child workflow', description: 'Runs the child' }, + state: { blocks: {} }, + }) + + await expect( + readWorkflowMetadataForTool('child-workflow', { + userId: 'billing-owner', + workflowId: 'parent-workflow', + executionId: 'execution-1', + executorDelegationOrigin: { + workflowId: 'parent-workflow', + executionId: 'execution-1', + principal: { kind: 'session', userId: 'actual-user', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, + }, + }) + ).resolves.toEqual({ name: 'Child workflow', description: 'Runs the child' }) + + expect(mockReadWorkflowDefinitionAsExecutor).toHaveBeenCalledWith({ + origin: { subjectUserId: 'actual-user', workflowId: 'child-workflow' }, + workflowId: 'child-workflow', + state: 'draft', + }) + }) + + it('preserves deployed authority instead of reinterpreting a compatibility user as actor', async () => { + mockReadWorkflowDefinitionAsExecutor.mockResolvedValue({ + workflow: { name: 'Child workflow', description: null }, + state: { blocks: {} }, + }) + const principal = { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'parent-workflow', + } + const currentWorkflow = { + workflowId: 'parent-workflow', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + } + + await expect( + readWorkflowInputFieldsForTool('child-workflow', { + userId: 'billing-owner', + workflowId: 'parent-workflow', + executionId: 'execution-1', + executorDelegationOrigin: { + workflowId: 'parent-workflow', + executionId: 'execution-1', + principal, + currentWorkflow, + }, + }) + ).resolves.toEqual([]) + + expect(mockReadWorkflowDefinitionAsExecutor).toHaveBeenCalledWith({ + origin: { + workflowId: 'parent-workflow', + executionId: 'execution-1', + principal, + currentWorkflow, + }, + workflowId: 'child-workflow', + state: 'deployed', + }) + }) + + it('rejects actorless draft enrichment', async () => { + await expect( + readWorkflowMetadataForTool('child-workflow', { + userId: 'billing-owner', + workflowId: 'parent-workflow', + executorDelegationOrigin: { + workflowId: 'parent-workflow', + principal: { + kind: 'system', + serviceId: 'internal', + workspaceId: 'workspace-1', + workflowId: 'parent-workflow', + }, + currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, + }, + }) + ).rejects.toThrow('Actorless workflow enrichment requires deployed execution authority') + + expect(mockReadWorkflowDefinitionAsExecutor).not.toHaveBeenCalled() + }) + + it('fails closed when execution authority is absent', async () => { + await expect( + readWorkflowMetadataForTool('child-workflow', { + userId: 'billing-owner', + workflowId: 'parent-workflow', + }) + ).rejects.toThrow('Workflow enrichment requires trusted execution authority') + + expect(mockReadWorkflowDefinitionAsExecutor).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/workflows/read-tool-enrichment.ts b/apps/sim/lib/internal/workflows/read-tool-enrichment.ts index be60e74ad1d..9ec04b735ff 100644 --- a/apps/sim/lib/internal/workflows/read-tool-enrichment.ts +++ b/apps/sim/lib/internal/workflows/read-tool-enrichment.ts @@ -1,38 +1,39 @@ +import { resolveExecutorOriginSubject } from '@/lib/internal/principals/executor' import { readWorkflowDefinitionAsExecutor } from '@/lib/internal/workflows/read-definition' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import type { ExecutorDelegationOrigin } from '@/executor/types' export interface WorkflowToolEnrichmentContext { userId?: string workflowId?: string executionId?: string + executorDelegationOrigin?: ExecutorDelegationOrigin } -async function readDraftWorkflowForTool( - workflowId: string, - context: WorkflowToolEnrichmentContext -) { - if (!context.userId) { - throw new Error('Workflow enrichment requires a trusted execution subject') +async function readWorkflowForTool(workflowId: string, context: WorkflowToolEnrichmentContext) { + const origin = context.executorDelegationOrigin + if (!origin) { + throw new Error('Workflow enrichment requires trusted execution authority') } - - return readWorkflowDefinitionAsExecutor({ - origin: { - subjectUserId: context.userId, + const subjectUserId = resolveExecutorOriginSubject(origin) + if (subjectUserId) { + return readWorkflowDefinitionAsExecutor({ + origin: { subjectUserId, workflowId }, workflowId, - ...(context.workflowId === workflowId && context.executionId - ? { executionId: context.executionId } - : {}), - }, - workflowId, - state: 'draft', - }) + state: 'draft', + }) + } + if (origin.currentWorkflow?.mode !== 'deployment') { + throw new Error('Actorless workflow enrichment requires deployed execution authority') + } + return readWorkflowDefinitionAsExecutor({ origin, workflowId, state: 'deployed' }) } export async function readWorkflowMetadataForTool( workflowId: string, context: WorkflowToolEnrichmentContext ): Promise<{ name: string; description: string | null }> { - const { workflow } = await readDraftWorkflowForTool(workflowId, context) + const { workflow } = await readWorkflowForTool(workflowId, context) return { name: workflow.name || 'Workflow', description: workflow.description || null, @@ -43,6 +44,6 @@ export async function readWorkflowInputFieldsForTool( workflowId: string, context: WorkflowToolEnrichmentContext ): Promise> { - const { state } = await readDraftWorkflowForTool(workflowId, context) + const { state } = await readWorkflowForTool(workflowId, context) return extractInputFieldsFromBlocks(state?.blocks ?? {}) } diff --git a/apps/sim/lib/knowledge/api/internal-route.test.ts b/apps/sim/lib/knowledge/api/internal-route.test.ts index 4ea8504ec64..3b04901a13c 100644 --- a/apps/sim/lib/knowledge/api/internal-route.test.ts +++ b/apps/sim/lib/knowledge/api/internal-route.test.ts @@ -97,7 +97,7 @@ describe('internal Knowledge execution attribution', () => { await expect( resolveInternalKnowledgeBillingAttribution(request(), executor, 'workspace-1') ).resolves.toEqual(BILLING_ATTRIBUTION) - expect(internalKnowledgeProvenanceUserId(request(), executor, 'workspace-1')).toBe( + expect(internalKnowledgeProvenanceUserId(request().headers, executor, 'workspace-1')).toBe( 'billing-owner-1' ) expect( diff --git a/apps/sim/lib/knowledge/api/internal-route.ts b/apps/sim/lib/knowledge/api/internal-route.ts index 2ce9787f8a7..68fa01cd54f 100644 --- a/apps/sim/lib/knowledge/api/internal-route.ts +++ b/apps/sim/lib/knowledge/api/internal-route.ts @@ -36,7 +36,7 @@ export function internalKnowledgeActorUserId(principal: Principal): string { } export function internalKnowledgeProvenanceUserId( - request: NextRequest, + headers: Headers, principal: Principal, workspaceId: string | undefined ): string { @@ -46,8 +46,7 @@ export function internalKnowledgeProvenanceUserId( if (!workspaceId) { throw new Error('Delegated Knowledge provenance requires a workspace scope') } - return requireWorkspaceBillingAttributionHeader(request.headers, { workspaceId }) - .billedAccountUserId + return requireWorkspaceBillingAttributionHeader(headers, { workspaceId }).billedAccountUserId } export function internalKnowledgeAuthType(principal: Principal): AuthTypeValue { diff --git a/apps/sim/lib/memory/application/use-cases.test.ts b/apps/sim/lib/memory/application/use-cases.test.ts new file mode 100644 index 00000000000..f30c95699ba --- /dev/null +++ b/apps/sim/lib/memory/application/use-cases.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment node + */ + +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + reportUnrecorded: vi.fn(), + readBoundProvenance: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: (value: unknown) => value, +})) + +vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({ + isDurableSecretProvenanceEnforced: () => false, + reportUnrecordedDurableProvenance: mocks.reportUnrecorded, +})) + +vi.mock('@/lib/memory/secret-provenance', () => ({ + readBoundMemorySecretProvenance: mocks.readBoundProvenance, + replaceMemorySecretProvenanceInTx: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +import { listMemoriesUseCase } from '@/lib/memory/application/use-cases' + +const WORKSPACE_ID = 'workspace-canonical' +const BILLING_OWNER_ID = 'billing-owner' +const BILLING_ATTRIBUTION: BillingAttributionSnapshot = { + actorUserId: BILLING_OWNER_ID, + workspaceId: WORKSPACE_ID, + organizationId: null, + billedAccountUserId: BILLING_OWNER_ID, + billingEntity: { type: 'user', id: BILLING_OWNER_ID }, + billingPeriod: { + start: '2026-08-01T00:00:00.000Z', + end: '2026-09-01T00:00:00.000Z', + }, + payerSubscription: null, +} + +const ACTORLESS_DEPLOYED_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + workspaceId: WORKSPACE_ID, + delegationId: 'delegation-1', + audience: 'sim:memory', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: WORKSPACE_ID, + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + }, +} + +describe('Memory application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: BILLING_OWNER_ID, + }) + mocks.readBoundProvenance.mockReturnValue({ status: 'unknown' }) + }) + + it('authorizes an actorless deployment before using signed billing for legacy provenance', async () => { + const record = { + id: 'memory-1', + key: 'conversation-1', + data: [{ role: 'user', content: 'hello' }], + secretProvenanceVersion: null, + } + queueTableRows(schemaMock.memory, [record]) + queueTableRows(schemaMock.memorySecretProvenance, []) + const resolveBillingAttribution = vi.fn(async () => BILLING_ATTRIBUTION) + + const result = await listMemoriesUseCase.execute({ + principal: ACTORLESS_DEPLOYED_PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + limit: 50, + includePersistedSecretProvenance: true, + resolveBillingAttribution, + }, + }) + + expect(mocks.loadWorkspace.mock.invocationCallOrder[0]).toBeLessThan( + resolveBillingAttribution.mock.invocationCallOrder[0] + ) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(resolveBillingAttribution).toHaveBeenCalledWith(WORKSPACE_ID) + expect(result.provenanceScope).toEqual({ + userId: BILLING_OWNER_ID, + workspaceId: WORKSPACE_ID, + }) + expect(mocks.reportUnrecorded).toHaveBeenCalledWith({ + surface: 'memory', + cause: 'durable-provenance-unknown', + affectedCount: 1, + workspaceId: WORKSPACE_ID, + actorUserId: BILLING_OWNER_ID, + }) + }) + + it('rejects billing attribution outside the authorized canonical workspace', async () => { + const record = { + id: 'memory-1', + key: 'conversation-1', + data: [{ role: 'user', content: 'hello' }], + secretProvenanceVersion: null, + } + queueTableRows(schemaMock.memory, [record]) + const resolveBillingAttribution = vi.fn( + async (): Promise => ({ + ...BILLING_ATTRIBUTION, + workspaceId: 'workspace-other', + }) + ) + + await expect( + listMemoriesUseCase.execute({ + principal: ACTORLESS_DEPLOYED_PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + limit: 50, + includePersistedSecretProvenance: true, + resolveBillingAttribution, + }, + }) + ).rejects.toThrow('Memory billing attribution does not match its canonical workspace') + + expect(mocks.loadWorkspace.mock.invocationCallOrder[0]).toBeLessThan( + resolveBillingAttribution.mock.invocationCallOrder[0] + ) + expect(mocks.readBoundProvenance).not.toHaveBeenCalled() + expect(mocks.reportUnrecorded).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/memory/application/use-cases.ts b/apps/sim/lib/memory/application/use-cases.ts index 51059648c08..441405fcee7 100644 --- a/apps/sim/lib/memory/application/use-cases.ts +++ b/apps/sim/lib/memory/application/use-cases.ts @@ -1,10 +1,15 @@ -import type { Principal } from '@sim/auth/principal' -import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type Principal, + resolvePrincipalAttribution, + resolvePrincipalSubject, +} from '@sim/auth/principal' import { db } from '@sim/db' import { memory, memorySecretProvenance } from '@sim/db/schema' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, like, sql } from 'drizzle-orm' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { assertBillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { @@ -42,11 +47,37 @@ interface WorkspaceInput { workspaceId: string } +export interface MemoryLegacyProvenanceScope { + userId: string + workspaceId: string +} + interface ReadProvenanceInput { includePersistedSecretProvenance?: boolean + resolveBillingAttribution?: (workspaceId: string) => Promise signal?: AbortSignal } +async function resolveMemoryLegacyProvenanceScope( + principal: Principal, + workspaceId: string, + resolveBillingAttribution?: ReadProvenanceInput['resolveBillingAttribution'] +): Promise { + const subject = resolvePrincipalSubject(principal) + if (subject?.kind === 'sim_user') return { userId: subject.userId, workspaceId } + + const billingAttribution = resolveBillingAttribution + ? assertBillingAttributionSnapshot(await resolveBillingAttribution(workspaceId)) + : undefined + if (billingAttribution && billingAttribution.workspaceId !== workspaceId) { + throw new Error('Memory billing attribution does not match its canonical workspace') + } + const { attributedUserId } = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: billingAttribution?.billedAccountUserId, + }) + return { userId: attributedUserId, workspaceId } +} + function memoryMessageError(data: unknown): string | null { const messages = Array.isArray(data) ? data : [data] for (const message of messages) { @@ -66,8 +97,7 @@ function memoryMessageError(data: unknown): string | null { async function loadReadProvenance( records: MemoryRecord[], - principal: Principal, - workspaceId: string, + scope: MemoryLegacyProvenanceScope, signal?: AbortSignal ): Promise { if (records.length === 0) return [] @@ -114,8 +144,8 @@ async function loadReadProvenance( surface: 'memory', cause: 'durable-provenance-unknown', affectedCount: unrecordedCount, - workspaceId, - actorUserId: requirePrincipalSubjectUserId(principal), + workspaceId: scope.workspaceId, + actorUserId: scope.userId, }) } @@ -126,10 +156,24 @@ async function readResultProvenance( records: MemoryRecord[], principal: Principal, workspaceId: string, - input: ReadProvenanceInput -): Promise { - if (!input.includePersistedSecretProvenance) return undefined - return loadReadProvenance(records, principal, workspaceId, input.signal) + input: ReadProvenanceInput, + existingScope?: MemoryLegacyProvenanceScope +): Promise<{ + readProvenance?: MemoryReadProvenance[] + provenanceScope?: MemoryLegacyProvenanceScope +}> { + if (!input.includePersistedSecretProvenance) return {} + const provenanceScope = + existingScope ?? + (await resolveMemoryLegacyProvenanceScope( + principal, + workspaceId, + input.resolveBillingAttribution + )) + return { + readProvenance: await loadReadProvenance(records, provenanceScope, input.signal), + provenanceScope, + } } export interface ListMemoriesInput extends WorkspaceInput, ReadProvenanceInput { @@ -156,9 +200,10 @@ export const listMemoriesUseCase = defineAuthorizedWorkspaceUseCase({ .orderBy(memory.createdAt) .limit(input.limit) input.signal?.throwIfAborted() + const provenance = await readResultProvenance(records, principal, context.workspaceId, input) return { records, - readProvenance: await readResultProvenance(records, principal, context.workspaceId, input), + ...provenance, } }, }) @@ -187,9 +232,10 @@ export const readMemoryUseCase = defineAuthorizedWorkspaceUseCase({ .orderBy(memory.createdAt) .limit(1) input.signal?.throwIfAborted() + const provenance = await readResultProvenance(records, principal, context.workspaceId, input) return { record: records[0] ?? null, - readProvenance: await readResultProvenance(records, principal, context.workspaceId, input), + ...provenance, } }, }) @@ -198,6 +244,9 @@ export interface AppendMemoryInput extends WorkspaceInput, ReadProvenanceInput { key: string data: unknown writeProvenance?: DurableSecretProvenance + resolveWriteProvenance?: ( + scope: MemoryLegacyProvenanceScope + ) => DurableSecretProvenance | undefined } export const appendMemoryUseCase = defineAuthorizedWorkspaceUseCase({ @@ -212,6 +261,17 @@ export const appendMemoryUseCase = defineAuthorizedWorkspaceUseCase({ if (messageError) throw new OrchestrationError('validation', messageError) input.signal?.throwIfAborted() + const provenanceScope = input.resolveWriteProvenance + ? await resolveMemoryLegacyProvenanceScope( + principal, + context.workspaceId, + input.resolveBillingAttribution + ) + : undefined + const writeProvenance = + input.resolveWriteProvenance && provenanceScope + ? input.resolveWriteProvenance(provenanceScope) + : input.writeProvenance const initialData = Array.isArray(input.data) ? input.data : [input.data] const now = new Date() const id = `mem_${generateId().replace(/-/g, '')}` @@ -230,7 +290,7 @@ export const appendMemoryUseCase = defineAuthorizedWorkspaceUseCase({ .for('update') let previousProvenance: DurableSecretProvenance | undefined - if (existing && input.writeProvenance) { + if (existing && writeProvenance) { const [sidecar] = await tx .select() .from(memorySecretProvenance) @@ -252,7 +312,7 @@ export const appendMemoryUseCase = defineAuthorizedWorkspaceUseCase({ workspaceId: context.workspaceId, key: input.key, data: initialData, - secretProvenanceVersion: input.writeProvenance ? 1 : null, + secretProvenanceVersion: writeProvenance ? 1 : null, createdAt: now, updatedAt: now, }) @@ -260,7 +320,7 @@ export const appendMemoryUseCase = defineAuthorizedWorkspaceUseCase({ target: [memory.workspaceId, memory.key], set: { data: sql`${memory.data} || ${JSON.stringify(initialData)}::jsonb`, - secretProvenanceVersion: input.writeProvenance + secretProvenanceVersion: writeProvenance ? 1 : (existing?.secretProvenanceVersion ?? null), updatedAt: now, @@ -268,14 +328,14 @@ export const appendMemoryUseCase = defineAuthorizedWorkspaceUseCase({ }) .returning({ id: memory.id, data: memory.data }) - if (input.writeProvenance) { + if (writeProvenance) { await replaceMemorySecretProvenanceInTx( tx, written.id, written.data, previousProvenance - ? mergeDurableSecretProvenance(previousProvenance, input.writeProvenance) - : input.writeProvenance + ? mergeDurableSecretProvenance(previousProvenance, writeProvenance) + : writeProvenance ) } }) @@ -302,9 +362,16 @@ export const appendMemoryUseCase = defineAuthorizedWorkspaceUseCase({ const record = records[0] if (!record) throw new Error('Failed to retrieve memory after creation/update') input.signal?.throwIfAborted() + const provenance = await readResultProvenance( + records, + principal, + context.workspaceId, + input, + provenanceScope + ) return { record, - readProvenance: await readResultProvenance(records, principal, context.workspaceId, input), + ...provenance, } }, }) diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 425a110b104..39cfdc2a6aa 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -788,6 +788,11 @@ const PUBLIC_STORAGE_CONTEXTS = new Set([ 'workspace-logos', ]) +/** Whether a trusted storage context is world-readable. */ +export function isPublicStorageContext(context: StorageContext): boolean { + return PUBLIC_STORAGE_CONTEXTS.has(context) +} + /** * Resolve the storage context for a stored file from its trusted key prefix. * @@ -811,7 +816,7 @@ export function resolveTrustedFileContext(key: string, context?: string): Storag try { return inferContextFromKey(key) } catch (error) { - if (context && !PUBLIC_STORAGE_CONTEXTS.has(context as StorageContext)) { + if (context && !isPublicStorageContext(context as StorageContext)) { return context as StorageContext } throw error diff --git a/apps/sim/lib/workflows/custom-tools/available-lookup.test.ts b/apps/sim/lib/workflows/custom-tools/available-lookup.test.ts index aa0784de1b5..5fb6a6d59b5 100644 --- a/apps/sim/lib/workflows/custom-tools/available-lookup.test.ts +++ b/apps/sim/lib/workflows/custom-tools/available-lookup.test.ts @@ -69,4 +69,18 @@ describe('getAvailableCustomTool', () => { expect(mocks.select).toHaveBeenCalledTimes(2) }) + + it('does not expose a personal fallback to an actorless workflow execution', async () => { + mocks.select.mockReturnValueOnce(selection([])) + + await expect( + getAvailableCustomTool({ + identifier: personalTool.id, + workspaceId: workspaceTool.workspaceId, + lookup: 'id', + }) + ).resolves.toBeNull() + + expect(mocks.select).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index eb793d18a1a..565e49735c1 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -279,7 +279,7 @@ export type AvailableCustomToolLookup = 'id' | 'id_or_title' export async function getAvailableCustomTool(params: { identifier: string - userId: string + userId?: string workspaceId: string lookup: AvailableCustomToolLookup }) { @@ -294,6 +294,7 @@ export async function getAvailableCustomTool(params: { .where(and(eq(customTools.workspaceId, params.workspaceId), identifierCondition)) .limit(1) if (workspaceTool[0]) return workspaceTool[0] + if (!params.userId) return null const legacyTool = await db .select() diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 20b2d2eee03..666493bf95a 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -1,3 +1,4 @@ +import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' import { environmentUtilsMockFns, loggerMock, @@ -435,6 +436,107 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { } ) + it.each([ + { + name: 'schedule', + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + triggerType: 'schedule', + isPublicApiAccess: false, + }, + { + name: 'webhook with a verified external subject', + principal: { + kind: 'system' as const, + serviceId: 'webhook' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + webhookId: 'webhook-1', + provider: 'slack', + subject: { + kind: 'external_user' as const, + provider: 'slack', + tenantId: 'team-1', + subjectId: 'slack-user-1', + }, + }, + triggerType: 'webhook', + isPublicApiAccess: false, + }, + { + name: 'workspace API key', + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + triggerType: 'api', + isPublicApiAccess: false, + }, + { + name: 'anonymous public API', + principal: { + kind: 'system' as const, + serviceId: 'public_api' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + triggerType: 'api', + isPublicApiAccess: true, + }, + ] satisfies Array<{ + name: string + principal: WorkflowExecutionPrincipal + triggerType: 'api' | 'schedule' | 'webhook' + isPublicApiAccess: boolean + }>)( + 'preserves the exact $name principal and deployed workflow authority in executor delegation', + async ({ principal, triggerType, isPublicApiAccess }) => { + executorExecuteMock.mockResolvedValue({ + success: true, + status: 'completed', + output: { done: true }, + logs: [], + metadata: { duration: 123, startTime: 'start', endTime: 'end' }, + }) + + const snapshot = createSnapshot() + await executeWorkflowCore({ + snapshot: { + ...snapshot, + metadata: { + ...snapshot.metadata, + userId: 'billing-actor', + principal, + triggerType, + useDraftState: false, + isPublicApiAccess, + }, + } as any, + callbacks: {}, + loggingSession: loggingSession as any, + }) + + const contextExtensions = executorConstructorMock.mock.calls[0]?.[0]?.contextExtensions + expect(contextExtensions.principal).toBe(principal) + expect(contextExtensions.executorDelegationOrigin.principal).toBe(principal) + expect(contextExtensions.executorDelegationOrigin).toEqual({ + workflowId: 'workflow-1', + executionId: 'execution-1', + principal, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'dep-1', + }, + }) + } + ) + it('starts logging with the workflow state that will be executed', async () => { const executedWorkflowState = { blocks: { diff --git a/apps/sim/lib/workspace-files/application/delegated-principal.test.ts b/apps/sim/lib/workspace-files/application/delegated-principal.test.ts new file mode 100644 index 00000000000..b37bcfd7827 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/delegated-principal.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' +import { rebindWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' + +describe('rebindWorkspaceFileDelegatedPrincipal', () => { + it('preserves actorless workflow identity and deployment authority', () => { + const expiresAt = new Date(Date.now() + 60_000) + const delegationContext = { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + }, + } + + const rebound = rebindWorkspaceFileDelegatedPrincipal({ + principal: { + kind: 'delegated', + serviceId: 'executor', + workspaceId: 'workspace-1', + delegationId: 'function-1', + audience: 'sim:function-executions', + issuedAt: new Date(Date.now() - 1_000), + expiresAt, + delegationContext, + }, + workspaceId: 'workspace-1', + delegationId: 'file-1', + executionId: 'execution-1', + }) + + expect(rebound).toMatchObject({ + serviceId: 'executor', + workspaceId: 'workspace-1', + delegationId: 'file-1', + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + resourceScope: { executionId: 'execution-1' }, + delegationContext, + }) + expect(rebound.expiresAt).toEqual(expiresAt) + expect(rebound).not.toHaveProperty('subjectUserId') + }) + + it('rejects a cross-workspace rebind', () => { + expect(() => + rebindWorkspaceFileDelegatedPrincipal({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-1', + audience: 'sim:function-executions', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }, + workspaceId: 'workspace-2', + delegationId: 'file-1', + }) + ).toThrow('Workspace file delegation does not match its authorized workspace') + }) +}) diff --git a/apps/sim/lib/workspace-files/application/delegated-principal.ts b/apps/sim/lib/workspace-files/application/delegated-principal.ts index 7cb5040c86c..664fde027d0 100644 --- a/apps/sim/lib/workspace-files/application/delegated-principal.ts +++ b/apps/sim/lib/workspace-files/application/delegated-principal.ts @@ -37,3 +37,40 @@ export function createWorkspaceFileDelegatedPrincipal( }, } } + +export interface RebindWorkspaceFileDelegationInput { + principal: DelegatedPrincipal + workspaceId: string + delegationId: string + fileId?: string + chatId?: string + executionId?: string +} + +/** Rebinds an already-authorized service principal without changing its workflow actor. */ +export function rebindWorkspaceFileDelegatedPrincipal( + input: RebindWorkspaceFileDelegationInput +): DelegatedPrincipal { + if (input.principal.workspaceId !== input.workspaceId || !input.delegationId) { + throw new Error('Workspace file delegation does not match its authorized workspace') + } + const issuedAt = new Date() + return { + ...input.principal, + workspaceId: input.workspaceId, + delegationId: input.delegationId, + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + issuedAt, + expiresAt: new Date( + Math.min( + input.principal.expiresAt.getTime(), + issuedAt.getTime() + WORKSPACE_FILE_DELEGATION_TTL_MS + ) + ), + resourceScope: { + ...(input.fileId ? { fileId: input.fileId } : {}), + ...(input.chatId ? { chatId: input.chatId } : {}), + ...(input.executionId ? { executionId: input.executionId } : {}), + }, + } +} diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts index ca1ca6b784d..d635047deb8 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts @@ -27,7 +27,10 @@ vi.mock('@sim/platform-authz/workspace', () => ({ })) import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' -import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key' +import { + readWorkspaceFileContentByKey, + readWorkspaceFileRecordByKey, +} from '@/lib/workspace-files/application/read-workspace-file-content-by-key' const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } const context = { @@ -92,4 +95,74 @@ describe('readWorkspaceFileContentByKey', () => { ).rejects.toMatchObject({ code: 'not_found' }) expect(mocks.fetchContent).not.toHaveBeenCalled() }) + + it('authorizes an exact-key record read for a workspace API key without a human fallback', async () => { + await expect( + readWorkspaceFileRecordByKey.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: file.workspaceId, + keyId: 'key-1', + }, + input: { key: file.key, assertedWorkspaceId: file.workspaceId }, + }) + ).resolves.toEqual({ file }) + + expect(mocks.getMetadata).toHaveBeenCalledWith(file.key, 'workspace') + expect(mocks.loadContext).toHaveBeenCalledWith(file.id) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getFile).toHaveBeenCalledWith(file.workspaceId, file.id, { + throwOnError: true, + }) + expect(mocks.fetchContent).not.toHaveBeenCalled() + }) + + it('authorizes an actorless deployment executor by its preserved workflow authority', async () => { + await expect( + readWorkspaceFileRecordByKey.execute({ + principal: { + kind: 'delegated', + serviceId: 'executor', + workspaceId: file.workspaceId, + delegationId: 'execution-file-read:request-1', + audience: 'sim:workspace-files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: file.workspaceId, + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + }, + }, + input: { key: file.key, assertedWorkspaceId: file.workspaceId }, + }) + ).resolves.toEqual({ file }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.fetchContent).not.toHaveBeenCalled() + }) + + it('conceals an exact key asserted under a different workspace before authorization', async () => { + await expect( + readWorkspaceFileRecordByKey.execute({ + principal, + input: { key: file.key, assertedWorkspaceId: 'workspace-other' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.loadContext).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getFile).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts index 4a669fd940f..066c49022a2 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts @@ -12,7 +12,7 @@ import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' -export interface ReadWorkspaceFileContentByKeyInput { +export interface ReadWorkspaceFileByKeyInput { key: string assertedWorkspaceId?: string } @@ -22,40 +22,65 @@ export interface ReadWorkspaceFileContentByKeyResult { content: Buffer } +export interface ReadWorkspaceFileRecordByKeyResult { + file: WorkspaceFileRecord +} + +async function loadCurrentWorkspaceFileByKey( + input: ReadWorkspaceFileByKeyInput, + context: ActiveWorkspaceFileContext +): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file || file.key !== input.key) throw new OrchestrationError('not_found', 'File not found') + return file +} + async function executeReadWorkspaceFileContentByKey({ input, context, }: AuthorizedWorkspaceUseCaseContext< typeof fileOperations.readContent, - ReadWorkspaceFileContentByKeyInput, + ReadWorkspaceFileByKeyInput, ActiveWorkspaceFileContext >): Promise { - const file = await getWorkspaceFile(context.workspaceId, context.fileId, { - throwOnError: true, - }) - if (!file || file.key !== input.key) throw new OrchestrationError('not_found', 'File not found') + const file = await loadCurrentWorkspaceFileByKey(input, context) return { file, content: await fetchWorkspaceFileBuffer(file, { maxBytes: MAX_BUFFERED_TRANSFER_BYTES }), } } -export const readWorkspaceFileContentByKey = defineAuthorizedWorkspaceFileUseCase({ +async function resolveWorkspaceFileByKeyContext({ + input, +}: { + input: ReadWorkspaceFileByKeyInput +}): Promise { + const metadata = await getFileMetadataByKey(input.key, 'workspace') + if ( + !metadata?.workspaceId || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== metadata.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + const canonical = await loadActiveWorkspaceFileContext(metadata.id) + if (!canonical || canonical.workspaceId !== metadata.workspaceId) { + throw new OrchestrationError('not_found', 'File not found') + } + return canonical +} + +export const readWorkspaceFileRecordByKey = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.readContent, - async resolveContext({ input }) { - const metadata = await getFileMetadataByKey(input.key, 'workspace') - if ( - !metadata?.workspaceId || - (input.assertedWorkspaceId !== undefined && - input.assertedWorkspaceId !== metadata.workspaceId) - ) { - throw new OrchestrationError('not_found', 'File not found') - } - const canonical = await loadActiveWorkspaceFileContext(metadata.id) - if (!canonical || canonical.workspaceId !== metadata.workspaceId) { - throw new OrchestrationError('not_found', 'File not found') - } - return canonical + resolveContext: resolveWorkspaceFileByKeyContext, + async execute({ input, context }): Promise { + return { file: await loadCurrentWorkspaceFileByKey(input, context) } }, +}) + +export const readWorkspaceFileContentByKey = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + resolveContext: resolveWorkspaceFileByKeyContext, execute: executeReadWorkspaceFileContentByKey, }) diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index cb0bf06b672..c7dc6067af7 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1967,6 +1967,13 @@ describe('workflow executor metadata delegation', () => { workspaceId: 'workspace-1', executionId: 'execution-1', userId: 'user-1', + executorDelegationOrigin: { + subjectUserId: 'user-1', + workflowId: 'parent-workflow', + executionId: 'execution-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, + }, }, readWorkflowMetadata: workflowMetadataMocks.readWorkflowMetadataForTool, } @@ -1979,6 +1986,13 @@ describe('workflow executor metadata delegation', () => { workflowId: 'parent-workflow', workspaceId: 'workspace-1', executionId: 'execution-1', + executorDelegationOrigin: { + subjectUserId: 'user-1', + workflowId: 'parent-workflow', + executionId: 'execution-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, + }, } ) expect(result).toMatchObject({ @@ -2003,6 +2017,13 @@ describe('workflow executor metadata delegation', () => { workspaceId: 'workspace-1', executionId: 'execution-1', userId: 'user-1', + executorDelegationOrigin: { + subjectUserId: 'user-1', + workflowId: 'current-workflow', + executionId: 'execution-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'current-workflow', mode: 'draft' }, + }, }, readWorkflowMetadata: workflowMetadataMocks.readWorkflowMetadataForTool, } @@ -2015,6 +2036,13 @@ describe('workflow executor metadata delegation', () => { workflowId: 'current-workflow', workspaceId: 'workspace-1', executionId: 'execution-1', + executorDelegationOrigin: { + subjectUserId: 'user-1', + workflowId: 'current-workflow', + executionId: 'execution-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'current-workflow', mode: 'draft' }, + }, } ) }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index ef932db6759..017f7479a5d 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -89,8 +89,8 @@ async function fetchWorkflowMetadata( ) => Promise<{ name: string; description: string | null }> ): Promise<{ name: string; description: string | null } | null> { try { - if (!executionContext?.userId || !readWorkflowMetadata) { - throw new Error('Workflow metadata enrichment requires a trusted execution subject') + if (!executionContext?.executorDelegationOrigin || !readWorkflowMetadata) { + throw new Error('Workflow metadata enrichment requires trusted execution authority') } return await readWorkflowMetadata(workflowId, executionContext) } catch (error) { diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index b3e14ac6187..a1a49055b8f 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -97,6 +97,7 @@ export const requestTool: ToolConfig = { }, request: { + allowSameOrigin: true, url: (params: RequestParams) => { return processUrl(params.url, params.pathParams, params.params) }, diff --git a/apps/sim/tools/http/webhook_request.ts b/apps/sim/tools/http/webhook_request.ts index 39e4e8e21b5..89a89bcd150 100644 --- a/apps/sim/tools/http/webhook_request.ts +++ b/apps/sim/tools/http/webhook_request.ts @@ -42,6 +42,7 @@ export const webhookRequestTool: ToolConfig params.url, method: () => 'POST', diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index ca07250ac39..444681b6310 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -60,6 +60,7 @@ const { mockResolveWorkspaceFileReference, mockAssertPermissionsAllowed, mockExecuteFunction, + mockCreateExecutorPrincipalFromExecutionContext, mockGetInternalToolOperationHandler, mockExecuteInternalToolOperation, } = vi.hoisted(() => ({ @@ -80,6 +81,7 @@ const { mockResolveWorkspaceFileReference: vi.fn(), mockAssertPermissionsAllowed: vi.fn(), mockExecuteFunction: vi.fn(), + mockCreateExecutorPrincipalFromExecutionContext: vi.fn(), mockGetInternalToolOperationHandler: vi.fn(), mockExecuteInternalToolOperation: vi.fn(), })) @@ -131,6 +133,10 @@ vi.mock('@/lib/function-execution/application/execute-function', () => ({ executeFunction: { execute: mockExecuteFunction }, })) +vi.mock('@/lib/internal/principals/executor', () => ({ + createExecutorPrincipalFromExecutionContext: mockCreateExecutorPrincipalFromExecutionContext, +})) + vi.mock('@/lib/internal/tool-operations/registry.server', () => ({ getInternalToolOperationHandler: mockGetInternalToolOperationHandler, })) @@ -187,6 +193,7 @@ const mockRegistryTools: Record = { retryNonIdempotent: { type: 'boolean' }, }, request: { + allowSameOrigin: true, url: (p: any) => p.url || '/api/test', method: (p: any) => p.method || 'GET', headers: (p: any) => p.headers || { 'Content-Type': 'application/json' }, @@ -480,6 +487,30 @@ beforeEach(() => { } ) ) + mockCreateExecutorPrincipalFromExecutionContext.mockImplementation( + async ({ context, audience, resourceScope }) => { + const origin = context.executorDelegationOrigin + if (!origin) throw new Error('Executor delegation origin is required') + return { + kind: 'delegated' as const, + serviceId: 'executor', + ...(origin.subjectUserId ? { subjectUserId: origin.subjectUserId } : {}), + workspaceId: context.workspaceId, + delegationId: 'test-executor-delegation', + audience, + issuedAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date('2026-01-01T00:05:00.000Z'), + ...(resourceScope ? { resourceScope } : {}), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: origin.workflowId, + ...(origin.executionId ? { executionId: origin.executionId } : {}), + ...(origin.principal ? { principal: origin.principal } : {}), + ...(origin.currentWorkflow ? { currentWorkflow: origin.currentWorkflow } : {}), + }, + } + } + ) // Suites below call vi.resetAllMocks(), which wipes the shared env/urls mock // implementations — restore their defaults and re-pin the base URL each test. resetEnvMock() @@ -546,13 +577,31 @@ function createToolExecutionContext(overrides?: Partial): Exec metadata: overrides?.metadata, environmentVariables: overrides?.environmentVariables, }) + const principal = + overrides?.principal ?? + (overrides?.userId + ? { kind: 'session' as const, userId: overrides.userId, sessionId: 'test-session' } + : undefined) + const executorDelegationOrigin = + overrides?.executorDelegationOrigin ?? + (principal + ? { + subjectUserId: overrides?.userId, + workflowId: overrides?.workflowId ?? ctx.workflowId, + executionId: overrides?.executionId ?? ctx.executionId, + principal, + } + : undefined) return { ...ctx, workspaceId: 'workspace-456', + principal, + executorDelegationOrigin, ...overrides, metadata: { ...ctx.metadata, ...overrides?.metadata, + principal: overrides?.metadata?.principal ?? principal, billingAttribution: overrides?.metadata?.billingAttribution ?? TEST_BILLING_ATTRIBUTION, }, } as ExecutionContext @@ -815,7 +864,6 @@ describe('executeTool Function', () => { workflowId: 'workflow-1', executionId: 'execution-1', workspaceId: 'workspace-456', - userId: 'user-1', largeValueExecutionIds: ['execution-1'], largeValueKeys: ['lv_ABCDEFGHIJKL'], fileKeys: ['file-1'], @@ -823,6 +871,7 @@ describe('executeTool Function', () => { }, }, }) + expect(mockExecuteFunction.mock.calls[0]?.[0].input.body.userId).toBeUndefined() expect(mockGenerateInternalToken).not.toHaveBeenCalled() expect(fetchSpy).not.toHaveBeenCalled() }) @@ -900,7 +949,6 @@ describe('executeTool Function', () => { __blockRef_0: { field: 'resolved-output' }, __blockRef_1: largeValueRef, }, - userId: 'user-1', workspaceId: 'workspace-456', workflowId: 'workflow-1', executionId: 'execution-1', @@ -912,6 +960,7 @@ describe('executeTool Function', () => { }), }) ) + expect(mockExecuteFunction.mock.calls[0]?.[0].input.body.userId).toBeUndefined() expect(mockGenerateInternalToken).not.toHaveBeenCalled() expect(fetchSpy).not.toHaveBeenCalled() }) @@ -994,7 +1043,6 @@ describe('executeTool Function', () => { workflowId: 'workflow-1', executionId: 'execution-1', workspaceId: 'workspace-456', - userId: 'user-1', largeValueExecutionIds: ['execution-1'], largeValueKeys: ['lv_ABCDEFGHIJKL'], fileKeys: ['file-1'], @@ -1003,6 +1051,7 @@ describe('executeTool Function', () => { }, }, }) + expect(mockExecuteFunction.mock.calls[0]?.[0].input.body.userId).toBeUndefined() expect(mockGenerateInternalToken).not.toHaveBeenCalled() expect(fetchSpy).not.toHaveBeenCalled() }) @@ -2614,6 +2663,148 @@ describe('Internal Route Trust', () => { expect(global.fetch).not.toHaveBeenCalled() }) + it('allows the generic HTTP tool to target this Sim instance through a loopback alias', async () => { + const result = await executeTool('http_request', { + url: 'http://127.0.0.2:3000/api/v1/workflows/test', + method: 'GET', + }) + + expect(result.success).toBe(true) + expect(mockValidateUrlWithDNS).toHaveBeenCalledWith( + 'http://127.0.0.2:3000/api/v1/workflows/test', + 'toolUrl' + ) + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + 'http://127.0.0.2:3000/api/v1/workflows/test', + '93.184.216.34', + expect.objectContaining({ assertRedirectTarget: undefined }) + ) + }) + + it('rejects an integration request that resolves back to this Sim instance', async () => { + const mockTool = { + id: 'test_same_origin_integration', + name: 'Same Origin Integration', + description: 'Regression fixture', + version: '1.0.0', + params: {}, + request: { + url: () => 'http://localhost:3000/api/tools/test', + method: 'GET' as const, + headers: () => ({}), + }, + } + ;(tools as Record).test_same_origin_integration = mockTool + + try { + const result = await executeTool('test_same_origin_integration', {}) + + expect(result.success).toBe(false) + expect(result.error).toContain( + 'External integration tools cannot target this Sim instance; use an internal operation' + ) + expect(mockValidateUrlWithDNS).not.toHaveBeenCalled() + expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(tools, 'test_same_origin_integration') + } + }) + + it.each(['127.0.0.1', '127.0.0.2', '[::1]'])( + 'rejects the loopback alias %s for a self-hosted Sim listener', + async (hostname) => { + const mockTool = { + id: 'test_loopback_alias_integration', + name: 'Loopback Alias Integration', + description: 'Regression fixture', + version: '1.0.0', + params: {}, + request: { + url: () => `http://${hostname}:3000/api/tools/test`, + method: 'GET' as const, + headers: () => ({}), + }, + } + ;(tools as Record).test_loopback_alias_integration = mockTool + + try { + const result = await executeTool('test_loopback_alias_integration', {}) + + expect(result.success).toBe(false) + expect(result.error).toContain( + 'External integration tools cannot target this Sim instance; use an internal operation' + ) + expect(mockValidateUrlWithDNS).not.toHaveBeenCalled() + expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(tools, 'test_loopback_alias_integration') + } + } + ) + + it('allows a self-hosted provider on a different loopback port', async () => { + const mockTool = { + id: 'test_local_provider', + name: 'Local Provider', + description: 'Regression fixture', + version: '1.0.0', + params: {}, + request: { + url: () => 'http://127.0.0.1:4000/api/provider', + method: 'GET' as const, + headers: () => ({}), + }, + } + ;(tools as Record).test_local_provider = mockTool + + try { + const result = await executeTool('test_local_provider', {}) + + expect(result.success).toBe(true) + expect(mockValidateUrlWithDNS).toHaveBeenCalledWith( + 'http://127.0.0.1:4000/api/provider', + 'toolUrl' + ) + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalled() + } finally { + Reflect.deleteProperty(tools, 'test_local_provider') + } + }) + + it('rejects an integration redirect that resolves back to this Sim instance', async () => { + const mockTool = { + id: 'test_same_origin_redirect', + name: 'Same Origin Redirect Integration', + description: 'Regression fixture', + version: '1.0.0', + params: {}, + request: { + url: () => 'https://api.example.com/download', + method: 'GET' as const, + headers: () => ({}), + }, + } + ;(tools as Record).test_same_origin_redirect = mockTool + + try { + const result = await executeTool('test_same_origin_redirect', {}) + + expect(result.success).toBe(true) + const secureFetchOptions = mockSecureFetchWithPinnedIP.mock.calls.at(-1)?.[2] + expect(secureFetchOptions?.assertRedirectTarget).toBeTypeOf('function') + expect(() => + secureFetchOptions?.assertRedirectTarget?.('http://127.0.0.2:3000/api/tools/test') + ).toThrow( + 'External integration tools cannot target this Sim instance; use an internal operation' + ) + expect(() => + secureFetchOptions?.assertRedirectTarget?.('https://provider.example.com/download') + ).not.toThrow() + } finally { + Reflect.deleteProperty(tools, 'test_same_origin_redirect') + } + }) + it('transports only active provenance selected for an internal model input', async () => { const registry = new ResolvedSecretTraceRegistry([ { diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index b221859cf98..641748fda7e 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isLoopbackIp, unwrapIpv6Brackets } from '@sim/security/ssrf' import { describeError, findCause, getErrorMessage, toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { isPlainRecord, isRecordLike } from '@sim/utils/object' @@ -985,6 +986,8 @@ const BODY_SIZE_LIMIT_ERROR_MESSAGE = const RESPONSE_SIZE_LIMIT_ERROR_MESSAGE = 'Tool response size limit exceeded (10MB). The response is too large to keep in workflow data. Reduce the response size or return a file reference instead.' +const SAME_ORIGIN_EXTERNAL_TOOL_ERROR_MESSAGE = + 'External integration tools cannot target this Sim instance; use an internal operation' /** * Validates request body size and throws a user-friendly error if exceeded @@ -2356,11 +2359,29 @@ function isErrorResponse( /** * Checks whether a fully resolved URL points back to this Sim instance. + * Loopback aliases are equivalent when protocol and port match because they + * address the same self-hosted listener even when their origin strings differ. * Used to propagate cycle-detection headers on API blocks that target * the platform's own workflow execution endpoints via absolute URL. */ function isSelfOriginUrl(url: string): boolean { - return isSameOrigin(url, getBaseUrl()) || isSameOrigin(url, getInternalApiBaseUrl()) + return [getBaseUrl(), getInternalApiBaseUrl()].some((baseUrl) => { + if (isSameOrigin(url, baseUrl)) return true + + try { + const target = new URL(url) + const base = new URL(baseUrl) + if (target.protocol !== base.protocol || target.port !== base.port) return false + + const targetHostname = unwrapIpv6Brackets(target.hostname.toLowerCase()) + const baseHostname = unwrapIpv6Brackets(base.hostname.toLowerCase()) + const targetIsLoopback = targetHostname === 'localhost' || isLoopbackIp(targetHostname) + const baseIsLoopback = baseHostname === 'localhost' || isLoopbackIp(baseHostname) + return targetIsLoopback && baseIsLoopback + } catch { + return false + } + }) } interface ResolvedRetryConfig { @@ -2627,8 +2648,13 @@ async function executeToolRequest( const requestParams = prepareToolRequest(tool, params, resolvedSecretTraceRegistry) const { headers } = requestParams const fullUrl = new URL(requestParams.url).toString() + const targetsThisSimInstance = isSelfOriginUrl(fullUrl) + + if (targetsThisSimInstance && tool.request.allowSameOrigin !== true) { + throw new Error(SAME_ORIGIN_EXTERNAL_TOOL_ERROR_MESSAGE) + } - if (isSelfOriginUrl(fullUrl)) { + if (targetsThisSimInstance) { const callChain = params._context?.callChain as string[] | undefined if (callChain && callChain.length > 0) { headers.set(SIM_VIA_HEADER, serializeCallChain(callChain)) @@ -2679,6 +2705,14 @@ async function executeToolRequest( proxyUrl: proxyOption, stripAuthOnRedirect: requestParams.stripAuthOnRedirect, redirectPolicy: requestParams.redirectPolicy, + assertRedirectTarget: + tool.request.allowSameOrigin === true + ? undefined + : (redirectUrl) => { + if (isSelfOriginUrl(redirectUrl)) { + throw new Error(SAME_ORIGIN_EXTERNAL_TOOL_ERROR_MESSAGE) + } + }, }) const responseHeaders = new Headers(secureResponse.headers.toRecord()) diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index e8bfc435eef..24bf5a26d1e 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutorDelegationOrigin } from '@/executor/types' import { mergeToolParameters } from '@/tools/merge-params' import * as toolMetadata from '@/tools/metadata' import { @@ -634,6 +635,13 @@ describe('Tool Parameters Utils', () => { describe('createLLMToolSchema - child workflow input enrichment', () => { const mockReadWorkflowInputFields = vi.fn() + const executorDelegationOrigin: ExecutorDelegationOrigin = { + subjectUserId: 'user-1', + workflowId: 'parent-workflow', + executionId: 'execution-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, + } beforeEach(() => { mockReadWorkflowInputFields.mockReset() @@ -652,6 +660,7 @@ describe('Tool Parameters Utils', () => { workflowId: 'parent-workflow', executionId: 'execution-1', workspaceId: 'workspace-1', + executorDelegationOrigin, }, mockReadWorkflowInputFields ) @@ -661,6 +670,7 @@ describe('Tool Parameters Utils', () => { workflowId: 'parent-workflow', executionId: 'execution-1', workspaceId: 'workspace-1', + executorDelegationOrigin, }) expect(schema.properties.inputMapping.properties).toEqual({ email: { type: 'string', description: 'Recipient address' }, @@ -673,7 +683,12 @@ describe('Tool Parameters Utils', () => { await createLLMToolSchema( mockWorkflowExecutorConfig, { workflowId: 'parent-workflow' }, - { userId: 'user-1', workflowId: 'parent-workflow', executionId: 'execution-1' }, + { + userId: 'user-1', + workflowId: 'parent-workflow', + executionId: 'execution-1', + executorDelegationOrigin, + }, mockReadWorkflowInputFields ) @@ -681,10 +696,11 @@ describe('Tool Parameters Utils', () => { userId: 'user-1', workflowId: 'parent-workflow', executionId: 'execution-1', + executorDelegationOrigin, }) }) - it('leaves inputMapping untyped and issues no request without an execution subject', async () => { + it('leaves inputMapping untyped and issues no request without trusted execution authority', async () => { const { schema } = await createLLMToolSchema( mockWorkflowExecutorConfig, { workflowId: 'child-workflow' }, @@ -702,10 +718,19 @@ describe('Tool Parameters Utils', () => { const { schema } = await createLLMToolSchema( mockWorkflowExecutorConfig, { workflowId: 'child-workflow' }, - { userId: 'user-1', workflowId: 'parent-workflow' }, + { + userId: 'user-1', + workflowId: 'parent-workflow', + executorDelegationOrigin, + }, mockReadWorkflowInputFields ) + expect(mockReadWorkflowInputFields).toHaveBeenCalledWith('child-workflow', { + userId: 'user-1', + workflowId: 'parent-workflow', + executorDelegationOrigin, + }) expect(schema.properties.inputMapping.properties).toBeUndefined() }) }) diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 12de914a3cf..13646cd1129 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -801,8 +801,8 @@ async function fetchWorkflowInputFields( readWorkflowInputFields?: WorkflowInputFieldsReader ): Promise> { try { - if (!context.userId || !readWorkflowInputFields) { - throw new Error('Workflow input enrichment requires a trusted execution subject') + if (!context.executorDelegationOrigin || !readWorkflowInputFields) { + throw new Error('Workflow input enrichment requires trusted execution authority') } return await readWorkflowInputFields(workflowId, context) } catch (error) { diff --git a/apps/sim/tools/schema-enrichers.test.ts b/apps/sim/tools/schema-enrichers.test.ts index ef45b294965..2cdbb82423e 100644 --- a/apps/sim/tools/schema-enrichers.test.ts +++ b/apps/sim/tools/schema-enrichers.test.ts @@ -27,6 +27,12 @@ const ORIGINAL_SCHEMA = { required: [], } +const EXECUTOR_ORIGIN = { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', +} + describe('enrichTableToolSchema', () => { beforeEach(() => { vi.clearAllMocks() @@ -50,14 +56,19 @@ describe('enrichTableToolSchema', () => { userId: 'user-1', workflowId: 'workflow-1', executionId: 'execution-1', + executorDelegationOrigin: EXECUTOR_ORIGIN, } ) expect(mockReadTableSchemaAsExecutor).toHaveBeenCalledWith({ tableId: 'table-1', - userId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', + context: { + workspaceId: 'workspace-1', + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + executorDelegationOrigin: EXECUTOR_ORIGIN, + }, }) expect(result.description).toContain('Table "Customers" columns:') expect(result.parameters.required).toContain('filter') @@ -74,6 +85,7 @@ describe('enrichTableToolSchema', () => { workspaceId: 'workspace-1', userId: 'user-1', workflowId: 'workflow-1', + executorDelegationOrigin: EXECUTOR_ORIGIN, }) ).rejects.toThrow('Table not found') }) @@ -81,7 +93,7 @@ describe('enrichTableToolSchema', () => { it('fails when trusted execution identity is missing', async () => { await expect( enrichTableToolSchema('table-1', 'table_query_rows', ORIGINAL_SCHEMA, 'Query rows', {}) - ).rejects.toThrow('User ID is required to enrich table tool schema for table-1') + ).rejects.toThrow('Workflow ID is required to enrich table tool schema for table-1') }) }) @@ -99,14 +111,19 @@ describe('enrichKBTagsSchema', () => { workspaceId: 'workspace-1', workflowId: 'workflow-1', executionId: 'execution-1', + executorDelegationOrigin: EXECUTOR_ORIGIN, }) expect(mockListKnowledgeTagsAsExecutor).toHaveBeenCalledWith({ knowledgeBaseId: 'kb-1', - userId: 'user-1', workspaceId: 'workspace-1', - workflowId: 'workflow-1', - executionId: 'execution-1', + context: { + workspaceId: 'workspace-1', + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + executorDelegationOrigin: EXECUTOR_ORIGIN, + }, }) expect(result?.properties).toEqual({ Client: { type: 'string', description: 'text tag' } }) }) @@ -118,23 +135,35 @@ describe('enrichKBTagsSchema', () => { userId: 'user-1', workspaceId: 'workspace-1', workflowId: 'workflow-1', + executorDelegationOrigin: EXECUTOR_ORIGIN, }) expect(mockListKnowledgeTagsAsExecutor).toHaveBeenCalledWith({ knowledgeBaseId: 'kb-1', - userId: 'user-1', workspaceId: 'workspace-1', - workflowId: 'workflow-1', + context: { + workspaceId: 'workspace-1', + userId: 'user-1', + workflowId: 'workflow-1', + executorDelegationOrigin: EXECUTOR_ORIGIN, + }, }) }) it.each([ - ['no acting user', { workspaceId: 'workspace-1', workflowId: 'workflow-1' }], + ['no execution authority', { workspaceId: 'workspace-1', workflowId: 'workflow-1' }], [ 'no acting workflow to bind the delegation on', - { workspaceId: 'workspace-1', userId: 'user-1' }, + { workspaceId: 'workspace-1', userId: 'user-1', executorDelegationOrigin: EXECUTOR_ORIGIN }, + ], + [ + 'no acting workspace', + { + userId: 'user-1', + workflowId: 'workflow-1', + executorDelegationOrigin: EXECUTOR_ORIGIN, + }, ], - ['no acting workspace', { userId: 'user-1', workflowId: 'workflow-1' }], ])('skips enrichment with %s rather than issuing an unauthorized read', async (_, context) => { await expect(enrichKBTagsSchema('kb-1', context)).resolves.toBeNull() expect(mockListKnowledgeTagsAsExecutor).not.toHaveBeenCalled() diff --git a/apps/sim/tools/schema-enrichers.ts b/apps/sim/tools/schema-enrichers.ts index 5ebafc63741..a3cf05bb6e8 100644 --- a/apps/sim/tools/schema-enrichers.ts +++ b/apps/sim/tools/schema-enrichers.ts @@ -10,19 +10,23 @@ async function fetchTableSchema( tableId: string, context: WorkflowToolExecutionContext ): Promise { - if (!context.userId) { - throw new Error(`User ID is required to enrich table tool schema for ${tableId}`) - } if (!context.workflowId) { throw new Error(`Workflow ID is required to enrich table tool schema for ${tableId}`) } + if (!context.executorDelegationOrigin) { + throw new Error(`Execution authority is required to enrich table tool schema for ${tableId}`) + } const { readTableSchemaAsExecutor } = await import('@/lib/internal/table/read-schema') return readTableSchemaAsExecutor({ tableId, - userId: context.userId, - workflowId: context.workflowId, - ...(context.executionId ? { executionId: context.executionId } : {}), + context: { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + executionId: context.executionId, + userId: context.userId, + executorDelegationOrigin: context.executorDelegationOrigin, + }, }) } @@ -90,8 +94,10 @@ async function fetchTagDefinitions( knowledgeBaseId: string, context: WorkflowToolExecutionContext ): Promise { - if (!context.userId) { - logger.warn(`Skipping tag definition enrichment for KB ${knowledgeBaseId}: no acting user`) + if (!context.executorDelegationOrigin) { + logger.warn( + `Skipping tag definition enrichment for KB ${knowledgeBaseId}: no execution authority` + ) return [] } if (!context.workflowId) { @@ -107,10 +113,14 @@ async function fetchTagDefinitions( const { listKnowledgeTagsAsExecutor } = await import('@/lib/internal/knowledge/list-tags') const tagDefinitions = await listKnowledgeTagsAsExecutor({ knowledgeBaseId, - userId: context.userId, workspaceId: context.workspaceId, - workflowId: context.workflowId, - ...(context.executionId ? { executionId: context.executionId } : {}), + context: { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + executionId: context.executionId, + userId: context.userId, + executorDelegationOrigin: context.executorDelegationOrigin, + }, }) logger.info(`Found ${tagDefinitions.length} tag definitions for KB ${knowledgeBaseId}`) return tagDefinitions diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 62768db55ff..151c4baedff 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -3,6 +3,7 @@ import type { HostedKeyRateLimitConfig } from '@/lib/core/rate-limiter' import type { HttpRedirectPolicy } from '@/lib/core/security/http-redirect-policy' import type { PrivateSecretProvenanceSelection } from '@/lib/execution/model-input-provenance' import type { OAuthService } from '@/lib/oauth' +import type { ExecutorDelegationOrigin } from '@/executor/types' import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' export type BYOKProviderId = @@ -55,6 +56,7 @@ export type WorkflowToolExecutionContext = { workflowId?: string executionId?: string userId?: string + executorDelegationOrigin?: ExecutorDelegationOrigin } export type OutputType = @@ -182,6 +184,11 @@ export interface ToolConfig

{ method: HttpMethod | ((params: P) => HttpMethod) headers: (params: P) => Record body?: (params: P) => Record | string | FormData | undefined + /** + * Allows the resolved request URL to target this Sim instance. Reserved for generic, + * user-directed HTTP capabilities; integration tools must use an in-process operation. + */ + allowSameOrigin?: true /** Defines the exact request fields that may become model-visible. */ modelInput?: | { diff --git a/scripts/check-tool-request-boundary.test.ts b/scripts/check-tool-request-boundary.test.ts index 1c687efb059..36a2000e862 100644 --- a/scripts/check-tool-request-boundary.test.ts +++ b/scripts/check-tool-request-boundary.test.ts @@ -1,13 +1,12 @@ import { describe, expect, it } from 'vitest' -import { auditToolRequestTrust } from './check-tool-request-boundary' +import { auditToolSelfHops } from './check-tool-request-boundary' -const PARAM_TEMPLATE_EXPRESSION = '$' + '{params.id}' -const INPUT_TEMPLATE_EXPRESSION = '$' + '{input.id}' -const DESTRUCTURED_TEMPLATE_EXPRESSION = '$' + '{id}' -const ENCODED_TEMPLATE_EXPRESSION = '$' + '{encodeURIComponent(params.id)}' +const ENCODED_ID_TEMPLATE = '$' + '{encodeURIComponent(params.id)}' +const GET_BASE_URL_TEMPLATE = '$' + '{getBaseUrl()}' +const PARAMS_HOST_TEMPLATE = '$' + '{params.host}' function auditRequest(request: string) { - return auditToolRequestTrust(` + return auditToolSelfHops(` const tool = { id: 'test_tool', request: { ${request} }, @@ -15,206 +14,1038 @@ function auditRequest(request: string) { `) } -describe('tool request trust audit', () => { - it('allows a literal internal URL because the definition owns the full path', () => { - const audit = auditRequest("url: '/api/tools/test', method: 'GET', headers: () => ({})") +describe('tool self-hop audit', () => { + it('allows an absolute external provider URL', () => { + const audit = auditRequest( + "url: 'https://api.example.com/v1/items', method: 'GET', headers: () => ({})" + ) + + expect(audit).toEqual({ + violations: [], + detectedSelfHops: 0, + legacyInternalPolicies: 0, + }) + }) + + it('rejects a literal same-origin API route', () => { + const audit = auditRequest("url: '/api/tools/test', method: 'POST'") + + expect(audit.detectedSelfHops).toBe(1) + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'same-origin-tool-request', + }), + ]) + }) + + it('rejects a dynamic same-origin API route', () => { + const audit = auditRequest(`url: (params) => \`/api/tools/${ENCODED_ID_TEMPLATE}\``) + + expect(audit.detectedSelfHops).toBe(1) + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a concatenated same-origin API route', () => { + const audit = auditRequest("url: (params) => '/api/tools/' + params.id") + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a statically concatenated same-origin API route', () => { + const audit = auditRequest("url: () => '/' + 'api/tools/test', method: 'POST'") + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a same-origin API route declared with method syntax', () => { + const audit = auditRequest("url() { return '/api/tools/test' }, method: 'POST'") + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a compound same-origin URL constructor path', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { + url: (params) => new URL('/api/tools/' + params.id, getBaseUrl()).toString(), + method: 'POST', + }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a same-origin path referenced through a constant', () => { + const audit = auditToolSelfHops(` + const INTERNAL_URL = '/api/tools/test' + const tool = { + id: 'test_tool', + request: { url: INTERNAL_URL, method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a same-origin request object referenced through an identifier', () => { + const audit = auditToolSelfHops(` + const internalRequest = { url: '/api/tools/test', method: 'POST' } + const tool = { id: 'test_tool', request: internalRequest } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'same-origin-tool-request', + }), + ]) + }) + + it('rejects a same-origin request inherited through a tool-level spread', () => { + const audit = auditToolSelfHops(` + const base = { request: { url: '/api/tools/test', method: 'POST' } } + const tool = { id: 'test_tool', ...base } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'same-origin-tool-request', + }), + ]) + }) + + it('rejects a same-origin URL inherited through a request-object spread', () => { + const audit = auditToolSelfHops(` + const internalRequest = { url: '/api/tools/test', method: 'POST' } + const tool = { + id: 'test_tool', + request: { ...internalRequest, headers: () => ({}) }, + } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'same-origin-tool-request', + }), + ]) + }) + + it('uses a direct external URL that overrides an internal request spread', () => { + const audit = auditToolSelfHops(` + const internalRequest = { url: '/api/tools/test', method: 'POST' } + const tool = { + id: 'test_tool', + request: { + ...internalRequest, + url: 'https://api.example.com/v1/items', + }, + } + `) - expect(audit.dynamicInternalRoutes).toBe(0) expect(audit.violations).toEqual([]) }) - it('rejects an unmarked dynamic internal URL', () => { - const audit = auditRequest(`url: (params) => \`/api/tools/\${encodeURIComponent(params.id)}\``) + it('rejects an unresolved spread that can override a known request', () => { + const audit = auditToolSelfHops(` + const known = { url: 'https://api.example.com/v1/items', method: 'POST' } + const tool = { id: 'test_tool', request: { ...known, ...unknownRequest } } + `) - expect(audit.dynamicInternalRoutes).toBe(1) expect(audit.violations).toEqual([ expect.objectContaining({ toolId: 'test_tool', - reason: 'missing-internal-policy', + reason: 'unresolved-request-policy', }), ]) }) - it('accepts a marked dynamic internal URL', () => { - const audit = auditRequest( - `internal: true, url: (params) => \`/api/tools/\${encodeURIComponent(params.id)}\`` - ) + it('retains an earlier URL when only one spread branch overrides it', () => { + const audit = auditToolSelfHops(` + const override = flag + ? { url: 'https://api.example.com/v1/items' } + : { headers: () => ({}) } + const tool = { + id: 'test_tool', + request: { url: '/api/tools/test', method: 'POST', ...override }, + } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'same-origin-tool-request', + }), + ]) + }) + + it('rejects a same-origin request object referenced through a member', () => { + const requestContainer = ` + const requestContainer = { + request: { url: '/api/tools/test', method: 'POST' }, + } + ` + const audit = auditToolSelfHops(` + ${requestContainer} + const tool = { id: 'test_tool', request: requestContainer.request } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'same-origin-tool-request', + }), + ]) + }) + + it('rejects an indirect legacy internal request policy', () => { + const audit = auditToolSelfHops(` + const legacyRequest = { + internal: true, + url: 'https://api.example.com/v1/items', + method: 'POST', + } + const tool = { id: 'test_tool', request: legacyRequest } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'legacy-internal-policy', + }), + ]) + }) + + it('rejects a same-origin path returned through a helper', () => { + const audit = auditToolSelfHops(` + function buildInternalUrl(id) { + return '/api/tools/' + id + } + const tool = { + id: 'test_tool', + request: { url: (params) => buildInternalUrl(params.id), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a conditional builder with a same-origin branch', () => { + const audit = auditRequest(` + url: (params) => + params.useExternal + ? 'https://api.example.com/v1/items' + : '/api/tools/test' + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a same-origin URL constructor', () => { + const audit = auditToolSelfHops(` + import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { + url: () => { + const url = new URL('/api/tools/test', getInternalApiBaseUrl()) + return url.toString() + }, + method: 'POST', + }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a same-origin path passed through a local URL helper', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl as getSimOrigin } from '@/lib/core/utils/urls' + function providerUrl(path, host) { + return new URL(path, host).toString() + } + const simOrigin = getSimOrigin() + const tool = { + id: 'test_tool', + request: { + url: () => providerUrl('/api/tools/test', simOrigin), + method: 'POST', + }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a same-origin request returned by a local helper', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + function buildRequest(path, host) { + return { + url: () => new URL(path, host).toString(), + method: 'POST', + } + } + const tool = { + id: 'test_tool', + request: buildRequest('/api/tools/test', getBaseUrl()), + } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'same-origin-tool-request', + }), + ]) + }) + + it('rejects a same-origin path forwarded through nested local helpers', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + function providerUrl(path, host) { + return new URL(path, host).toString() + } + function buildUrl(path) { + return providerUrl(path, getBaseUrl()) + } + const tool = { + id: 'test_tool', + request: { url: () => buildUrl('/api/tools/test'), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a same-origin path concatenated with the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { url: () => getBaseUrl() + '/api/tools/test', method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a helper-returned path concatenated with the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + function buildPath() { + return '/api/tools/test' + } + const tool = { + id: 'test_tool', + request: { url: () => getBaseUrl() + buildPath(), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a locally-bound helper path concatenated with the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + function buildPath() { + const path = '/api/tools/test' + return path + } + const tool = { + id: 'test_tool', + request: { url: () => getBaseUrl() + buildPath(), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a same-origin path interpolated with the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { url: () => \`${GET_BASE_URL_TEMPLATE}/api/tools/test\`, method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a helper-returned path interpolated with the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + function buildPath() { + return '/api/tools/test' + } + const tool = { + id: 'test_tool', + request: { url: () => \`\${getBaseUrl()}\${buildPath()}\`, method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a helper-returned path resolved against the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + function buildPath() { + return '/api/tools/test' + } + const tool = { + id: 'test_tool', + request: { url: () => new URL(buildPath(), getBaseUrl()).toString(), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a relative internal path resolved against the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { url: () => new URL('api/tools/test', getBaseUrl()).toString(), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a normalized relative internal path resolved against the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + function buildPath() { + return 'provider/../api/tools/test' + } + const tool = { + id: 'test_tool', + request: { url: () => new URL(buildPath(), getBaseUrl()).toString(), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects an internal path resolved against a path-normalized Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const baseUrl = getBaseUrl() + '/tool-proxy/' + const tool = { + id: 'test_tool', + request: { url: () => new URL('/api/tools/test', baseUrl).toString(), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects an internal path resolved against a template-normalized Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const baseUrl = \`\${getBaseUrl()}/tool-proxy/\` + const tool = { + id: 'test_tool', + request: { url: () => new URL('/api/tools/test', baseUrl).toString(), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects an internal path resolved against a helper-normalized Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + function getNormalizedOrigin() { + const origin = \`\${getBaseUrl()}/tool-proxy/\` + return origin + } + const tool = { + id: 'test_tool', + request: { + url: () => new URL('/api/tools/test', getNormalizedOrigin()).toString(), + method: 'POST', + }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a one-argument URL built from the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { + url: () => new URL(\`${GET_BASE_URL_TEMPLATE}/api/tools/test\`).toString(), + method: 'POST', + }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a one-argument URL concatenated from the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { + url: () => new URL(getBaseUrl() + '/api/tools/test').toString(), + method: 'POST', + }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a chained path concatenated from the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { url: () => getBaseUrl() + '/api' + '/tools/test', method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a known Sim URL builder wrapped in URL construction', () => { + const audit = auditToolSelfHops(` + import { buildAPIUrl } from '@/executor/utils/http' + const tool = { + id: 'test_tool', + request: { + url: () => new URL(buildAPIUrl('/api/tools/test')).toString(), + method: 'POST', + }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects an internal path resolved against a local Sim-origin wrapper', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + function getHost() { + return getBaseUrl() + } + const tool = { + id: 'test_tool', + request: { url: () => new URL('/api/tools/test', getHost()).toString(), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a same-origin URL returned by an imported helper', () => { + const audit = auditToolSelfHops(` + import { buildWorkflowMcpServerUrl } from '@/lib/mcp/urls' + const tool = { + id: 'test_tool', + request: { url: (params) => buildWorkflowMcpServerUrl(params.id), method: 'POST' }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects a same-origin path passed through a known imported URL builder', () => { + const audit = auditToolSelfHops(` + import { buildAPIUrl as buildSimUrl } from '@/executor/utils/http' + const tool = { + id: 'test_tool', + request: { + url: () => buildSimUrl('/api/tools/test').toString(), + method: 'POST', + }, + } + `) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('rejects the obsolete request.internal escape hatch', () => { + const audit = auditRequest('internal: true, url: (params) => buildInternalRoute(params.id)') + + expect(audit.legacyInternalPolicies).toBe(1) + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'legacy-internal-policy', + }), + ]) + }) + + it('rejects same-origin opt-in on an integration tool', () => { + const audit = auditToolSelfHops(` + const tool = { + id: 'test_tool', + request: { + allowSameOrigin: true, + url: (params) => params.url, + method: 'POST', + headers: () => ({}), + }, + } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'unapproved-same-origin-policy', + }), + ]) + }) + + it.each(['http_request', 'webhook_request'])( + 'allows the intentional same-origin policy on %s', + (toolId) => { + const audit = auditToolSelfHops(` + const tool = { + id: '${toolId}', + request: { + allowSameOrigin: true, + url: (params) => params.url, + method: 'POST', + headers: () => ({}), + }, + } + `) + + expect(audit.violations).toEqual([]) + } + ) + + it('resolves a constant tool ID before applying the same-origin allowlist', () => { + const audit = auditToolSelfHops(` + const TOOL_ID = 'http_request' + const tool = { + id: TOOL_ID, + request: { + allowSameOrigin: true, + url: (params) => params.url, + method: 'POST', + headers: () => ({}), + }, + } + `) - expect(audit.dynamicInternalRoutes).toBe(1) - expect(audit.dynamicInternalPolicies).toBe(1) expect(audit.violations).toEqual([]) }) - it('rejects an unencoded internal path parameter', () => { - const audit = auditRequest( - `internal: true, url: (params) => \`/api/tools/${PARAM_TEMPLATE_EXPRESSION}\`` - ) + it('audits a same-origin request when the tool ID is an expression', () => { + const audit = auditToolSelfHops(` + const tool = { + id: flag ? 'first_tool' : 'second_tool', + request: { url: '/api/tools/test', method: 'POST', headers: () => ({}) }, + } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ reason: 'same-origin-tool-request' }), + ]) + }) + + it('fails closed on a computed request property key', () => { + const audit = auditToolSelfHops(` + const tool = { + id: 'test_tool', + [runtimeRequestKey]: { + url: 'https://provider.example.com', + method: 'POST', + headers: () => ({}), + }, + } + `) expect(audit.violations).toEqual([ expect.objectContaining({ toolId: 'test_tool', - reason: 'unsafe-internal-path-interpolation', + reason: 'unresolved-request-policy', }), ]) }) - it('detects a concatenated dynamic internal URL', () => { - const audit = auditRequest("url: (params) => '/api/tools/' + encodeURIComponent(params.id)") + it('fails closed on a computed request URL key', () => { + const audit = auditToolSelfHops(` + const tool = { + id: 'test_tool', + request: { + [runtimeUrlKey]: '/api/tools/test', + method: 'POST', + headers: () => ({}), + }, + } + `) - expect(audit.dynamicInternalRoutes).toBe(1) - expect(audit.violations[0]?.reason).toBe('missing-internal-policy') + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'unresolved-request-policy', + }), + ]) }) - it('rejects an unencoded concatenated internal path parameter', () => { - const audit = auditRequest("internal: true, url: (params) => '/api/tools/' + params.id") + it('rejects request.internal even when the URL comes only from a spread', () => { + const audit = auditToolSelfHops(` + const externalRequest = { url: 'https://api.example.com/v1/items' } + const tool = { + id: 'test_tool', + request: { ...externalRequest, internal: true }, + } + `) + expect(audit.legacyInternalPolicies).toBe(1) expect(audit.violations).toEqual([ expect.objectContaining({ toolId: 'test_tool', - reason: 'unsafe-internal-path-interpolation', + reason: 'legacy-internal-policy', }), ]) }) - it('accepts an encoded concatenated internal path parameter', () => { - const audit = auditRequest( - "internal: true, url: (params) => '/api/tools/' + encodeURIComponent(params.id)" - ) + it('does not mistake a provider-relative path argument for a Sim API route', () => { + const audit = auditToolSelfHops(` + function providerUrl(path, host) { + return new URL(path, host).toString() + } + const tool = { + id: 'test_tool', + request: { url: (params) => providerUrl('/api/messages', params.host), method: 'GET' }, + } + `) expect(audit.violations).toEqual([]) }) - it('rejects an unencoded path parameter with a renamed callback binding', () => { - const audit = auditRequest( - `internal: true, url: (input) => \`/api/tools/${INPUT_TEMPLATE_EXPRESSION}\`` - ) + it('allows an API-shaped provider path resolved against an external origin', () => { + const audit = auditToolSelfHops(` + function providerUrl(path, host) { + return new URL(path, host).toString() + } + const tool = { + id: 'test_tool', + request: { + url: () => providerUrl('/api/messages', 'https://provider.example.com'), + method: 'POST', + }, + } + `) - expect(audit.violations[0]?.reason).toBe('unsafe-internal-path-interpolation') + expect(audit.violations).toEqual([]) }) - it('rejects an unencoded destructured callback binding', () => { - const audit = auditRequest( - `internal: true, url: ({ id }) => \`/api/tools/${DESTRUCTURED_TEMPLATE_EXPRESSION}\`` - ) + it('allows a helper-returned API-shaped path resolved against an external origin', () => { + const audit = auditToolSelfHops(` + function buildPath() { + return '/api/messages' + } + const tool = { + id: 'test_tool', + request: { + url: () => new URL(buildPath(), 'https://provider.example.com').toString(), + method: 'POST', + }, + } + `) - expect(audit.violations[0]?.reason).toBe('unsafe-internal-path-interpolation') + expect(audit.violations).toEqual([]) }) - it('rejects an unencoded template nested inside a concatenation', () => { - const audit = auditRequest( - `internal: true, url: (params) => '/api/tools/' + \`${PARAM_TEMPLATE_EXPRESSION}\`` - ) + it('allows a protocol-relative provider URL resolved against the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { + url: () => new URL('//provider.example.com/api/messages', getBaseUrl()).toString(), + method: 'POST', + }, + } + `) + + expect(audit.violations).toEqual([]) + }) + + it('does not treat hostname mutation as Sim-origin normalization', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const providerOrigin = getBaseUrl() + '.provider.example.com' + const tool = { + id: 'test_tool', + request: { + url: () => new URL('/api/messages', providerOrigin).toString(), + method: 'POST', + }, + } + `) + + expect(audit.violations).toEqual([]) + }) + + it('fails closed when a dynamic suffix follows the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { + url: (params) => new URL( + '/api/messages', + \`\${getBaseUrl()}\${params.providerDomain}\` + ).toString(), + method: 'POST', + }, + } + `) - expect(audit.violations[0]?.reason).toBe('unsafe-internal-path-interpolation') + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'unresolved-request-policy', + }), + ]) }) - it('accepts an encoded template nested inside a concatenation', () => { + it('allows an API-shaped path interpolated with an external provider origin', () => { const audit = auditRequest( - `internal: true, url: (params) => '/api/tools/' + \`${ENCODED_TEMPLATE_EXPRESSION}\`` + `url: (params) => \`${PARAMS_HOST_TEMPLATE}/api/messages\`, method: 'POST'` ) expect(audit.violations).toEqual([]) }) - it('allows a raw query value after a nested encoded path template', () => { + it('allows a one-argument URL for an external provider API', () => { const audit = auditRequest( - `internal: true, url: (params) => '/api/tools/' + \`${ENCODED_TEMPLATE_EXPRESSION}?query=\` + params.query` + "url: () => new URL('https://api.example.com/api/messages').toString(), method: 'POST'" ) expect(audit.violations).toEqual([]) }) - it('accepts a definition-owned policy for conditional internal and external branches', () => { + it('ignores internal-looking returns in an unused nested callback', () => { const audit = auditRequest(` - internal: (params) => params.internal, - url: (params) => params.internal ? '/api/tools/test' : 'https://example.com/test' + url: () => { + const parseProviderField = () => { + return '/api/provider-field' + } + return 'https://api.example.com/v1/items' + }, + method: 'POST' `) - expect(audit.dynamicInternalRoutes).toBe(1) - expect(audit.dynamicInternalPolicies).toBe(1) expect(audit.violations).toEqual([]) }) - it('accepts a named predicate for conditional internal and external branches', () => { - const audit = auditRequest(` - internal: usesInternalRoute, - url: (params) => params.internal ? '/api/tools/test' : 'https://example.com/test' + it('allows an external request returned by a local helper', () => { + const audit = auditToolSelfHops(` + function buildRequest(path, host) { + return { + url: () => new URL(path, host).toString(), + method: 'POST', + } + } + const tool = { + id: 'test_tool', + request: buildRequest('/api/messages', 'https://provider.example.com'), + } `) expect(audit.violations).toEqual([]) }) - it('rejects static trust for a mixed internal and external URL builder', () => { - const audit = auditRequest(` - internal: true, - url: (params) => params.internal ? '/api/tools/test' : 'https://example.com/test' + it('allows a provider request returned by an imported helper', () => { + const audit = auditToolSelfHops( + ` + import { snowflakeStatementRequest } from '@/tools/snowflake/utils' + const tool = { + id: 'test_tool', + request: snowflakeStatementRequest(() => ({ statement: 'select 1' })), + } + `, + 'apps/sim/tools/snowflake/audit-fixture.ts' + ) + + expect(audit.violations).toEqual([]) + }) + + it('preserves Sim-origin arguments passed into an imported request factory', () => { + const audit = auditToolSelfHops( + ` + import { getBaseUrl } from '@/lib/core/utils/urls' + import { createRequest } from './fixtures/check-tool-request-boundary/request-factory' + const tool = { id: 'test_tool', request: createRequest(getBaseUrl()) } + `, + 'scripts/audit-fixture.ts' + ) + + expect(audit.violations[0]?.reason).toBe('same-origin-tool-request') + }) + + it('allows an external request object referenced through a member', () => { + const audit = auditToolSelfHops(` + const baseTool = { + request: { url: 'https://api.example.com/v1/items', method: 'GET' }, + } + const tool = { id: 'test_tool', request: baseTool.request } + `) + + expect(audit.violations).toEqual([]) + }) + + it('rejects a tool request object that cannot be statically resolved', () => { + const audit = auditToolSelfHops(` + const tool = { id: 'test_tool', request: unknownRequestFactory() } `) expect(audit.violations).toEqual([ expect.objectContaining({ toolId: 'test_tool', - reason: 'mixed-route-requires-conditional-policy', + reason: 'unresolved-request-policy', }), ]) }) - it('detects an external URL constructor in a mixed URL builder', () => { - const audit = auditRequest(` - internal: true, - url: (params) => - params.internal - ? '/api/tools/test' - : new URL('https://example.com/test').toString() + it('rejects a request URL returned by an uninspectable helper', () => { + const audit = auditToolSelfHops(` + const tool = { + id: 'test_tool', + request: { url: () => unknownUrlHelper(), method: 'GET' }, + } `) - expect(audit.violations[0]?.reason).toBe('mixed-route-requires-conditional-policy') + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'unresolved-request-policy', + }), + ]) }) - it('rejects false as an internal route policy', () => { - const audit = auditRequest(` - internal: false, - url: (params) => params.internal ? '/api/tools/test' : 'https://example.com/test' + it('rejects an uninspectable URL helper combined with the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { url: () => getBaseUrl() + unknownPathHelper(), method: 'GET' }, + } `) expect(audit.violations).toEqual([ expect.objectContaining({ toolId: 'test_tool', - reason: 'invalid-internal-policy', + reason: 'unresolved-request-policy', }), ]) }) - it('detects internal paths constructed through URL', () => { - const audit = auditRequest(` - url: (params) => { - const url = new URL('/api/tools/test', 'http://placeholder') - url.searchParams.set('id', params.id) - return url.pathname + url.search + it('rejects a dynamic path resolved against the Sim origin', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { url: (params) => new URL(params.path, getBaseUrl()), method: 'GET' }, } `) - expect(audit.dynamicInternalRoutes).toBe(1) - expect(audit.violations[0]?.reason).toBe('missing-internal-policy') + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'unresolved-request-policy', + }), + ]) }) - it('rejects internal trust on an external-only URL builder', () => { - const audit = auditRequest("internal: true, url: () => 'https://example.com/test'") + it('allows an encoded path segment resolved against a static non-API Sim path', () => { + const audit = auditToolSelfHops(` + import { getBaseUrl } from '@/lib/core/utils/urls' + const tool = { + id: 'test_tool', + request: { + url: (params) => new URL('/assets/' + encodeURIComponent(params.id), getBaseUrl()), + method: 'GET', + }, + } + `) - expect(audit.violations[0]?.reason).toBe('internal-policy-without-internal-route') + expect(audit.violations).toEqual([]) }) - it('allows an explicit policy when a helper owns the internal route construction', () => { - const audit = auditRequest('internal: true, url: (params) => buildInternalRoute(params.id)') + it('allows an uninspectable path helper after an explicit external origin', () => { + const audit = auditToolSelfHops(` + const tool = { + id: 'test_tool', + request: { + url: (params) => 'https://provider.example.com/' + unknownPathHelper(params.id), + method: 'GET', + }, + } + `) - expect(audit.dynamicInternalRoutes).toBe(0) - expect(audit.dynamicInternalPolicies).toBe(1) expect(audit.violations).toEqual([]) }) - it('does not mistake a provider-relative helper argument for a Sim API route', () => { - const audit = auditRequest("url: (params) => providerUrl('/api/messages', params.host)") + it('rejects a direct-id tool whose request may come from an unresolved spread', () => { + const audit = auditToolSelfHops(` + const tool = { id: 'test_tool', ...unknownBase } + `) - expect(audit.dynamicInternalRoutes).toBe(0) - expect(audit.violations).toEqual([]) + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'unresolved-request-policy', + }), + ]) + }) + + it('rejects a request when any conditional branch cannot be resolved', () => { + const audit = auditToolSelfHops(` + const external = { url: 'https://api.example.com/v1/items', method: 'GET' } + const tool = { + id: 'test_tool', + request: flag ? external : unknownRequestFactory(), + } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'unresolved-request-policy', + }), + ]) + }) + + it('does not use a nested function return to resolve an outer request factory', () => { + const audit = auditToolSelfHops(` + function buildRequest() { + function decoy() { + return { url: 'https://api.example.com/v1/items', method: 'GET' } + } + return unknownRequestFactory() + } + const tool = { id: 'test_tool', request: buildRequest() } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ + toolId: 'test_tool', + reason: 'unresolved-request-policy', + }), + ]) }) }) diff --git a/scripts/check-tool-request-boundary.ts b/scripts/check-tool-request-boundary.ts index 6e55616d446..1faaaa9b373 100644 --- a/scripts/check-tool-request-boundary.ts +++ b/scripts/check-tool-request-boundary.ts @@ -1,13 +1,12 @@ #!/usr/bin/env bun /** - * Fails when production code reads an executable ToolConfig request member outside the canonical - * transport. Tool definitions may declare request config, but only request-transport.ts may - * materialize its URL, method, headers, or body. The direct-access check is intentionally - * syntactic and zero-exception: ordinary nested request objects must first be bound to a local - * before their wire members are read, keeping the reserved ToolConfig shape impossible to - * reintroduce silently. + * Enforces the two tool execution boundaries: external ToolConfig requests are materialized only + * by request-transport.ts, while same-process work uses registered InternalToolConfig operations. + * Tool definitions may not point back to Sim API routes or revive the retired request.internal + * escape hatch. Dynamic provider origins remain supported because the executor rejects their + * resolved URL when it targets Sim; only the two generic user-directed HTTP tools may opt out. */ -import { readdirSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, extname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parse } from '@babel/parser' @@ -17,7 +16,20 @@ const ROOT = resolve(SCRIPT_DIR, '..') const APP = join(ROOT, 'apps/sim') const CANONICAL_TRANSPORT = join(APP, 'tools/request-transport.ts') const REQUEST_MEMBERS = new Set(['url', 'method', 'headers', 'body']) +const SIM_URLS_MODULE = '@/lib/core/utils/urls' +const SIM_ORIGIN_EXPORTS = new Set(['getBaseUrl', 'getInternalApiBaseUrl']) +const SIM_URL_BUILDER_EXPORTS = new Set(['ensureAbsoluteUrl']) +const EXECUTOR_HTTP_MODULE = '@/executor/utils/http' +const EXECUTOR_URL_BUILDER_EXPORTS = new Set(['buildAPIUrl']) const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']) +const FUNCTION_NODE_TYPES = new Set([ + 'ArrowFunctionExpression', + 'FunctionExpression', + 'FunctionDeclaration', + 'ObjectMethod', +]) +const URL_VALUE_WRAPPER_CALLS = new Set(['String', 'encodeURI', 'encodeURIComponent']) +const APPROVED_SAME_ORIGIN_TOOL_IDS = new Set(['http_request', 'webhook_request']) interface Violation { file: string @@ -25,22 +37,21 @@ interface Violation { expression: string } -export interface RequestTrustViolation { +export interface ToolSelfHopViolation { file: string line: number toolId?: string reason: - | 'missing-internal-policy' - | 'invalid-internal-policy' - | 'internal-policy-without-internal-route' - | 'mixed-route-requires-conditional-policy' - | 'unsafe-internal-path-interpolation' + | 'same-origin-tool-request' + | 'legacy-internal-policy' + | 'unresolved-request-policy' + | 'unapproved-same-origin-policy' } -export interface RequestTrustAudit { - violations: RequestTrustViolation[] - dynamicInternalRoutes: number - dynamicInternalPolicies: number +export interface ToolSelfHopAudit { + violations: ToolSelfHopViolation[] + detectedSelfHops: number + legacyInternalPolicies: number } interface SyntaxNode extends Record { @@ -113,6 +124,7 @@ function unwrapExpression(expression: SyntaxNode): SyntaxNode { function getStaticPropertyName(property: SyntaxNode): string | undefined { if (!isSyntaxNode(property.key)) return undefined const key = property.key + if (property.computed === true) return getStaticString(key) if (key.type === 'Identifier' && typeof key.name === 'string') return key.name if (key.type === 'StringLiteral' && typeof key.value === 'string') return key.value return undefined @@ -148,56 +160,465 @@ function getStringPrefix(expression: SyntaxNode): string | undefined { return undefined } -function isInternalPathExpression(expression: SyntaxNode): boolean { +function getStaticString(expression: SyntaxNode): string | undefined { const current = unwrapExpression(expression) + if (current.type === 'StringLiteral' && typeof current.value === 'string') return current.value + if ( + current.type === 'TemplateLiteral' && + Array.isArray(current.expressions) && + current.expressions.length === 0 && + Array.isArray(current.quasis) + ) { + return current.quasis + .filter(isSyntaxNode) + .map((quasi) => { + const value = quasi.value + if (typeof value !== 'object' || value === null) return '' + return 'cooked' in value && typeof value.cooked === 'string' + ? value.cooked + : 'raw' in value && typeof value.raw === 'string' + ? value.raw + : '' + }) + .join('') + } + if ( + current.type === 'BinaryExpression' && + current.operator === '+' && + isSyntaxNode(current.left) && + isSyntaxNode(current.right) + ) { + const left = getStaticString(current.left) + const right = getStaticString(current.right) + return left !== undefined && right !== undefined ? left + right : undefined + } + return undefined +} + +interface SelfHopResolver { + bindings: ReadonlyMap + importedBindings: ReadonlyMap + simOriginBindings: ReadonlySet + simUrlBuilderBindings: ReadonlySet + file: string + locals?: ReadonlyMap + scopedLocals?: ReadonlyMap +} + +interface ImportedBinding { + importedName: string + source: string +} + +function resolveIdentifier(name: string, resolver: SelfHopResolver): SyntaxNode | undefined { + return resolver.locals?.get(name) ?? resolver.bindings.get(name) +} + +function resolveScopedIdentifier( + name: string, + resolver: SelfHopResolver +): ScopedExpression | undefined { + const scopedLocal = resolver.scopedLocals?.get(name) + if (scopedLocal) return scopedLocal + const local = resolver.locals?.get(name) + if (local) return { expression: local, resolver } + const binding = resolver.bindings.get(name) + if (binding) return { expression: binding, resolver } + return loadImportedBinding(name, resolver) +} + +function resolveScopedArgument( + expression: SyntaxNode, + resolver: SelfHopResolver +): ScopedExpression { + const current = unwrapExpression(expression) + if (current.type === 'Identifier' && typeof current.name === 'string') { + return resolveScopedIdentifier(current.name, resolver) ?? { expression: current, resolver } + } + return { expression: current, resolver } +} + +function collectFunctionLocalBindings(fn: SyntaxNode, locals: Map): void { + const visit = (node: SyntaxNode) => { + if (node !== fn && FUNCTION_NODE_TYPES.has(node.type)) return + if ( + node.type === 'VariableDeclarator' && + isSyntaxNode(node.id) && + node.id.type === 'Identifier' && + typeof node.id.name === 'string' && + isSyntaxNode(node.init) + ) { + locals.set(node.id.name, node.init) + } + for (const child of getChildNodes(node)) visit(child) + } + visit(fn) +} + +function isInternalPathExpression( + expression: SyntaxNode, + resolver: SelfHopResolver, + seen = new Set(), + allowRelative = false +): boolean { + const current = unwrapExpression(expression) + const staticValue = getStaticString(current) + if (staticValue && isInternalApiPath(staticValue, allowRelative)) return true const prefix = getStringPrefix(current) - if (prefix?.startsWith('/api/')) return true + if (prefix && isInternalApiPath(prefix, allowRelative)) return true + + if (current.type === 'Identifier' && typeof current.name === 'string') { + const key = `${resolver.file}:path:${current.name}` + if (seen.has(key)) return false + const binding = resolveScopedIdentifier(current.name, resolver) + if (!binding) return false + const nextSeen = new Set(seen) + nextSeen.add(key) + return isInternalPathExpression(binding.expression, binding.resolver, nextSeen, allowRelative) + } + + if (current.type === 'CallExpression' || current.type === 'OptionalCallExpression') { + if (!isSyntaxNode(current.callee)) return false + const callee = unwrapExpression(current.callee) + if (callee.type === 'Identifier' && typeof callee.name === 'string') { + const key = `${resolver.file}:path-call:${callee.name}` + if (seen.has(key)) return false + const binding = resolveScopedIdentifier(callee.name, resolver) + if (binding && FUNCTION_NODE_TYPES.has(unwrapExpression(binding.expression).type)) { + const nextSeen = new Set(seen) + nextSeen.add(key) + const argumentsList = Array.isArray(current.arguments) + ? current.arguments + .filter(isSyntaxNode) + .map((argument) => resolveScopedArgument(argument, resolver)) + : [] + return functionReturnsInternalPath( + binding.expression, + binding.resolver, + argumentsList, + nextSeen, + allowRelative + ) + } + } + } if (current.type === 'ConditionalExpression') { return ( - (isSyntaxNode(current.consequent) && isInternalPathExpression(current.consequent)) || - (isSyntaxNode(current.alternate) && isInternalPathExpression(current.alternate)) + (isSyntaxNode(current.consequent) && + isInternalPathExpression(current.consequent, resolver, new Set(seen), allowRelative)) || + (isSyntaxNode(current.alternate) && + isInternalPathExpression(current.alternate, resolver, new Set(seen), allowRelative)) ) } if (current.type === 'LogicalExpression') { return ( - (isSyntaxNode(current.left) && isInternalPathExpression(current.left)) || - (isSyntaxNode(current.right) && isInternalPathExpression(current.right)) + (isSyntaxNode(current.left) && + isInternalPathExpression(current.left, resolver, new Set(seen), allowRelative)) || + (isSyntaxNode(current.right) && + isInternalPathExpression(current.right, resolver, new Set(seen), allowRelative)) ) } if (current.type === 'BinaryExpression' && current.operator === '+') { - return ( - (isSyntaxNode(current.left) && isInternalPathExpression(current.left)) || - (isSyntaxNode(current.right) && isInternalPathExpression(current.right)) - ) + return isSyntaxNode(current.left) + ? isInternalPathExpression(current.left, resolver, new Set(seen), allowRelative) + : false } return false } -function isExternalUrlExpression(expression: SyntaxNode): boolean { - const current = unwrapExpression(expression) - const prefix = getStringPrefix(current) - if (prefix && /^https?:\/\//.test(prefix)) return true +function functionReturnsInternalPath( + fn: SyntaxNode, + resolver: SelfHopResolver, + argumentsList: readonly ScopedExpression[], + seen: ReadonlySet, + allowRelative: boolean +): boolean { + const current = unwrapExpression(fn) + if (!FUNCTION_NODE_TYPES.has(current.type)) return false + const locals = new Map(resolver.locals) + const scopedLocals = new Map(resolver.scopedLocals) + const parameters = Array.isArray(current.params) ? current.params : [] + for (const [index, parameter] of parameters.entries()) { + if ( + isSyntaxNode(parameter) && + parameter.type === 'Identifier' && + typeof parameter.name === 'string' && + argumentsList[index] + ) { + const argument = argumentsList[index] + scopedLocals.set(parameter.name, argument) + if (argument.resolver === resolver) locals.set(parameter.name, argument.expression) + } + } + collectFunctionLocalBindings(current, locals) + const localResolver = { ...resolver, locals, scopedLocals } + if (current.type === 'ArrowFunctionExpression' && isSyntaxNode(current.body)) { + const body = unwrapExpression(current.body) + if (body.type !== 'BlockStatement') { + return isInternalPathExpression(body, localResolver, new Set(seen), allowRelative) + } + } + let found = false + const visit = (node: SyntaxNode) => { + if (found || (node !== current && FUNCTION_NODE_TYPES.has(node.type))) return + if ( + node.type === 'ReturnStatement' && + isSyntaxNode(node.argument) && + isInternalPathExpression(node.argument, localResolver, new Set(seen), allowRelative) + ) { + found = true + return + } + for (const child of getChildNodes(node)) visit(child) + } + visit(current) + return found +} - if (current.type === 'ConditionalExpression') { +function isInternalApiPath(value: string, allowRelative: boolean): boolean { + if (value.startsWith('/api/')) return true + if (!allowRelative) return false + try { + const base = new URL('https://sim-boundary.invalid/') + const resolved = new URL(value, base) + return resolved.origin === base.origin && resolved.pathname.startsWith('/api/') + } catch { + return false + } +} + +function isOriginPreservingStaticSuffix(expression: SyntaxNode): boolean { + const suffix = getStaticString(expression) + return suffix !== undefined && (suffix === '' || /^[/?#]/.test(suffix)) +} + +function isSimOriginExpression( + expression: SyntaxNode, + resolver: SelfHopResolver, + seen = new Set() +): boolean { + const current = unwrapExpression(expression) + if (current.type === 'Identifier' && typeof current.name === 'string') { + const key = `${resolver.file}:origin:${current.name}` + if (seen.has(key)) return false + const binding = resolveScopedIdentifier(current.name, resolver) + if (!binding) return false + const nextSeen = new Set(seen) + nextSeen.add(key) + return isSimOriginExpression(binding.expression, binding.resolver, nextSeen) + } + if (current.type === 'CallExpression' || current.type === 'OptionalCallExpression') { + if (!isSyntaxNode(current.callee)) return false + const callee = unwrapExpression(current.callee) + if ( + callee.type === 'Identifier' && + typeof callee.name === 'string' && + resolver.simOriginBindings.has(callee.name) + ) { + return true + } + if (callee.type === 'Identifier' && typeof callee.name === 'string') { + const key = `${resolver.file}:origin-call:${callee.name}` + if (seen.has(key)) return false + const binding = resolveScopedIdentifier(callee.name, resolver) + if (binding && FUNCTION_NODE_TYPES.has(unwrapExpression(binding.expression).type)) { + const nextSeen = new Set(seen) + nextSeen.add(key) + const argumentsList = Array.isArray(current.arguments) + ? current.arguments + .filter(isSyntaxNode) + .map((argument) => resolveScopedArgument(argument, resolver)) + : [] + return functionReturnsSimOrigin( + binding.expression, + binding.resolver, + argumentsList, + nextSeen + ) + } + } + } + if ( + current.type === 'BinaryExpression' && + current.operator === '+' && + isSyntaxNode(current.left) && + isSyntaxNode(current.right) + ) { return ( - (isSyntaxNode(current.consequent) && isExternalUrlExpression(current.consequent)) || - (isSyntaxNode(current.alternate) && isExternalUrlExpression(current.alternate)) + isSimOriginExpression(current.left, resolver, new Set(seen)) && + isOriginPreservingStaticSuffix(current.right) ) } if ( - current.type === 'LogicalExpression' || - (current.type === 'BinaryExpression' && current.operator === '+') + current.type === 'TemplateLiteral' && + Array.isArray(current.expressions) && + Array.isArray(current.quasis) && + current.expressions.length > 0 && + current.quasis.length === current.expressions.length + 1 && + current.expressions.every(isSyntaxNode) && + current.quasis.every(isSyntaxNode) && + getTemplateQuasiValue(current.quasis[0]) === '' && + isSimOriginExpression(current.expressions[0], resolver, new Set(seen)) ) { + const suffix = getTemplateQuasiValue(current.quasis[1]) return ( - (isSyntaxNode(current.left) && isExternalUrlExpression(current.left)) || - (isSyntaxNode(current.right) && isExternalUrlExpression(current.right)) + suffix !== undefined && + (suffix === '' ? current.expressions.length === 1 : /^[/?#]/.test(suffix)) ) } + if (current.type === 'ConditionalExpression') { + return ( + (isSyntaxNode(current.consequent) && + isSimOriginExpression(current.consequent, resolver, new Set(seen))) || + (isSyntaxNode(current.alternate) && + isSimOriginExpression(current.alternate, resolver, new Set(seen))) + ) + } + if (current.type === 'LogicalExpression') { + return ( + (isSyntaxNode(current.left) && + isSimOriginExpression(current.left, resolver, new Set(seen))) || + (isSyntaxNode(current.right) && isSimOriginExpression(current.right, resolver, new Set(seen))) + ) + } + return false +} + +function functionReturnsSimOrigin( + fn: SyntaxNode, + resolver: SelfHopResolver, + argumentsList: readonly ScopedExpression[], + seen: ReadonlySet +): boolean { + const current = unwrapExpression(fn) + if (!FUNCTION_NODE_TYPES.has(current.type)) return false + const locals = new Map(resolver.locals) + const scopedLocals = new Map(resolver.scopedLocals) + const parameters = Array.isArray(current.params) ? current.params : [] + for (const [index, parameter] of parameters.entries()) { + if ( + isSyntaxNode(parameter) && + parameter.type === 'Identifier' && + typeof parameter.name === 'string' && + argumentsList[index] + ) { + const argument = argumentsList[index] + scopedLocals.set(parameter.name, argument) + if (argument.resolver === resolver) locals.set(parameter.name, argument.expression) + } + } + collectFunctionLocalBindings(current, locals) + const localResolver = { ...resolver, locals, scopedLocals } + if (current.type === 'ArrowFunctionExpression' && isSyntaxNode(current.body)) { + const body = unwrapExpression(current.body) + if (body.type !== 'BlockStatement') { + return isSimOriginExpression(body, localResolver, new Set(seen)) + } + } + let found = false + const visit = (node: SyntaxNode) => { + if (found || (node !== current && FUNCTION_NODE_TYPES.has(node.type))) return + if ( + node.type === 'ReturnStatement' && + isSyntaxNode(node.argument) && + isSimOriginExpression(node.argument, localResolver, new Set(seen)) + ) { + found = true + return + } + for (const child of getChildNodes(node)) visit(child) + } + visit(current) + return found +} + +function isSameOriginConcatenation(expression: SyntaxNode, resolver: SelfHopResolver): boolean { + const current = unwrapExpression(expression) + const parts: SyntaxNode[] = [] + const collect = (node: SyntaxNode) => { + const value = unwrapExpression(node) + if ( + value.type === 'BinaryExpression' && + value.operator === '+' && + isSyntaxNode(value.left) && + isSyntaxNode(value.right) + ) { + collect(value.left) + collect(value.right) + return + } + parts.push(value) + } + collect(current) + if (parts.length < 2 || !isSimOriginExpression(parts[0], resolver)) return false + let staticSuffix = '' + for (const part of parts.slice(1)) { + const value = getStaticString(part) + if (value !== undefined) { + staticSuffix += value + if (staticSuffix.startsWith('/api/')) return true + continue + } + if (isInternalPathExpression(part, resolver)) return true + break + } return false } -function getUrlConstructionPrefix(node: SyntaxNode): string | undefined { +function isKnownSimUrlBuilderCall(expression: SyntaxNode, resolver: SelfHopResolver): boolean { + const current = unwrapExpression(expression) + if ( + (current.type !== 'CallExpression' && current.type !== 'OptionalCallExpression') || + !isSyntaxNode(current.callee) || + !Array.isArray(current.arguments) || + !isSyntaxNode(current.arguments[0]) + ) { + return false + } + const callee = unwrapExpression(current.callee) + return ( + callee.type === 'Identifier' && + typeof callee.name === 'string' && + resolver.simUrlBuilderBindings.has(callee.name) && + isInternalPathExpression(current.arguments[0], resolver) + ) +} + +function getTemplateQuasiValue(quasi: SyntaxNode): string | undefined { + const value = quasi.value + if (typeof value !== 'object' || value === null) return undefined + if ('cooked' in value && typeof value.cooked === 'string') return value.cooked + return 'raw' in value && typeof value.raw === 'string' ? value.raw : undefined +} + +function isSameOriginTemplate(expression: SyntaxNode, resolver: SelfHopResolver): boolean { + const current = unwrapExpression(expression) + if ( + current.type !== 'TemplateLiteral' || + !Array.isArray(current.expressions) || + !Array.isArray(current.quasis) || + current.expressions.length === 0 || + current.quasis.length !== current.expressions.length + 1 || + !current.expressions.every(isSyntaxNode) || + !current.quasis.every(isSyntaxNode) + ) { + return false + } + const leadingQuasi = getTemplateQuasiValue(current.quasis[0]) + if (leadingQuasi !== '') return false + const origin = current.expressions[0] + if (!isSimOriginExpression(origin, resolver)) return false + const pathQuasi = getTemplateQuasiValue(current.quasis[1]) + if (pathQuasi?.startsWith('/api/')) return true + return ( + pathQuasi === '' && + current.expressions.length > 1 && + isInternalPathExpression(current.expressions[1], resolver) + ) +} + +function isInternalUrlConstruction(node: SyntaxNode, resolver: SelfHopResolver): boolean { const current = unwrapExpression(node) if ( current.type !== 'NewExpression' || @@ -205,80 +626,650 @@ function getUrlConstructionPrefix(node: SyntaxNode): string | undefined { current.callee.type !== 'Identifier' || current.callee.name !== 'URL' || !Array.isArray(current.arguments) || - current.arguments.length === 0 || !isSyntaxNode(current.arguments[0]) ) { - return undefined + return false } - return getStringPrefix(current.arguments[0]) + if (current.arguments.length === 1) { + return ( + isSameOriginConcatenation(current.arguments[0], resolver) || + isSameOriginTemplate(current.arguments[0], resolver) || + isKnownSimUrlBuilderCall(current.arguments[0], resolver) + ) + } + return ( + isSyntaxNode(current.arguments[1]) && + isInternalPathExpression(current.arguments[0], resolver, new Set(), true) && + isSimOriginExpression(current.arguments[1], resolver) + ) } -function isInternalUrlConstruction(node: SyntaxNode): boolean { - return getUrlConstructionPrefix(node)?.startsWith('/api/') === true +function hasExplicitExternalUrlPrefix( + expression: SyntaxNode, + resolver: SelfHopResolver, + seen = new Set() +): boolean { + const current = unwrapExpression(expression) + const staticValue = getStaticString(current) + if (staticValue !== undefined) return /^https?:\/\//.test(staticValue) + const prefix = getStringPrefix(current) + if (prefix !== undefined && /^https?:\/\//.test(prefix)) return true + if (current.type === 'Identifier' && typeof current.name === 'string') { + const key = `${resolver.file}:external-origin:${current.name}` + if (seen.has(key)) return false + const binding = resolveScopedIdentifier(current.name, resolver) + if (!binding) return false + const nextSeen = new Set(seen) + nextSeen.add(key) + return hasExplicitExternalUrlPrefix(binding.expression, binding.resolver, nextSeen) + } + if ( + current.type === 'BinaryExpression' && + current.operator === '+' && + isSyntaxNode(current.left) + ) { + return hasExplicitExternalUrlPrefix(current.left, resolver, new Set(seen)) + } + if (current.type === 'ConditionalExpression') { + return ( + isSyntaxNode(current.consequent) && + isSyntaxNode(current.alternate) && + hasExplicitExternalUrlPrefix(current.consequent, resolver, new Set(seen)) && + hasExplicitExternalUrlPrefix(current.alternate, resolver, new Set(seen)) + ) + } + if (current.type === 'LogicalExpression') { + return ( + isSyntaxNode(current.left) && + isSyntaxNode(current.right) && + hasExplicitExternalUrlPrefix(current.left, resolver, new Set(seen)) && + hasExplicitExternalUrlPrefix(current.right, resolver, new Set(seen)) + ) + } + if ( + current.type === 'TemplateLiteral' && + Array.isArray(current.expressions) && + Array.isArray(current.quasis) && + current.expressions.length > 0 && + current.quasis.length === current.expressions.length + 1 && + current.expressions.every(isSyntaxNode) && + current.quasis.every(isSyntaxNode) && + getTemplateQuasiValue(current.quasis[0]) === '' && + hasExplicitExternalUrlPrefix(current.expressions[0], resolver, new Set(seen)) + ) { + const suffix = getTemplateQuasiValue(current.quasis[1]) + return ( + suffix !== undefined && + (suffix === '' ? current.expressions.length === 1 : /^[/?#]/.test(suffix)) + ) + } + return false } -function isExternalUrlConstruction(node: SyntaxNode): boolean { - const prefix = getUrlConstructionPrefix(node) - return prefix !== undefined && /^https?:\/\//.test(prefix) -} +function expressionContainsUnresolvedUrlHelper( + expression: SyntaxNode, + resolver: SelfHopResolver, + seen = new Set(), + unresolvedIdentifierIsUnsafe = false +): boolean { + const current = unwrapExpression(expression) + if (hasExplicitExternalUrlPrefix(current, resolver)) return false -function functionContainsInternalRoute(fn: SyntaxNode): boolean { - const current = unwrapExpression(fn) + if (current.type === 'Identifier' && typeof current.name === 'string') { + const key = `${resolver.file}:unresolved-url:${current.name}` + if (seen.has(key)) return false + const binding = resolveScopedIdentifier(current.name, resolver) + if (!binding) return unresolvedIdentifierIsUnsafe + const nextSeen = new Set(seen) + nextSeen.add(key) + return expressionContainsUnresolvedUrlHelper( + binding.expression, + binding.resolver, + nextSeen, + unresolvedIdentifierIsUnsafe + ) + } + + if (current.type === 'ConditionalExpression') { + return ( + (isSyntaxNode(current.consequent) && + expressionContainsUnresolvedUrlHelper( + current.consequent, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + )) || + (isSyntaxNode(current.alternate) && + expressionContainsUnresolvedUrlHelper( + current.alternate, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + )) + ) + } + if (current.type === 'LogicalExpression') { + return ( + (isSyntaxNode(current.left) && + expressionContainsUnresolvedUrlHelper( + current.left, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + )) || + (isSyntaxNode(current.right) && + expressionContainsUnresolvedUrlHelper( + current.right, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + )) + ) + } if ( - !['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration'].includes(current.type) + current.type === 'BinaryExpression' && + current.operator === '+' && + isSyntaxNode(current.left) ) { - return false + if (isSimOriginExpression(current.left, resolver) && isSyntaxNode(current.right)) { + return expressionContainsUnresolvedUrlHelper(current.right, resolver, new Set(seen), true) + } + if (unresolvedIdentifierIsUnsafe && isSyntaxNode(current.right)) { + return ( + expressionContainsUnresolvedUrlHelper(current.left, resolver, new Set(seen), true) || + expressionContainsUnresolvedUrlHelper(current.right, resolver, new Set(seen), true) + ) + } + return expressionContainsUnresolvedUrlHelper( + current.left, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + ) + } + if ( + current.type === 'TemplateLiteral' && + Array.isArray(current.expressions) && + Array.isArray(current.quasis) && + current.expressions.every(isSyntaxNode) && + current.quasis.every(isSyntaxNode) && + current.expressions.length > 0 && + current.quasis.length === current.expressions.length + 1 + ) { + const leading = getTemplateQuasiValue(current.quasis[0]) + if (leading !== '') { + return ( + unresolvedIdentifierIsUnsafe && + current.expressions.some((part) => + expressionContainsUnresolvedUrlHelper(part, resolver, new Set(seen), true) + ) + ) + } + const origin = current.expressions[0] + if (isSimOriginExpression(origin, resolver)) { + const following = getTemplateQuasiValue(current.quasis[1]) + return ( + following === '' && + current.expressions.length > 1 && + expressionContainsUnresolvedUrlHelper(current.expressions[1], resolver, new Set(seen), true) + ) + } + return expressionContainsUnresolvedUrlHelper( + origin, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + ) + } + + if (current.type === 'CallExpression' || current.type === 'OptionalCallExpression') { + if (!isSyntaxNode(current.callee)) return true + const callee = unwrapExpression(current.callee) + if (callee.type === 'Identifier' && typeof callee.name === 'string') { + if ( + resolver.simOriginBindings.has(callee.name) || + resolver.simUrlBuilderBindings.has(callee.name) + ) { + return false + } + if (URL_VALUE_WRAPPER_CALLS.has(callee.name)) { + if (unresolvedIdentifierIsUnsafe && callee.name === 'encodeURIComponent') return false + const firstArgument = Array.isArray(current.arguments) + ? current.arguments.find(isSyntaxNode) + : undefined + return firstArgument + ? expressionContainsUnresolvedUrlHelper( + firstArgument, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + ) + : false + } + const key = `${resolver.file}:unresolved-url-call:${callee.name}` + if (seen.has(key)) return false + const binding = resolveScopedIdentifier(callee.name, resolver) + if (!binding || !FUNCTION_NODE_TYPES.has(unwrapExpression(binding.expression).type)) { + return true + } + const nextSeen = new Set(seen) + nextSeen.add(key) + const argumentsList = Array.isArray(current.arguments) + ? current.arguments + .filter(isSyntaxNode) + .map((argument) => resolveScopedArgument(argument, resolver)) + : [] + return functionContainsUnresolvedUrlHelper( + binding.expression, + binding.resolver, + nextSeen, + argumentsList, + unresolvedIdentifierIsUnsafe + ) + } + const access = getStaticMemberAccess(callee) + if (!access) return true + return expressionContainsUnresolvedUrlHelper( + access.target, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + ) + } + + if (current.type === 'NewExpression') { + if ( + isSyntaxNode(current.callee) && + current.callee.type === 'Identifier' && + current.callee.name === 'URL' && + Array.isArray(current.arguments) + ) { + if ( + current.arguments.length > 1 && + isSyntaxNode(current.arguments[1]) && + hasExplicitExternalUrlPrefix(current.arguments[1], resolver) + ) { + return false + } + const path = current.arguments.find(isSyntaxNode) + const base = current.arguments.length > 1 ? current.arguments[1] : undefined + if (isSyntaxNode(base)) { + return isSimOriginExpression(base, resolver) + ? Boolean( + path && expressionContainsUnresolvedUrlHelper(path, resolver, new Set(seen), true) + ) + : expressionContainsUnresolvedUrlHelper( + base, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + ) + } + return path + ? expressionContainsUnresolvedUrlHelper( + path, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + ) + : false + } + return true + } + + if (current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression') { + const access = getStaticMemberAccess(current) + return access + ? expressionContainsUnresolvedUrlHelper( + access.target, + resolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + ) + : unresolvedIdentifierIsUnsafe + } + if (FUNCTION_NODE_TYPES.has(current.type)) { + return functionContainsUnresolvedUrlHelper( + current, + resolver, + seen, + [], + unresolvedIdentifierIsUnsafe + ) } + return false +} + +function functionContainsUnresolvedUrlHelper( + fn: SyntaxNode, + resolver: SelfHopResolver, + seen: ReadonlySet, + argumentsList: readonly ScopedExpression[] = [], + unresolvedIdentifierIsUnsafe = false +): boolean { + const current = unwrapExpression(fn) + if (!FUNCTION_NODE_TYPES.has(current.type)) return true + const locals = new Map(resolver.locals) + const scopedLocals = new Map(resolver.scopedLocals) + const parameters = Array.isArray(current.params) ? current.params : [] + for (const [index, parameter] of parameters.entries()) { + if ( + isSyntaxNode(parameter) && + parameter.type === 'Identifier' && + typeof parameter.name === 'string' && + argumentsList[index] + ) { + const argument = argumentsList[index] + scopedLocals.set(parameter.name, argument) + if (argument.resolver === resolver) locals.set(parameter.name, argument.expression) + } + } + collectFunctionLocalBindings(current, locals) + const localResolver = { ...resolver, locals, scopedLocals } if (current.type === 'ArrowFunctionExpression' && isSyntaxNode(current.body)) { const body = unwrapExpression(current.body) - if (body.type !== 'BlockStatement' && isInternalPathExpression(body)) return true + if (body.type !== 'BlockStatement') { + return expressionContainsUnresolvedUrlHelper( + body, + localResolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + ) + } } - - let found = false + let unresolved = false const visit = (node: SyntaxNode) => { - if (found) return + if (unresolved || (node !== current && FUNCTION_NODE_TYPES.has(node.type))) return if ( node.type === 'ReturnStatement' && isSyntaxNode(node.argument) && - isInternalPathExpression(node.argument) + expressionContainsUnresolvedUrlHelper( + node.argument, + localResolver, + new Set(seen), + unresolvedIdentifierIsUnsafe + ) ) { - found = true - return - } - if (isInternalUrlConstruction(node)) { - found = true + unresolved = true return } for (const child of getChildNodes(node)) visit(child) } visit(current) - return found + return unresolved } -function functionContainsExternalRoute(fn: SyntaxNode): boolean { - const current = unwrapExpression(fn) +function collectImportedBindings(program: SyntaxNode): { + importedBindings: Map + simOriginBindings: Set + simUrlBuilderBindings: Set +} { + const importedBindings = new Map() + const simOriginBindings = new Set() + const simUrlBuilderBindings = new Set() + const statements = Array.isArray(program.body) ? program.body : [] + for (const statement of statements) { + if ( + !isSyntaxNode(statement) || + statement.type !== 'ImportDeclaration' || + !isSyntaxNode(statement.source) || + statement.source.type !== 'StringLiteral' || + typeof statement.source.value !== 'string' || + !Array.isArray(statement.specifiers) + ) { + continue + } + const source = statement.source.value + for (const specifier of statement.specifiers) { + if ( + !isSyntaxNode(specifier) || + specifier.type !== 'ImportSpecifier' || + !isSyntaxNode(specifier.imported) || + !isSyntaxNode(specifier.local) || + specifier.local.type !== 'Identifier' || + typeof specifier.local.name !== 'string' + ) { + continue + } + const imported = specifier.imported + const importedName = + imported.type === 'Identifier' && typeof imported.name === 'string' + ? imported.name + : imported.type === 'StringLiteral' && typeof imported.value === 'string' + ? imported.value + : undefined + if (!importedName) continue + importedBindings.set(specifier.local.name, { importedName, source }) + if (source === SIM_URLS_MODULE && SIM_ORIGIN_EXPORTS.has(importedName)) { + simOriginBindings.add(specifier.local.name) + } + if ( + (source === SIM_URLS_MODULE && SIM_URL_BUILDER_EXPORTS.has(importedName)) || + (source === EXECUTOR_HTTP_MODULE && EXECUTOR_URL_BUILDER_EXPORTS.has(importedName)) + ) { + simUrlBuilderBindings.add(specifier.local.name) + } + } + } + return { importedBindings, simOriginBindings, simUrlBuilderBindings } +} + +function collectTopLevelBindings(program: SyntaxNode): Map { + const bindings = new Map() + const statements = Array.isArray(program.body) ? program.body : [] + for (const statement of statements) { + if (!isSyntaxNode(statement)) continue + const declaration = + statement.type === 'ExportNamedDeclaration' && isSyntaxNode(statement.declaration) + ? statement.declaration + : statement + if ( + declaration.type === 'FunctionDeclaration' && + isSyntaxNode(declaration.id) && + declaration.id.type === 'Identifier' && + typeof declaration.id.name === 'string' + ) { + bindings.set(declaration.id.name, declaration) + continue + } + if (declaration.type !== 'VariableDeclaration' || !Array.isArray(declaration.declarations)) { + continue + } + for (const variable of declaration.declarations) { + if ( + isSyntaxNode(variable) && + variable.type === 'VariableDeclarator' && + isSyntaxNode(variable.id) && + variable.id.type === 'Identifier' && + typeof variable.id.name === 'string' && + isSyntaxNode(variable.init) + ) { + bindings.set(variable.id.name, variable.init) + } + } + } + return bindings +} + +const MODULE_RESOLVER_CACHE = new Map() + +function parseProgram(source: string, file: string): SyntaxNode { + const extension = extname(file) + return parse(source, { + sourceFilename: file, + sourceType: 'unambiguous', + errorRecovery: true, + plugins: [ + ...(extension === '.jsx' || extension === '.tsx' ? (['jsx'] as const) : []), + ...(!['.js', '.jsx', '.mjs', '.cjs'].includes(extension) ? (['typescript'] as const) : []), + ], + }).program +} + +function createSelfHopResolver(program: SyntaxNode, file: string): SelfHopResolver { + return { + bindings: collectTopLevelBindings(program), + ...collectImportedBindings(program), + file, + } +} + +function resolveImportFile(importerFile: string, source: string): string | undefined { + if (!source.startsWith('@/') && !source.startsWith('./') && !source.startsWith('../')) { + return undefined + } + const base = source.startsWith('@/') + ? join(APP, source.slice(2)) + : resolve(dirname(importerFile), source) + const candidates = [ + base, + ...[...SOURCE_EXTENSIONS].map((extension) => `${base}${extension}`), + ...[...SOURCE_EXTENSIONS].map((extension) => join(base, `index${extension}`)), + ] + return candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile()) +} + +function loadImportedBinding( + name: string, + resolver: SelfHopResolver +): { expression: SyntaxNode; resolver: SelfHopResolver } | undefined { + const imported = resolver.importedBindings.get(name) + if (!imported) return undefined + const importedFile = resolveImportFile(resolver.file, imported.source) + if (!importedFile) return undefined + let importedResolver = MODULE_RESOLVER_CACHE.get(importedFile) + if (!importedResolver) { + importedResolver = createSelfHopResolver( + parseProgram(readFileSync(importedFile, 'utf8'), importedFile), + importedFile + ) + MODULE_RESOLVER_CACHE.set(importedFile, importedResolver) + } + const expression = importedResolver.bindings.get(imported.importedName) + return expression ? { expression, resolver: importedResolver } : undefined +} + +function expressionContainsInternalRoute( + expression: SyntaxNode, + resolver: SelfHopResolver, + seen = new Set() +): boolean { + const current = unwrapExpression(expression) if ( - !['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration'].includes(current.type) + isInternalPathExpression(current, resolver) || + isInternalUrlConstruction(current, resolver) || + isSameOriginConcatenation(current, resolver) || + isSameOriginTemplate(current, resolver) ) { + return true + } + + if (current.type === 'Identifier' && typeof current.name === 'string') { + const key = `${resolver.file}:route:${current.name}` + if (seen.has(key)) return false + const binding = resolveScopedIdentifier(current.name, resolver) + if (!binding) return false + const nextSeen = new Set(seen) + nextSeen.add(key) + return expressionContainsInternalRoute(binding.expression, binding.resolver, nextSeen) + } + + if ( + current.type === 'CallExpression' || + current.type === 'OptionalCallExpression' || + current.type === 'NewExpression' + ) { + if (!isSyntaxNode(current.callee)) return false + const callee = unwrapExpression(current.callee) + if (callee.type === 'Identifier') { + if (isKnownSimUrlBuilderCall(current, resolver)) return true + const binding = + typeof callee.name === 'string' ? resolveScopedIdentifier(callee.name, resolver) : undefined + if (binding && FUNCTION_NODE_TYPES.has(unwrapExpression(binding.expression).type)) { + const key = `${resolver.file}:route-call:${callee.name}` + if (seen.has(key)) return false + const nextSeen = new Set(seen) + nextSeen.add(key) + const argumentsList = Array.isArray(current.arguments) + ? current.arguments + .filter(isSyntaxNode) + .map((argument) => resolveScopedArgument(argument, resolver)) + : [] + return functionContainsInternalRoute( + binding.expression, + binding.resolver, + nextSeen, + argumentsList + ) + } + return expressionContainsInternalRoute(callee, resolver, seen) + } + const access = getStaticMemberAccess(callee) + return access ? expressionContainsInternalRoute(access.target, resolver, seen) : false + } + + if (current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression') { + const access = getStaticMemberAccess(current) + return access ? expressionContainsInternalRoute(access.target, resolver, seen) : false + } + + if (FUNCTION_NODE_TYPES.has(current.type)) { + return functionContainsInternalRoute(current, resolver, seen) + } + return false +} + +function functionContainsInternalRoute( + fn: SyntaxNode, + resolver: SelfHopResolver, + seen: ReadonlySet, + argumentsList: readonly ScopedExpression[] = [] +): boolean { + const current = unwrapExpression(fn) + if (!FUNCTION_NODE_TYPES.has(current.type)) { return false } + const locals = new Map(resolver.locals) + const scopedLocals = new Map(resolver.scopedLocals) + const parameters = Array.isArray(current.params) ? current.params : [] + for (const [index, parameter] of parameters.entries()) { + if ( + isSyntaxNode(parameter) && + parameter.type === 'Identifier' && + typeof parameter.name === 'string' && + argumentsList[index] + ) { + const argument = argumentsList[index] + scopedLocals.set(parameter.name, argument) + if (argument.resolver === resolver) locals.set(parameter.name, argument.expression) + } + } + const localResolver: SelfHopResolver = { ...resolver, locals, scopedLocals } + + collectFunctionLocalBindings(current, locals) + if (current.type === 'ArrowFunctionExpression' && isSyntaxNode(current.body)) { const body = unwrapExpression(current.body) - if (body.type !== 'BlockStatement' && isExternalUrlExpression(body)) return true + if ( + body.type !== 'BlockStatement' && + expressionContainsInternalRoute(body, localResolver, new Set(seen)) + ) { + return true + } } let found = false const visit = (node: SyntaxNode) => { - if (found) return + if (found || (node !== current && FUNCTION_NODE_TYPES.has(node.type))) return if ( node.type === 'ReturnStatement' && isSyntaxNode(node.argument) && - isExternalUrlExpression(node.argument) + expressionContainsInternalRoute(node.argument, localResolver, new Set(seen)) ) { found = true return } - if (isExternalUrlConstruction(node)) { + if (isInternalUrlConstruction(node, localResolver)) { found = true return } @@ -288,298 +1279,460 @@ function functionContainsExternalRoute(fn: SyntaxNode): boolean { return found } -function collectBindingIdentifiers(pattern: SyntaxNode, bindings: Set): void { - const current = unwrapExpression(pattern) - if (current.type === 'Identifier' && typeof current.name === 'string') { - bindings.add(current.name) - return - } - if (current.type === 'AssignmentPattern' && isSyntaxNode(current.left)) { - collectBindingIdentifiers(current.left, bindings) - return - } - if (current.type === 'RestElement' && isSyntaxNode(current.argument)) { - collectBindingIdentifiers(current.argument, bindings) - return - } - if (current.type === 'TSParameterProperty' && isSyntaxNode(current.parameter)) { - collectBindingIdentifiers(current.parameter, bindings) - return - } - if (current.type === 'ObjectPattern' && Array.isArray(current.properties)) { - for (const property of current.properties) { - if (!isSyntaxNode(property)) continue - if (property.type === 'RestElement' && isSyntaxNode(property.argument)) { - collectBindingIdentifiers(property.argument, bindings) - } else if (property.type === 'ObjectProperty' && isSyntaxNode(property.value)) { - collectBindingIdentifiers(property.value, bindings) - } - } - return - } - if (current.type === 'ArrayPattern' && Array.isArray(current.elements)) { - for (const element of current.elements) { - if (isSyntaxNode(element)) collectBindingIdentifiers(element, bindings) - } - } +function resolveStaticStringExpression( + expression: SyntaxNode, + resolver: SelfHopResolver, + seen = new Set() +): string | undefined { + const value = unwrapExpression(expression) + const staticValue = getStaticString(value) + if (staticValue !== undefined) return staticValue + if (value.type !== 'Identifier' || typeof value.name !== 'string') return undefined + const key = `${resolver.file}:static-string:${value.name}` + if (seen.has(key)) return undefined + const binding = resolveScopedIdentifier(value.name, resolver) + if (!binding) return undefined + const nextSeen = new Set(seen) + nextSeen.add(key) + return resolveStaticStringExpression(binding.expression, binding.resolver, nextSeen) } -function getFunctionParameterBindings(fn: SyntaxNode): Set { - const bindings = new Set() - const current = unwrapExpression(fn) - if (!Array.isArray(current.params)) return bindings - for (const param of current.params) { - if (isSyntaxNode(param)) collectBindingIdentifiers(param, bindings) - } - return bindings +function getToolId(object: SyntaxNode, resolver: SelfHopResolver): string | undefined { + const idProperty = getObjectProperty(object, 'id') + if (!idProperty || !isSyntaxNode(idProperty.value)) return undefined + return resolveStaticStringExpression(idProperty.value, resolver) } -function containsParameterReference( - expression: SyntaxNode, - parameterBindings: ReadonlySet -): boolean { - const current = unwrapExpression(expression) - if ( - current.type === 'Identifier' && - typeof current.name === 'string' && - parameterBindings.has(current.name) - ) { - return true - } - return getChildNodes(current).some((child) => - containsParameterReference(child, parameterBindings) - ) +interface ScopedExpression { + expression: SyntaxNode + resolver: SelfHopResolver } -function isEncodedPathExpression(expression: SyntaxNode): boolean { - const current = unwrapExpression(expression) - return ( - current.type === 'CallExpression' && - isSyntaxNode(current.callee) && - current.callee.type === 'Identifier' && - current.callee.name === 'encodeURIComponent' - ) +interface ResolvedRequestObject extends ScopedExpression { + locals: ReadonlyMap } -function getConcatenationParts(expression: SyntaxNode): SyntaxNode[] { - const current = unwrapExpression(expression) - if ( - current.type !== 'BinaryExpression' || - current.operator !== '+' || - !isSyntaxNode(current.left) || - !isSyntaxNode(current.right) - ) { - return [current] - } - return [...getConcatenationParts(current.left), ...getConcatenationParts(current.right)] +interface ResolvedObjectProperty { + property: SyntaxNode + request: ResolvedRequestObject } -function templateElementContainsQuery(element: unknown): boolean { - if (!isSyntaxNode(element)) return false - const value = element.value - return ( - typeof value === 'object' && - value !== null && - (('cooked' in value && typeof value.cooked === 'string' && value.cooked.includes('?')) || - ('raw' in value && typeof value.raw === 'string' && value.raw.includes('?'))) - ) +interface RequestObjectResolution { + requests: ResolvedRequestObject[] + complete: boolean +} + +interface ObjectPropertyResolution { + properties: Array + complete: boolean } -function inspectTemplatePathExpressions( - template: SyntaxNode, - parameterBindings: ReadonlySet, - initialQueryStarted = false -): { queryStarted: boolean; unsafe: boolean } { - if (!Array.isArray(template.quasis) || !Array.isArray(template.expressions)) { - return { queryStarted: initialQueryStarted, unsafe: false } +function combineRequestResolutions( + resolutions: readonly RequestObjectResolution[] +): RequestObjectResolution { + return { + requests: resolutions.flatMap((resolution) => resolution.requests), + complete: resolutions.every((resolution) => resolution.complete), } +} - let queryStarted = initialQueryStarted - for (let index = 0; index < template.expressions.length; index++) { - if (templateElementContainsQuery(template.quasis[index])) queryStarted = true - const expression = template.expressions[index] +function resolveScopedBinding( + name: string, + resolver: SelfHopResolver, + locals: ReadonlyMap +): ScopedExpression | undefined { + const local = locals.get(name) + if (local) return local + const binding = resolver.bindings.get(name) + if (binding) return { expression: binding, resolver } + return loadImportedBinding(name, resolver) +} + +function collectFunctionLocals( + fn: SyntaxNode, + resolver: SelfHopResolver, + argumentsList: readonly ScopedExpression[] +): Map { + const locals = new Map() + const parameters = Array.isArray(fn.params) ? fn.params : [] + for (const [index, parameter] of parameters.entries()) { if ( - !queryStarted && - isSyntaxNode(expression) && - containsParameterReference(expression, parameterBindings) && - !isEncodedPathExpression(expression) + isSyntaxNode(parameter) && + parameter.type === 'Identifier' && + typeof parameter.name === 'string' && + argumentsList[index] ) { - return { queryStarted, unsafe: true } + locals.set(parameter.name, argumentsList[index]) } } - if (templateElementContainsQuery(template.quasis[template.expressions.length])) { - queryStarted = true + const collect = (node: SyntaxNode) => { + if (node !== fn && FUNCTION_NODE_TYPES.has(node.type)) { + return + } + if ( + node.type === 'VariableDeclarator' && + isSyntaxNode(node.id) && + node.id.type === 'Identifier' && + typeof node.id.name === 'string' && + isSyntaxNode(node.init) + ) { + locals.set(node.id.name, { expression: node.init, resolver }) + } + for (const child of getChildNodes(node)) collect(child) } - return { queryStarted, unsafe: false } + collect(fn) + return locals } -function functionContainsUnsafeInternalPathInterpolation(fn: SyntaxNode): boolean { - const current = unwrapExpression(fn) - const parameterBindings = getFunctionParameterBindings(current) - let found = false +function resolveRequestObjects( + scoped: ScopedExpression, + locals: ReadonlyMap = new Map(), + seen = new Set() +): RequestObjectResolution { + const current = unwrapExpression(scoped.expression) + if (current.type === 'ObjectExpression') { + return { + requests: [{ expression: current, resolver: scoped.resolver, locals }], + complete: true, + } + } - const visit = (node: SyntaxNode) => { - if (found) return - if ( - node.type === 'BinaryExpression' && - node.operator === '+' && - isInternalPathExpression(node) - ) { - let queryStarted = false - for (const part of getConcatenationParts(node)) { - const currentPart = unwrapExpression(part) - if (currentPart.type === 'TemplateLiteral') { - const inspected = inspectTemplatePathExpressions( - currentPart, - parameterBindings, - queryStarted - ) - if (inspected.unsafe) { - found = true - return - } - queryStarted = inspected.queryStarted + if (current.type === 'Identifier' && typeof current.name === 'string') { + const key = `${scoped.resolver.file}:binding:${current.name}` + if (seen.has(key)) return { requests: [], complete: false } + const binding = resolveScopedBinding(current.name, scoped.resolver, locals) + if (!binding) return { requests: [], complete: false } + const nextSeen = new Set(seen) + nextSeen.add(key) + return resolveRequestObjects(binding, locals, nextSeen) + } + + if (current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression') { + const access = getStaticMemberAccess(current) + if (!access) return { requests: [], complete: false } + const targets = resolveRequestObjects( + { expression: access.target, resolver: scoped.resolver }, + locals, + new Set(seen) + ) + const resolutions: RequestObjectResolution[] = [] + let complete = targets.complete + for (const target of targets.requests) { + const properties = getResolvedObjectProperties(target, access.member, new Set(seen)) + complete &&= properties.complete + for (const resolved of properties.properties) { + if (!resolved || !isSyntaxNode(resolved.property.value)) { + complete = false continue } - const prefix = getStringPrefix(part) - if (prefix?.includes('?')) queryStarted = true - if ( - !queryStarted && - containsParameterReference(part, parameterBindings) && - !isEncodedPathExpression(part) - ) { - found = true - return - } + resolutions.push( + resolveRequestObjects( + { expression: resolved.property.value, resolver: resolved.request.resolver }, + resolved.request.locals, + new Set(seen) + ) + ) } } - if ( - node.type === 'TemplateLiteral' && - Array.isArray(node.quasis) && - Array.isArray(node.expressions) && - node.quasis.length > 0 && - isSyntaxNode(node.quasis[0]) && - getStringPrefix(node)?.startsWith('/api/') - ) { - if (inspectTemplatePathExpressions(node, parameterBindings).unsafe) { - found = true - return - } + const combined = combineRequestResolutions(resolutions) + return { requests: combined.requests, complete: complete && combined.complete } + } + + if (current.type === 'ConditionalExpression') { + return combineRequestResolutions( + [current.consequent, current.alternate] + .filter(isSyntaxNode) + .map((branch) => + resolveRequestObjects( + { expression: branch, resolver: scoped.resolver }, + locals, + new Set(seen) + ) + ) + ) + } + + if (current.type === 'LogicalExpression') { + return combineRequestResolutions( + [current.left, current.right] + .filter(isSyntaxNode) + .map((branch) => + resolveRequestObjects( + { expression: branch, resolver: scoped.resolver }, + locals, + new Set(seen) + ) + ) + ) + } + + if (current.type !== 'CallExpression' && current.type !== 'OptionalCallExpression') { + return { requests: [], complete: false } + } + if (!isSyntaxNode(current.callee)) return { requests: [], complete: false } + const callee = unwrapExpression(current.callee) + if (callee.type !== 'Identifier' || typeof callee.name !== 'string') { + return { requests: [], complete: false } + } + const key = `${scoped.resolver.file}:call:${callee.name}` + if (seen.has(key)) return { requests: [], complete: false } + const binding = resolveScopedBinding(callee.name, scoped.resolver, locals) + if (!binding) return { requests: [], complete: false } + const fn = unwrapExpression(binding.expression) + if (!FUNCTION_NODE_TYPES.has(fn.type)) { + return { requests: [], complete: false } + } + const argumentsList = Array.isArray(current.arguments) + ? current.arguments + .filter(isSyntaxNode) + .map((argument) => resolveScopedArgument(argument, scoped.resolver)) + : [] + const functionLocals = collectFunctionLocals(fn, binding.resolver, argumentsList) + const nextSeen = new Set(seen) + nextSeen.add(key) + if (fn.type === 'ArrowFunctionExpression' && isSyntaxNode(fn.body)) { + const body = unwrapExpression(fn.body) + if (body.type !== 'BlockStatement') { + return resolveRequestObjects( + { expression: body, resolver: binding.resolver }, + functionLocals, + nextSeen + ) + } + } + const results: ResolvedRequestObject[] = [] + let complete = true + let returnCount = 0 + const visit = (node: SyntaxNode) => { + if (node !== fn && FUNCTION_NODE_TYPES.has(node.type)) return + if (node.type === 'ReturnStatement' && isSyntaxNode(node.argument)) { + returnCount += 1 + const resolution = resolveRequestObjects( + { expression: node.argument, resolver: binding.resolver }, + functionLocals, + new Set(nextSeen) + ) + results.push(...resolution.requests) + complete &&= resolution.complete + return } for (const child of getChildNodes(node)) visit(child) } + visit(fn) + return { requests: results, complete: complete && returnCount > 0 } +} - visit(current) - return found +function requestObjectResolver(request: ResolvedRequestObject): SelfHopResolver { + const locals = new Map() + for (const [name, value] of request.locals) { + const current = unwrapExpression(value.expression) + if ( + value.resolver === request.resolver || + (current.type === 'StringLiteral' && typeof current.value === 'string') + ) { + locals.set(name, current) + } + } + return { ...request.resolver, locals, scopedLocals: request.locals } } -function getToolId(object: SyntaxNode): string | undefined { - const idProperty = getObjectProperty(object, 'id') - if (!idProperty || !isSyntaxNode(idProperty.value)) return undefined - const value = unwrapExpression(idProperty.value) - return value.type === 'StringLiteral' && typeof value.value === 'string' ? value.value : undefined +function getResolvedObjectProperties( + request: ResolvedRequestObject, + name: string, + seen = new Set(), + endIndex?: number +): ObjectPropertyResolution { + if (!Array.isArray(request.expression.properties)) { + return { properties: [], complete: false } + } + const properties = request.expression.properties.filter(isSyntaxNode) + const lastIndex = endIndex ?? properties.length - 1 + for (let index = lastIndex; index >= 0; index -= 1) { + const property = properties[index] + if (property.type === 'ObjectProperty' || property.type === 'ObjectMethod') { + const propertyName = getStaticPropertyName(property) + if (propertyName === name) { + return { properties: [{ property, request }], complete: true } + } + if (property.computed === true && propertyName === undefined) { + const earlier = getResolvedObjectProperties(request, name, seen, index - 1) + return { properties: earlier.properties, complete: false } + } + } + if (property.type !== 'SpreadElement' || !isSyntaxNode(property.argument)) continue + const key = `${request.resolver.file}:spread:${property.start ?? index}:${name}` + if (seen.has(key)) return { properties: [], complete: false } + const nextSeen = new Set(seen) + nextSeen.add(key) + const spreadResolution = resolveRequestObjects( + { expression: property.argument, resolver: request.resolver }, + request.locals, + nextSeen + ) + const resolved: Array = [] + let complete = spreadResolution.complete + const fallback = () => getResolvedObjectProperties(request, name, nextSeen, index - 1) + if (spreadResolution.requests.length === 0) { + const earlier = fallback() + return { properties: earlier.properties, complete: false } + } + for (const spreadRequest of spreadResolution.requests) { + const spreadProperties = getResolvedObjectProperties(spreadRequest, name, nextSeen) + complete &&= spreadProperties.complete + for (const spreadProperty of spreadProperties.properties) { + if (spreadProperty) { + resolved.push(spreadProperty) + continue + } + const earlier = fallback() + resolved.push(...earlier.properties) + complete &&= earlier.complete + } + } + return { properties: resolved, complete } + } + return { properties: [undefined], complete: true } } -/** Audits dynamic tool URL builders for an explicit, definition-owned internal-route policy. */ -export function auditToolRequestTrust(source: string, file = 'source.ts'): RequestTrustAudit { - const extension = extname(file) - const syntaxTree = parse(source, { - sourceFilename: file, - sourceType: 'unambiguous', - errorRecovery: true, - plugins: [ - ...(extension === '.jsx' || extension === '.tsx' ? (['jsx'] as const) : []), - ...(!['.js', '.jsx', '.mjs', '.cjs'].includes(extension) ? (['typescript'] as const) : []), - ], - }) - const violations: RequestTrustViolation[] = [] - let dynamicInternalRoutes = 0 - let dynamicInternalPolicies = 0 +/** Rejects tool definitions that route execution back through this Sim app. */ +export function auditToolSelfHops(source: string, file = 'source.ts'): ToolSelfHopAudit { + const program = parseProgram(source, file) + const violations: ToolSelfHopViolation[] = [] + let detectedSelfHops = 0 + let legacyInternalPolicies = 0 + const resolver = createSelfHopResolver(program, file) const visit = (node: SyntaxNode) => { if (node.type === 'ObjectExpression') { - const requestProperty = getObjectProperty(node, 'request') - if (requestProperty && isSyntaxNode(requestProperty.value)) { - const request = unwrapExpression(requestProperty.value) - const urlProperty = getObjectProperty(request, 'url') - const internalProperty = getObjectProperty(request, 'internal') - const url = urlProperty?.value - const isDynamic = - isSyntaxNode(url) && - ['ArrowFunctionExpression', 'FunctionExpression'].includes(unwrapExpression(url).type) - if (isDynamic && isSyntaxNode(url)) { - const hasInternalRoute = functionContainsInternalRoute(url) - const hasExternalRoute = functionContainsExternalRoute(url) - const hasInternalPolicy = internalProperty !== undefined - const internalPolicyValue = internalProperty?.value - const internalPolicyType = isSyntaxNode(internalPolicyValue) - ? unwrapExpression(internalPolicyValue).type - : undefined - const hasStaticInternalPolicy = - internalPolicyType === 'BooleanLiteral' && - isSyntaxNode(internalPolicyValue) && - unwrapExpression(internalPolicyValue).value === true - const hasConditionalInternalPolicy = - internalPolicyType === 'ArrowFunctionExpression' || - internalPolicyType === 'FunctionExpression' || - internalPolicyType === 'Identifier' - const hasValidInternalPolicy = hasStaticInternalPolicy || hasConditionalInternalPolicy - const hasUnsafeInternalPathInterpolation = - functionContainsUnsafeInternalPathInterpolation(url) - if (hasInternalRoute) dynamicInternalRoutes += 1 - if (hasInternalPolicy) dynamicInternalPolicies += 1 - if (hasInternalPolicy && !hasValidInternalPolicy) { - violations.push({ - file, - line: internalProperty?.loc?.start.line ?? requestProperty.loc?.start.line ?? 1, - toolId: getToolId(node), - reason: 'invalid-internal-policy', - }) - } else if (hasInternalRoute && !hasInternalPolicy) { - violations.push({ - file, - line: urlProperty?.loc?.start.line ?? requestProperty.loc?.start.line ?? 1, - toolId: getToolId(node), - reason: 'missing-internal-policy', - }) - } else if (hasStaticInternalPolicy && hasExternalRoute && !hasInternalRoute) { - const location = (hasInternalPolicy ? internalProperty : urlProperty)?.loc?.start.line - violations.push({ - file, - line: location ?? requestProperty.loc?.start.line ?? 1, - toolId: getToolId(node), - reason: 'internal-policy-without-internal-route', - }) + const idProperty = getObjectProperty(node, 'id') + const toolId = idProperty ? getToolId(node, resolver) : undefined + const directRequestProperty = getObjectProperty(node, 'request') + if (idProperty && (toolId !== undefined || directRequestProperty)) { + const toolObject: ResolvedRequestObject = { + expression: node, + resolver, + locals: new Map(), + } + const requestProperties = getResolvedObjectProperties(toolObject, 'request') + const concreteRequestProperties = requestProperties.properties.filter( + (property): property is ResolvedObjectProperty => property !== undefined + ) + let unresolvedReported = false + const reportUnresolved = (line: number) => { + if (unresolvedReported) return + unresolvedReported = true + violations.push({ + file, + line, + toolId, + reason: 'unresolved-request-policy', + }) + } + if (!requestProperties.complete) { + reportUnresolved(node.loc?.start.line ?? 1) + } + for (const { + property: requestProperty, + request: requestOwner, + } of concreteRequestProperties) { + if (!isSyntaxNode(requestProperty.value)) { + reportUnresolved(requestProperty.loc?.start.line ?? 1) + continue } - if ( - hasValidInternalPolicy && - hasInternalRoute && - hasExternalRoute && - !hasConditionalInternalPolicy - ) { - violations.push({ - file, - line: internalProperty?.loc?.start.line ?? requestProperty.loc?.start.line ?? 1, - toolId: getToolId(node), - reason: 'mixed-route-requires-conditional-policy', - }) + const requests = resolveRequestObjects( + { expression: requestProperty.value, resolver: requestOwner.resolver }, + requestOwner.locals + ) + if (!requests.complete || requests.requests.length === 0) { + reportUnresolved(requestProperty.loc?.start.line ?? 1) } - if (hasInternalRoute && hasUnsafeInternalPathInterpolation) { - violations.push({ - file, - line: urlProperty?.loc?.start.line ?? requestProperty.loc?.start.line ?? 1, - toolId: getToolId(node), - reason: 'unsafe-internal-path-interpolation', - }) + for (const request of requests.requests) { + const urlProperties = getResolvedObjectProperties(request, 'url') + const internalProperties = getResolvedObjectProperties(request, 'internal') + const allowSameOriginProperties = getResolvedObjectProperties( + request, + 'allowSameOrigin' + ) + let hasLegacyInternalPolicy = false + if ( + !urlProperties.complete || + !internalProperties.complete || + !allowSameOriginProperties.complete + ) { + reportUnresolved(requestProperty.loc?.start.line ?? 1) + } + for (const resolvedInternal of internalProperties.properties) { + if (!resolvedInternal) continue + const internalProperty = resolvedInternal.property + hasLegacyInternalPolicy = true + legacyInternalPolicies += 1 + violations.push({ + file, + line: internalProperty.loc?.start.line ?? requestProperty.loc?.start.line ?? 1, + toolId, + reason: 'legacy-internal-policy', + }) + } + for (const resolvedPolicy of allowSameOriginProperties.properties) { + if (!resolvedPolicy) continue + const policyProperty = resolvedPolicy.property + const policyValue = + policyProperty.type === 'ObjectProperty' && isSyntaxNode(policyProperty.value) + ? unwrapExpression(policyProperty.value) + : undefined + if (!policyValue || policyValue.type !== 'BooleanLiteral') { + reportUnresolved( + policyProperty.loc?.start.line ?? requestProperty.loc?.start.line ?? 1 + ) + continue + } + if (policyValue.value === true && !APPROVED_SAME_ORIGIN_TOOL_IDS.has(toolId)) { + violations.push({ + file, + line: policyProperty.loc?.start.line ?? requestProperty.loc?.start.line ?? 1, + toolId, + reason: 'unapproved-same-origin-policy', + }) + } + } + for (const resolvedUrl of urlProperties.properties) { + if (!resolvedUrl) continue + const { property: urlProperty, request: urlRequest } = resolvedUrl + const urlExpression = + urlProperty.type === 'ObjectMethod' + ? urlProperty + : isSyntaxNode(urlProperty.value) + ? urlProperty.value + : undefined + if (!urlExpression) continue + const currentUrl = unwrapExpression(urlExpression) + const urlResolver = requestObjectResolver(urlRequest) + if (expressionContainsInternalRoute(currentUrl, urlResolver)) { + detectedSelfHops += 1 + violations.push({ + file, + line: urlProperty.loc?.start.line ?? requestProperty.loc?.start.line ?? 1, + toolId, + reason: 'same-origin-tool-request', + }) + } else if ( + !hasLegacyInternalPolicy && + expressionContainsUnresolvedUrlHelper(currentUrl, urlResolver) + ) { + reportUnresolved( + urlProperty.loc?.start.line ?? requestProperty.loc?.start.line ?? 1 + ) + } + } } } } } for (const child of getChildNodes(node)) visit(child) } - visit(syntaxTree.program) + visit(program) - return { violations, dynamicInternalRoutes, dynamicInternalPolicies } + return { violations, detectedSelfHops, legacyInternalPolicies } } function getStaticMemberAccess( @@ -744,10 +1897,10 @@ function main(): void { const violations = productionSources .filter((file) => file !== CANONICAL_TRANSPORT) .flatMap((file) => findToolRequestBoundaryViolations(readFileSync(file, 'utf8'), file)) - const requestTrustAudits = productionSources + const selfHopAudits = productionSources .filter((file) => file.startsWith(join(APP, 'tools'))) - .map((file) => auditToolRequestTrust(readFileSync(file, 'utf8'), file)) - const requestTrustViolations = requestTrustAudits.flatMap((audit) => audit.violations) + .map((file) => auditToolSelfHops(readFileSync(file, 'utf8'), file)) + const selfHopViolations = selfHopAudits.flatMap((audit) => audit.violations) if (violations.length > 0) { console.error('Direct ToolConfig request execution is forbidden outside the shared transport:') @@ -760,19 +1913,15 @@ function main(): void { process.exit(1) } - if (requestTrustViolations.length > 0) { - console.error('Dynamic tool routes have an invalid internal trust declaration:') - for (const violation of requestTrustViolations) { + if (selfHopViolations.length > 0) { + console.error('Tool definitions must not execute through same-origin Sim API routes:') + for (const violation of selfHopViolations) { const description = - violation.reason === 'missing-internal-policy' - ? 'dynamic /api route is missing request.internal' - : violation.reason === 'invalid-internal-policy' - ? 'request.internal must be true or a predicate function' - : violation.reason === 'internal-policy-without-internal-route' - ? 'request.internal is declared but the URL builder has no /api route' - : violation.reason === 'mixed-route-requires-conditional-policy' - ? 'mixed internal/external URL builder requires a predicate request.internal policy' - : 'dynamic /api path parameter must use encodeURIComponent' + violation.reason === 'same-origin-tool-request' + ? 'replace the /api self-hop with InternalToolConfig.operation and a registered server handler' + : violation.reason === 'legacy-internal-policy' + ? 'request.internal is obsolete; use InternalToolConfig.operation for in-process work' + : 'request configuration could not be audited; keep it in a statically resolvable local helper' console.error( ` ${relative(ROOT, violation.file)}:${violation.line} ${violation.toolId ?? 'unknown tool'}: ${description}` ) @@ -781,16 +1930,13 @@ function main(): void { } console.log('✓ production tool requests are materialized only by the shared transport') - const dynamicInternalRoutes = requestTrustAudits.reduce( - (total, audit) => total + audit.dynamicInternalRoutes, - 0 - ) - const dynamicInternalPolicies = requestTrustAudits.reduce( - (total, audit) => total + audit.dynamicInternalPolicies, + const detectedSelfHops = selfHopAudits.reduce((total, audit) => total + audit.detectedSelfHops, 0) + const legacyInternalPolicies = selfHopAudits.reduce( + (total, audit) => total + audit.legacyInternalPolicies, 0 ) console.log( - `✓ ${dynamicInternalRoutes} directly detectable dynamic internal routes declare trust (${dynamicInternalPolicies} explicit dynamic policies)` + `✓ no tool self-hops detected (${detectedSelfHops} same-origin requests, ${legacyInternalPolicies} legacy internal policies)` ) } diff --git a/scripts/fixtures/check-tool-request-boundary/request-factory.ts b/scripts/fixtures/check-tool-request-boundary/request-factory.ts new file mode 100644 index 00000000000..ae0550b3a60 --- /dev/null +++ b/scripts/fixtures/check-tool-request-boundary/request-factory.ts @@ -0,0 +1,6 @@ +export function createRequest(host: string) { + return { + url: () => `${host}/api/tools/test`, + method: 'POST', + } +}