fix(executor): restore delegated workflow execution (#6539)

* fix(executor): restore delegated workflow execution

* fix(executor): trust custom block execution scope

* fix(providers): keep tool parameters type safe
This commit is contained in:
Theodore Li
2026-08-11 07:12:16 -04:00
committed by GitHub
parent 7c75061d6a
commit 783e1b542c
19 changed files with 613 additions and 19 deletions
@@ -429,3 +429,26 @@ describe('DAGExecutor createExecutionContext useDraftState', () => {
expect(buildMetadataUseDraftState({ isDeployedContext: false })).toBe(true)
})
})
describe('DAGExecutor executor delegation origin', () => {
it('copies the canonical origin into the runtime execution context', () => {
const executorDelegationOrigin = {
subjectUserId: 'user-1',
workflowId: 'parent-workflow',
executionId: 'parent-execution',
}
const executor = new DAGExecutor({
workflow: { version: '1', blocks: [], connections: [] },
contextExtensions: { executorDelegationOrigin },
})
const { context } = (
executor as unknown as {
createExecutionContext: (workflowId: string) => { context: ExecutionContext }
}
).createExecutionContext('child-workflow')
expect(context.workflowId).toBe('child-workflow')
expect(context.executorDelegationOrigin).toBe(executorDelegationOrigin)
})
})
+1
View File
@@ -421,6 +421,7 @@ export class DAGExecutor {
fileKeys: this.contextExtensions.fileKeys,
allowLargeValueWorkflowScope: this.contextExtensions.allowLargeValueWorkflowScope,
userId: this.contextExtensions.userId,
executorDelegationOrigin: this.contextExtensions.executorDelegationOrigin,
isDeployedContext: this.contextExtensions.isDeployedContext,
enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess,
piiBlockOutputRedaction: this.contextExtensions.piiBlockOutputRedaction,
+3
View File
@@ -6,6 +6,7 @@ import type { NodeMetadata } from '@/executor/dag/types'
import type {
BlockLog,
BlockState,
ExecutorDelegationOrigin,
NormalizedBlockOutput,
StartBlockRunMetadata,
StreamingExecution,
@@ -231,6 +232,8 @@ export interface ContextExtensions {
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
/** Canonical signed execution identity inherited by regular nested workflows. */
executorDelegationOrigin?: ExecutorDelegationOrigin
/**
* Immutable actor/payer decision for this execution. Child workflow
* executions receive it here (they carry no full metadata), so internal
@@ -27,7 +27,10 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr
import { executeProviderRequest } from '@/providers'
import { installStreamingCostPolicy } from '@/providers/cost-policy'
import { SIM_AUTO_MODEL_ID } from '@/providers/models'
import { getProviderToolInputProvenance } from '@/providers/tool-input-provenance'
import {
getProviderToolInputProvenance,
getProviderToolModelInputRegistry,
} from '@/providers/tool-input-provenance'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -1155,6 +1158,61 @@ describe('AgentBlockHandler', () => {
expect(runtimeContext.resolvedSecretTraceRegistry.getActiveMatches()).toEqual([])
})
it('binds prompt-exposed placeholders to each provider tool for runtime rebinding', async () => {
const registry = new ResolvedSecretTraceRegistry([
{
name: 'TEST_API_KEY_PERSONAL',
plaintext: 'personal-secret-value',
encryptedValue: 'encrypted-personal-secret',
},
])
registry.recordResolvedAtInputPath('TEST_API_KEY_PERSONAL', 'personal-secret-value', [
'userPrompt',
])
registry.recordResolvedInputProjection(
['userPrompt'],
'Use personal-secret-value',
'Use {{TEST_API_KEY_PERSONAL}}'
)
mockContext.resolvedSecretTraceRegistry = registry
await handler.execute(mockContext, mockBlock, {
model: 'gpt-4o',
userPrompt: 'Use personal-secret-value',
tools: [
{
type: 'custom-tool',
title: 'canary',
schema: {
function: {
name: 'canary',
parameters: {
type: 'object',
properties: { secret: { type: 'string' } },
required: ['secret'],
},
},
},
},
],
})
const [, providerRequest] = mockExecuteProviderRequest.mock.calls[0]
const modelInputRegistry = getProviderToolModelInputRegistry(providerRequest.tools[0])
expect(providerRequest.messages).toEqual([
{ role: 'user', content: 'Use {{TEST_API_KEY_PERSONAL}}' },
])
expect(
modelInputRegistry?.resolveModelExposedEnvReferences({
secret: '{{TEST_API_KEY_PERSONAL}}',
})
).toMatchObject({
complete: true,
matched: true,
value: { secret: 'personal-secret-value' },
})
})
it('does not carry a projected system prompt into Agent output provenance', async () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'x', encryptedValue: 'encrypted-token' },
@@ -85,6 +85,7 @@ import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
import {
type ProviderToolInputProvenance,
registerProviderToolInputProvenance,
registerProviderToolModelInputRegistry,
} from '@/providers/tool-input-provenance'
import type { ProviderToolConfig } from '@/providers/types'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
@@ -397,6 +398,11 @@ export class AgentBlockHandler implements BlockHandler {
const settledInputRegistry = ctx.resolvedSecretTraceRegistry
const resultRegistry = settledInputRegistry?.forkForInputPaths([])
if (modelInputProjection.registry) {
for (const tool of formatted.tools) {
registerProviderToolModelInputRegistry(tool, modelInputProjection.registry)
}
}
if (resultRegistry && settledInputRegistry) {
for (const [tool, provenance] of formatted.inputProvenance) {
registerProviderToolInputProvenance(tool, {
@@ -2590,6 +2596,7 @@ export class AgentBlockHandler implements BlockHandler {
},
{
resolvedSecretTraceRegistry: modelRuntimeRegistry,
executionContext: ctx,
}
)
@@ -4,7 +4,7 @@ import { generateId } from '@sim/utils/id'
import { isPlainRecord } from '@sim/utils/object'
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutionContext } from '@/executor/types'
import type { ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types'
import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type { SerializedBlock } from '@/serializer/types'
@@ -55,6 +55,7 @@ export function buildCustomBlockExecutionContext(
options: {
abortSignal?: AbortSignal
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
executorDelegationOrigin?: ExecutorDelegationOrigin
} = {}
): ExecutionContext {
// Prefer the invoking agent run's ids so correlation and cancellation both
@@ -64,6 +65,7 @@ export function buildCustomBlockExecutionContext(
workflowId: context.workflowId ?? 'custom-block-tool',
workspaceId: context.workspaceId,
userId: context.userId,
executorDelegationOrigin: options.executorDelegationOrigin,
executionId,
isDeployedContext: context.isDeployedContext,
// Inherit the accumulated chain so the handler appends + validates depth;
@@ -582,6 +582,11 @@ describe('WorkflowBlockHandler', () => {
expect(executorOptions[0].contextExtensions.billingAttribution).toBe(sourceAttribution)
expect(executorOptions[0].contextExtensions.userId).toBe('owner-9')
expect(executorOptions[0].contextExtensions.workspaceId).toBe('workspace-source')
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({
subjectUserId: 'owner-9',
workflowId: 'source-workflow-id',
executionId: loggingSessionArgs[0][1],
})
})
it('builds trusted caller metadata for custom block children with the toggle on', async () => {
@@ -1225,6 +1230,15 @@ describe('WorkflowBlockHandler', () => {
expect(loggingSessionArgs).toHaveLength(0)
})
it('fails before execution when the source child log row cannot be opened', async () => {
mockSafeStart.mockResolvedValue(false)
await expect(handler.execute(customBlockContext(), customBlock(), {})).rejects.toThrow()
expect(mockExecutorExecute).not.toHaveBeenCalled()
expect(executorOptions).toHaveLength(0)
})
it('runs the child under its own execution id but keeps the parent readable', async () => {
const ctx = customBlockContext()
await handler.execute(ctx, customBlock(), {})
@@ -1235,6 +1249,24 @@ describe('WorkflowBlockHandler', () => {
expect(ctx.largeValueExecutionIds).toContain(extensions.executionId)
})
it('replaces the consumer delegation origin with the source child execution', async () => {
const ctx = customBlockContext({
executorDelegationOrigin: {
subjectUserId: 'consumer-1',
workflowId: 'consumer-workflow',
executionId: 'parent-execution-id',
},
})
await handler.execute(ctx, customBlock(), {})
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({
subjectUserId: 'owner-9',
workflowId: 'source-workflow-id',
executionId: executorOptions[0].contextExtensions.executionId,
})
})
it('shares one large-value id list so nested custom blocks propagate upward', async () => {
const ctx = customBlockContext()
await handler.execute(ctx, customBlock(), {})
@@ -1604,9 +1636,49 @@ describe('WorkflowBlockHandler', () => {
const extensions = executorOptions[0].contextExtensions
expect(extensions.executionId).toBe('parent-execution-id')
expect(extensions.resolvedSecretTraceRegistry).toBe(registry)
expect(extensions.executorDelegationOrigin).toEqual({
subjectUserId: 'user-1',
workflowId: 'parent-workflow-id',
executionId: 'parent-execution-id',
})
expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith(
extensions.executorDelegationOrigin
)
expect(extensions.onStream).toBe(ctx.onStream)
expect(extensions.childWorkflowContext).toBeDefined()
})
it('preserves the canonical parent origin through deeper regular children', async () => {
const ctx = {
...mockContext,
workspaceId: 'workspace-1',
workflowId: 'intermediate-workflow-id',
executionId: 'parent-execution-id',
executorDelegationOrigin: {
subjectUserId: 'user-1',
workflowId: 'root-workflow-id',
executionId: 'parent-execution-id',
},
} as ExecutionContext
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Grandchild Workflow',
workspaceId: 'workspace-1',
state: { blocks: [], edges: [], loops: {}, parallels: {} },
},
}),
})
await handler.execute(ctx, mockBlock, { workflowId: 'grandchild-workflow-id' })
expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith(ctx.executorDelegationOrigin)
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toBe(
ctx.executorDelegationOrigin
)
})
})
describe('projectCustomBlockOutput', () => {
@@ -37,6 +37,7 @@ import {
type BlockHandler,
type ExecutionContext,
type ExecutionResult,
type ExecutorDelegationOrigin,
START_BLOCK_METADATA_FIELD,
type StartBlockRunMetadata,
type StreamingExecution,
@@ -278,17 +279,22 @@ export class WorkflowBlockHandler implements BlockHandler {
/** Large-value id list shared with the child (and any nested custom blocks). */
let sharedLargeValueIds: string[] | undefined
let childCancellation: { signal: AbortSignal; dispose: () => void } | undefined
let childExecutorDelegationOrigin: ExecutorDelegationOrigin | undefined
/** Settled in `finally` once the child is fully done — see `trackChildRun`. */
let settleChildRun: (() => void) | undefined
try {
if (!loadUserId) {
throw new Error('Workflow child loading requires a human execution subject')
}
const workflowReadHeaders = await buildExecutorDelegationHeaders({
subjectUserId: loadUserId,
workflowId: isCustomBlock ? workflowId : ctx.workflowId,
...(!isCustomBlock && ctx.executionId ? { executionId: ctx.executionId } : {}),
})
const workflowReadDelegationOrigin: ExecutorDelegationOrigin = isCustomBlock
? { subjectUserId: loadUserId, workflowId }
: (ctx.executorDelegationOrigin ?? {
subjectUserId: loadUserId,
workflowId: ctx.workflowId,
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
})
if (!isCustomBlock) childExecutorDelegationOrigin = workflowReadDelegationOrigin
const workflowReadHeaders = await buildExecutorDelegationHeaders(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
@@ -509,10 +515,13 @@ export class WorkflowBlockHandler implements BlockHandler {
...(correlation ? { triggerData: { correlation } } : {}),
})
if (!childSessionStarted) {
logger.error('Custom block child logging failed to start; child spend will be unbilled', {
workflowId,
childExecutionId,
})
childExecutionId = undefined
throw new Error('Custom block child logging failed to start')
}
childExecutorDelegationOrigin = {
subjectUserId: loadUserId,
workflowId,
executionId: childExecutionId,
}
// The child no longer shares the parent's execution id, so it no longer
// hears the parent's cancellation event — bridge it explicitly.
@@ -599,6 +608,7 @@ export class WorkflowBlockHandler implements BlockHandler {
enforceCredentialAccess: ctx.enforceCredentialAccess,
workspaceId: childWorkspaceId,
userId: childUserId,
executorDelegationOrigin: childExecutorDelegationOrigin,
executionId: childExecutionId ?? ctx.executionId,
// Large values are cached per execution id, so a child running under its
// own id still needs the invoking run's id to read values in its inputs.
@@ -9,6 +9,7 @@ import {
type CustomBlockExecutorContext,
} from '@/executor/handlers/workflow/custom-block-tool-runner'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutorDelegationOrigin } from '@/executor/types'
import { classifyExecutionError } from '@/executor/utils/errors'
import { parseJSON } from '@/executor/utils/json'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -52,6 +53,7 @@ export async function runWorkflowTool(
options: {
abortSignal?: AbortSignal
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
executorDelegationOrigin?: ExecutorDelegationOrigin
} = {}
): Promise<ToolResponse> {
if (!params.workflowId) {
+15
View File
@@ -338,6 +338,19 @@ export interface BlockState {
resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
}
/**
* Canonical signed execution identity used for executor-delegated internal operations.
*
* A nested workflow changes {@link ExecutionContext.workflowId} for execution semantics, but it
* still belongs to the parent log row identified here. Custom blocks replace this origin with the
* publisher-owned child execution after opening their own source-workspace log row.
*/
export interface ExecutorDelegationOrigin {
subjectUserId: string
workflowId: string
executionId?: string
}
export interface ExecutionContext {
workflowId: string
workspaceId?: string
@@ -347,6 +360,8 @@ export interface ExecutionContext {
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
/** Trusted origin for signed executor delegation, distinct from the currently executing child. */
executorDelegationOrigin?: ExecutorDelegationOrigin
isDeployedContext?: boolean
enforceCredentialAccess?: boolean
copilotToolExecution?: boolean
@@ -185,6 +185,15 @@ export type ResolvedSecretModelEgressSnapshot =
| { complete: true; matches: readonly ResolvedSecretTraceMatch[] }
| { complete: false }
export type ResolvedSecretModelReferenceResolution<T> =
| {
complete: true
matched: boolean
value: T
registry: ResolvedSecretTraceRegistry
}
| { complete: false }
export interface ResolvedSecretTraceProvenanceEntryV1 {
encryptedValue: string
name?: string
@@ -803,6 +812,92 @@ export class ResolvedSecretTraceRegistry {
return fork
}
/**
* Rebinds environment placeholders that were actually projected into a model request.
*
* The model receives `{{NAME}}`, never the plaintext. A later tool argument may carry that
* placeholder back verbatim. Only named entries attached to a resolver-recorded projected input
* are eligible here, so inventing another workspace variable name does not grant access to it.
* The returned registry contains only references used by this tool call.
*/
resolveModelExposedEnvReferences<T>(value: T): ResolvedSecretModelReferenceResolution<T> {
if (!this.complete) return { complete: false }
const projectedValueContains = (candidate: unknown, placeholder: string): boolean => {
const pending = [candidate]
const visited = new WeakSet<object>()
while (pending.length > 0) {
const current = pending.pop()
if (typeof current === 'string' && current.includes(placeholder)) return true
if (current === null || typeof current !== 'object' || visited.has(current)) continue
visited.add(current)
pending.push(...Object.values(current))
}
return false
}
const exposedEntriesByName = new Map<string, ActiveSecretEntry>()
for (const state of this.resolvedInputPaths.values()) {
if (state.projectedValue === undefined) continue
for (const entryKey of state.entryKeys) {
const entry = this.activeEntries.get(entryKey)
if (
!entry ||
entry.anonymous ||
entry.name.length === 0 ||
!projectedValueContains(state.projectedValue, `{{${entry.name}}}`)
) {
continue
}
const existing = exposedEntriesByName.get(entry.name)
if (existing && existing.plaintext !== entry.plaintext) return { complete: false }
exposedEntriesByName.set(entry.name, entry)
}
}
const registry = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope)
let matched = false
const resolve = (candidate: unknown, path: string[]): unknown => {
if (typeof candidate === 'string') {
const usedEntries = new Map<string, ActiveSecretEntry>()
const resolved = candidate.replace(/\{\{([^{}]+)\}\}/g, (placeholder, rawName) => {
const name = String(rawName).trim()
const entry = exposedEntriesByName.get(name)
if (!entry) return placeholder
usedEntries.set(activeEntryKey(entry), entry)
return entry.plaintext
})
if (usedEntries.size === 0) return candidate
matched = true
for (const entry of usedEntries.values()) {
registry.addActiveEntry({ ...entry }, { propagated: true })
}
registry.bindResolvedInputPathEntries(path, usedEntries.values())
registry.recordResolvedInputProjection(path, resolved, candidate)
return resolved
}
if (Array.isArray(candidate)) {
return candidate.map((item, index) => resolve(item, [...path, String(index)]))
}
if (candidate === null || typeof candidate !== 'object') return candidate
const resolved: Record<string, unknown> = {}
for (const [key, child] of Object.entries(candidate)) {
resolved[key] = resolve(child, [...path, key])
}
return resolved
}
const resolvedValue = resolve(value, []) as T
return {
complete: true,
matched,
value: resolvedValue,
registry,
}
}
/** Merges one settled tool-call registry into the turn-scoped registry. */
mergeToolCallRegistry(child: ResolvedSecretTraceRegistry): void {
if (!scopesMatch(this.scope, child.scope)) {
@@ -0,0 +1,76 @@
/**
* @vitest-environment node
*/
import type { DelegatedPrincipal } from '@sim/auth/principal'
import { describe, expect, it } from 'vitest'
import {
WORKFLOW_DELEGATION_AUDIENCE,
workflowDelegationPolicy,
} from '@/lib/workflows/application/authorization'
import { workflowOperations } from '@/lib/workflows/application/operations'
function createExecutorPrincipal(overrides: Partial<DelegatedPrincipal> = {}): DelegatedPrincipal {
return {
kind: 'delegated',
serviceId: 'executor',
subjectUserId: 'user-1',
workspaceId: 'workspace-1',
delegationId: 'execution-1',
audience: WORKFLOW_DELEGATION_AUDIENCE,
issuedAt: new Date(),
expiresAt: new Date(Date.now() + 60_000),
delegationContext: { kind: 'workflow_execution', workflowId: 'parent-workflow' },
...overrides,
}
}
describe('workflow delegation policy', () => {
it('allows an active execution to read a different workflow in the same workspace', () => {
const principal = createExecutorPrincipal()
expect(
workflowDelegationPolicy.isWithinScope(principal, {
workspaceId: 'workspace-1',
workspaceOrganizationId: null,
allowPersonalApiKeys: true,
billedAccountUserId: 'user-1',
workflowId: 'child-workflow',
})
).toBe(true)
})
it('rejects a child workflow in another workspace', () => {
const principal = createExecutorPrincipal()
expect(
workflowDelegationPolicy.isWithinScope(principal, {
workspaceId: 'workspace-2',
workspaceOrganizationId: null,
allowPersonalApiKeys: true,
billedAccountUserId: 'user-2',
workflowId: 'child-workflow',
})
).toBe(false)
})
it('rejects executor delegation without a canonical workflow execution origin', () => {
const principal = createExecutorPrincipal({ delegationContext: undefined })
expect(
workflowDelegationPolicy.isWithinScope(principal, {
workspaceId: 'workspace-1',
workspaceOrganizationId: null,
allowPersonalApiKeys: true,
billedAccountUserId: 'user-1',
workflowId: 'child-workflow',
})
).toBe(false)
})
it('permits executor delegation only on workflow reads', () => {
expect(workflowOperations.read.delegatedServices).toContain('executor')
expect(workflowOperations.update.delegatedServices).not.toContain('executor')
expect(workflowOperations.delete.delegatedServices).not.toContain('executor')
})
})
@@ -29,8 +29,7 @@ export const workflowDelegationPolicy: WorkspaceDelegationPolicy<WorkflowAuthori
delegationContext.kind === 'workflow_execution' &&
'workflowId' in delegationContext &&
typeof delegationContext.workflowId === 'string' &&
delegationContext.workflowId.length > 0 &&
context.workflowId === delegationContext.workflowId
delegationContext.workflowId.length > 0
)
},
}
+91 -1
View File
@@ -1,6 +1,7 @@
/**
* @vitest-environment node
*/
import { createExecutionContext } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockExecuteTool } = vi.hoisted(() => ({
@@ -17,7 +18,10 @@ import {
executeProviderTool as executeProviderToolWithInput,
runWithProviderRuntimeContext,
} from '@/providers/runtime-context'
import { registerProviderToolInputProvenance } from '@/providers/tool-input-provenance'
import {
registerProviderToolInputProvenance,
registerProviderToolModelInputRegistry,
} from '@/providers/tool-input-provenance'
import { prepareToolExecution } from '@/providers/utils'
async function executeProviderTool(
@@ -85,6 +89,92 @@ describe('provider runtime context', () => {
expect(toolCall?.[2]?.resolvedSecretTraceRegistry).not.toBe(registry)
})
it('passes the trusted execution context to provider-emitted tool calls', async () => {
const registry = new ResolvedSecretTraceRegistry()
const executionContext = createExecutionContext({
workflowId: 'workflow-parent',
environmentVariables: {},
})
await runWithProviderRuntimeContext(
{ executionContext, resolvedSecretTraceRegistry: registry },
() => executeProviderTool('protected-tool', {})
)
expect(mockExecuteTool).toHaveBeenCalledWith(
'protected-tool',
{},
expect.objectContaining({ executionContext })
)
})
it('rebinds a prompt-exposed environment placeholder for the exact tool call', async () => {
const sourceRegistry = new ResolvedSecretTraceRegistry([
{
name: 'TEST_API_KEY_PERSONAL',
plaintext: 'personal-secret-value',
encryptedValue: 'encrypted-personal-secret',
},
])
sourceRegistry.recordResolvedAtInputPath('TEST_API_KEY_PERSONAL', 'personal-secret-value', [
'userPrompt',
])
sourceRegistry.recordResolvedInputProjection(
['userPrompt'],
'Use personal-secret-value',
'Use {{TEST_API_KEY_PERSONAL}}'
)
const modelInputRegistry = sourceRegistry.forkForInputPaths([['userPrompt']])
const runtimeRegistry = sourceRegistry.forkForInputPaths([])
const tool = {
id: 'custom_canary',
params: {},
parameters: { type: 'object', properties: {}, required: [] },
}
registerProviderToolModelInputRegistry(tool, modelInputRegistry)
mockExecuteTool.mockResolvedValueOnce({
success: true,
output: { reflected: 'personal-secret-value' },
})
const execution = await runWithProviderRuntimeContext(
{ resolvedSecretTraceRegistry: runtimeRegistry },
() => {
const { executionParams } = prepareToolExecution(
tool,
{ secret: '{{TEST_API_KEY_PERSONAL}}' },
{}
)
return executeProviderToolWithInput(tool.id, executionParams)
}
)
expect(mockExecuteTool.mock.calls.at(-1)?.[1]).toEqual({
secret: 'personal-secret-value',
_toolSchema: { type: 'object', properties: {}, required: [] },
})
expect(execution.rawResponse.output).toEqual({ reflected: 'personal-secret-value' })
expect(execution.modelResponse.output).toEqual({
reflected: '{{TEST_API_KEY_PERSONAL}}',
})
})
it('does not bind an environment placeholder absent from the model input', async () => {
const sourceRegistry = new ResolvedSecretTraceRegistry([
{ name: 'UNEXPOSED', plaintext: 'hidden-value', encryptedValue: 'encrypted-hidden' },
])
const tool = {
id: 'custom_canary',
params: {},
parameters: { type: 'object', properties: {}, required: [] },
}
registerProviderToolModelInputRegistry(tool, sourceRegistry)
const { executionParams } = prepareToolExecution(tool, { secret: '{{UNEXPOSED}}' }, {})
expect(executionParams.secret).toBe('{{UNEXPOSED}}')
})
it('does not treat an arbitrary tool-result collision as secret provenance', async () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
+5
View File
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
import { getErrorMessage } from '@sim/utils/errors'
import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types'
import type { ExecutionContext } from '@/executor/types'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import { getPreparedProviderToolInputProvenance } from '@/providers/tool-input-provenance'
import { type ExecuteToolOptions, executeTool } from '@/tools'
@@ -9,6 +10,8 @@ import type { ToolResponse } from '@/tools/types'
export interface ProviderRuntimeContext {
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
/** Trusted server execution context inherited by model-emitted tool calls. */
executionContext?: ExecutionContext
}
export type ExecuteProviderToolOptions = ExecuteToolOptions
@@ -68,8 +71,10 @@ export async function executeProviderTool(
: undefined
try {
const executionContext = options.executionContext ?? runtimeContext?.executionContext
const result = await executeTool(toolId, params, {
...options,
...(executionContext ? { executionContext } : {}),
resolvedSecretTraceRegistry: toolCallRegistry,
})
if (!registry || !toolCallRegistry) {
@@ -16,6 +16,7 @@ export interface PreparedProviderToolInputProvenance {
const configuredToolInputProvenance = new WeakMap<object, ProviderToolInputProvenance>()
const preparedToolInputProvenance = new WeakMap<object, PreparedProviderToolInputProvenance>()
const modelInputRegistries = new WeakMap<object, ResolvedSecretTraceRegistry>()
/** Associates one provider tool object with its exact resolver-recorded preset input. */
export function registerProviderToolInputProvenance(
@@ -32,6 +33,21 @@ export function getProviderToolInputProvenance(
return configuredToolInputProvenance.get(tool)
}
/** Associates a provider tool with secrets whose placeholders were visible in this model turn. */
export function registerProviderToolModelInputRegistry(
tool: object,
registry: ResolvedSecretTraceRegistry
): void {
modelInputRegistries.set(tool, registry)
}
/** Reads the model-visible placeholder allowlist for the exact provider tool instance. */
export function getProviderToolModelInputRegistry(
tool: object
): ResolvedSecretTraceRegistry | undefined {
return modelInputRegistries.get(tool)
}
/** Associates one prepared execution object with its isolated transformed-input registry. */
export function registerPreparedProviderToolInputProvenance(
executionParams: object,
+22 -5
View File
@@ -52,6 +52,7 @@ import {
} from '@/providers/models'
import {
getProviderToolInputProvenance,
getProviderToolModelInputRegistry,
registerPreparedProviderToolInputProvenance,
} from '@/providers/tool-input-provenance'
import type { ProviderId, ProviderToolConfig } from '@/providers/types'
@@ -1563,11 +1564,27 @@ export function prepareToolExecution(
// scoped to "Selected secrets" with an empty list is an explicit deny, and a
// model emitting `mountedSecrets: ['STRIPE_KEY']` would otherwise mount it.
const modelParams = stripModelBlockedParams(tool.modelBlockedParams, llmArgs)
let toolParams = mergeToolParameters(tool.params || {}, modelParams) as Record<string, any>
const modelInputRegistry = getProviderToolModelInputRegistry(tool)
const modelReferenceResolution = modelInputRegistry?.resolveModelExposedEnvReferences(modelParams)
if (modelReferenceResolution && !modelReferenceResolution.complete) {
throw new Error('Agent tool input environment references could not be safely resolved')
}
const resolvedModelParams = modelReferenceResolution?.value ?? modelParams
let toolParams = mergeToolParameters(tool.params || {}, resolvedModelParams)
const inputProvenance = getProviderToolInputProvenance(tool)
const inputRegistry = inputProvenance?.registry.forkForInputPaths([inputProvenance.sourcePath])
let projectedToolParams = inputProvenance
? (mergeToolParameters(inputProvenance.projectedParams, modelParams) as Record<string, any>)
let inputRegistry = inputProvenance?.registry.forkForInputPaths([inputProvenance.sourcePath])
if (modelReferenceResolution?.matched) {
if (inputRegistry) {
inputRegistry.mergeToolCallRegistry(modelReferenceResolution.registry)
} else {
inputRegistry = modelReferenceResolution.registry
}
}
if (inputRegistry && !inputRegistry.isComplete()) {
throw new Error('Agent tool input environment references could not be safely resolved')
}
let projectedToolParams = inputRegistry
? mergeToolParameters(inputProvenance?.projectedParams ?? tool.params ?? {}, modelParams)
: undefined
if (tool.paramsTransform) {
@@ -1622,7 +1639,7 @@ export function prepareToolExecution(
...(tool.parameters ? { _toolSchema: tool.parameters } : {}),
}
if (inputProvenance && inputRegistry) {
if (inputRegistry) {
const inputPaths = [['params']] as const
if (projectedToolParams) {
inputRegistry.recordTransformedInputProjection(
+87
View File
@@ -37,6 +37,7 @@ import {
import { fileGetContentTool } from '@/tools/file/get'
import { memoryAddTool } from '@/tools/memory/add'
import { tableBatchInsertRowsTool } from '@/tools/table/batch_insert_rows'
import { customBlockExecutorTool } from '@/tools/workflow/custom-block-executor'
import { workflowExecutorTool } from '@/tools/workflow/executor'
// Hoisted mock state - these are available to vi.mock factories
@@ -47,6 +48,7 @@ const {
mockGetCustomToolById,
mockListCustomTools,
mockMarkWorkspaceFileSecretProvenanceUnknown,
mockRunCustomBlockTool,
mockRunWorkflowTool,
mockGetCustomToolByIdOrTitle,
mockGenerateInternalDelegationToken,
@@ -63,6 +65,7 @@ const {
mockGetCustomToolById: vi.fn(),
mockListCustomTools: vi.fn(),
mockMarkWorkspaceFileSecretProvenanceUnknown: vi.fn(),
mockRunCustomBlockTool: vi.fn(),
mockRunWorkflowTool: vi.fn(),
mockGetCustomToolByIdOrTitle: vi.fn(),
mockGenerateInternalDelegationToken: vi.fn(),
@@ -130,9 +133,14 @@ vi.mock('@/executor/handlers/workflow/workflow-tool-runner', () => ({
runWorkflowTool: (...args: unknown[]) => mockRunWorkflowTool(...args),
}))
vi.mock('@/executor/handlers/workflow/custom-block-tool-runner', () => ({
runCustomBlockTool: (...args: unknown[]) => mockRunCustomBlockTool(...args),
}))
// Mock the tools registry to avoid loading the full 4500+ line registry file.
// Only the tools actually exercised in tests are provided.
const mockRegistryTools: Record<string, any> = {
deployed_block_executor: customBlockExecutorTool,
workflow_executor: workflowExecutorTool,
file_get_content: fileGetContentTool,
memory_add: memoryAddTool,
@@ -774,6 +782,36 @@ describe('executeTool Function', () => {
expect(new Headers(request?.headers).get('authorization')).toBe('Bearer executor-token')
})
it('uses the canonical parent origin for protected tools inside a nested workflow', async () => {
global.fetch = Object.assign(
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
),
{ preconnect: vi.fn() }
) as typeof fetch
const executionContext = createToolExecutionContext({
userId: 'trusted-user',
workflowId: 'child-workflow',
executionId: 'parent-execution',
executorDelegationOrigin: {
subjectUserId: 'trusted-user',
workflowId: 'parent-workflow',
executionId: 'parent-execution',
},
})
await executeTool('test_executor_delegation', {}, { executionContext })
expect(mockGenerateInternalDelegationToken).toHaveBeenCalledWith({
subjectUserId: 'trusted-user',
workflowId: 'parent-workflow',
executionId: 'parent-execution',
})
})
it('rejects protected internal tools without trusted executor scope before transport', async () => {
const result = await executeTool('test_executor_delegation', {
_context: {
@@ -1481,6 +1519,55 @@ describe('executeTool Function', () => {
expect(global.fetch).not.toHaveBeenCalled()
})
it('overwrites custom-block tool context with the trusted workflow scope', async () => {
const executionContext = createToolExecutionContext({
userId: 'trusted-user',
workflowId: 'trusted-workflow',
workspaceId: 'trusted-workspace',
executionId: 'trusted-execution',
callChain: ['trusted-parent'],
isDeployedContext: true,
})
mockRunCustomBlockTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
await executeTool(
'deployed_block_executor_custom_block_123',
{
blockType: 'custom_block_123',
_context: {
userId: 'forged-user',
workflowId: 'forged-workflow',
workspaceId: 'forged-workspace',
executionId: 'forged-execution',
callChain: ['forged-parent'],
isDeployedContext: false,
billingAttribution: { forged: true },
},
},
{ executionContext }
)
expect(mockRunCustomBlockTool).toHaveBeenCalledWith(
expect.objectContaining({
blockType: 'custom_block_123',
_context: expect.objectContaining({
userId: 'trusted-user',
workflowId: 'trusted-workflow',
workspaceId: 'trusted-workspace',
executionId: 'trusted-execution',
callChain: ['trusted-parent'],
isDeployedContext: true,
billingAttribution: TEST_BILLING_ATTRIBUTION,
requestId: expect.any(String),
}),
}),
expect.objectContaining({
resolvedSecretTraceRegistry: undefined,
})
)
expect(global.fetch).not.toHaveBeenCalled()
})
it('filters cross-scope workflow provenance to literals present in the unchanged result', async () => {
const registry = new ResolvedSecretTraceRegistry([], {
userId: 'parent-user',
+16
View File
@@ -158,6 +158,13 @@ function resolveInternalExecutorDelegation(
if (!executionContext?.userId || !executionContext.workflowId) {
throw new Error('Executor delegation requires a trusted workflow execution context')
}
if (executionContext.executorDelegationOrigin) {
const origin = executionContext.executorDelegationOrigin
if (!origin.subjectUserId || !origin.workflowId) {
throw new Error('Executor delegation origin requires an authenticated user and workflow')
}
return origin
}
return {
subjectUserId: executionContext.userId,
workflowId: executionContext.workflowId,
@@ -1780,6 +1787,7 @@ async function executeToolImplementation(
{
abortSignal: effectiveSignal,
resolvedSecretTraceRegistry,
executorDelegationOrigin: executionContext?.executorDelegationOrigin,
}
)
const endTime = new Date()
@@ -1807,7 +1815,15 @@ async function executeToolImplementation(
...contextParams,
_context: {
...(contextParams._context as Record<string, unknown> | undefined),
...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}),
...(scope.workflowId ? { workflowId: scope.workflowId } : {}),
...(scope.userId ? { userId: scope.userId } : {}),
...(scope.executionId ? { executionId: scope.executionId } : {}),
...(scope.callChain ? { callChain: scope.callChain } : {}),
...(scope.isDeployedContext !== undefined
? { isDeployedContext: scope.isDeployedContext }
: {}),
...(scope.billingAttribution ? { billingAttribution: scope.billingAttribution } : {}),
requestId,
},
},