Skip to content

Commit 075599a

Browse files
committed
fix(copilot): secrets injection into sandbox
1 parent ccc2ec9 commit 075599a

17 files changed

Lines changed: 1017 additions & 293 deletions

File tree

apps/docs/content/docs/en/platform/credentials.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ This is an observability projection only. Secret resolution and workflow behavio
7575
Masking is activated only when Sim successfully resolves a value from **Settings → Secrets** through `{{KEY}}`. A hardcoded literal, direct `environmentVariables['KEY']` read, or shell `$KEY` read does not activate it by itself. Once activated, every exact occurrence of that value in the run's log-facing content is masked. Encoded, hashed, or otherwise transformed versions are not matched. Do not deliberately return or print secrets.
7676
</Callout>
7777

78+
### Copilot code execution
79+
80+
When Copilot runs its built-in Function or code-execution tool, the sandbox still receives the real value for a successful `{{KEY}}` substitution. Before the tool result is returned to Copilot, exact occurrences of that activated value are replaced with `{{KEY}}`. This keeps the plaintext out of Copilot's tool-result context without changing the code that ran or its local runtime result. If Sim cannot verify the execution's secret provenance, it omits the result content instead of returning it unverified. Hardcoded, directly read, encoded, hashed, and otherwise transformed values follow the same limitations described above.
81+
7882
## Secret Details
7983

8084
Click **Details** on any secret row to open its detail view.
@@ -122,7 +126,7 @@ When a workflow runs, secrets resolve in this order:
122126

123127
<FAQ items={[
124128
{ question: "Are my secrets encrypted at rest?", answer: "Yes. Values saved under Secrets are encrypted before being stored in the database." },
125-
{ question: "Can a saved secret still appear in a workflow result?", answer: "Yes. Functional data is not rewritten, so the raw value can still reach downstream blocks, tools, and models and can appear in workflow execution responses, streams, or callbacks if your workflow deliberately returns or prints it. Log-facing views and read APIs, including Workflow Output on the Logs Overview and the Logs block's Get Run Details output, receive a protected copy after a successful {{KEY}} substitution." },
129+
{ question: "Can a saved secret still appear in a workflow result?", answer: "Yes. Functional workflow data is not rewritten, so the raw value can still reach downstream blocks, tools, and models and can appear in workflow execution responses, streams, or callbacks if your workflow deliberately returns or prints it. Log-facing views and read APIs receive a protected copy after a successful {{KEY}} substitution. Copilot's built-in Function and code-execution tool results are also protected before they are returned to Copilot." },
126130
{ question: "What happens if both a workspace secret and a personal secret have the same key name?", answer: "The workspace secret takes precedence. During execution, the resolver checks workspace secrets first and uses personal secrets only as a fallback. This ensures production workflows use the shared, team-managed value." },
127131
{ question: "Who determines which personal secret is used for automated runs?", answer: "For manual runs, the personal secrets of the user who clicked Run are used as fallback. For automated runs triggered by API, webhook, or schedule, the personal secrets of the workflow owner are used instead." },
128132
{ question: "Can I import secrets from a .env file?", answer: "Yes. Paste .env-style content (KEY=VALUE format) into any key or value field and the secrets will be auto-populated. The parser supports export KEY=VALUE, quoted values, and inline comments." },

apps/sim/app/api/mothership/execute/route.test.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,9 @@ describe('mothership private trace provenance transport', () => {
190190
}
191191

192192
function activateSecret(options: CopilotLifecycleOptions): void {
193-
options.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'secret-value')
193+
const registry =
194+
options.environmentContext?.resolvedSecretTraceRegistry ?? options.resolvedSecretTraceRegistry
195+
registry?.recordResolved('API_KEY', 'secret-value')
194196
}
195197

196198
it('does not expose private provenance unless the internal caller requests it', async () => {
@@ -218,7 +220,7 @@ describe('mothership private trace provenance transport', () => {
218220
expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled()
219221
expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith(
220222
expect.any(Object),
221-
expect.objectContaining({ resolvedSecretTraceRegistry: undefined })
223+
expect.objectContaining({ environmentContext: undefined })
222224
)
223225
})
224226

@@ -227,6 +229,7 @@ describe('mothership private trace provenance transport', () => {
227229
mockRunHeadlessCopilotLifecycle.mockImplementation(
228230
async (_payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
229231
expect(options.resolvedSecretTraceRegistry?.isComplete()).toBe(false)
232+
expect(options.environmentContext).toBeUndefined()
230233
return successResult()
231234
}
232235
)
@@ -258,9 +261,10 @@ describe('mothership private trace provenance transport', () => {
258261
it('fails provenance closed without changing a runtime value that rotated after catalog load', async () => {
259262
mockRunHeadlessCopilotLifecycle.mockImplementation(
260263
async (_payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
261-
expect(
262-
options.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'rotated-secret-value')
263-
).toBe(false)
264+
const registry =
265+
options.environmentContext?.resolvedSecretTraceRegistry ??
266+
options.resolvedSecretTraceRegistry
267+
expect(registry?.recordResolved('API_KEY', 'rotated-secret-value')).toBe(false)
264268
return { ...successResult(), content: 'rotated-secret-value' }
265269
}
266270
)
@@ -292,6 +296,11 @@ describe('mothership private trace provenance transport', () => {
292296
it('returns encrypted provenance on a marker-gated successful request', async () => {
293297
mockRunHeadlessCopilotLifecycle.mockImplementation(
294298
async (_payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
299+
expect(options.environmentContext?.decryptedEnvVars).toEqual({
300+
API_KEY: 'secret-value',
301+
})
302+
expect(options.environmentContext?.resolvedSecretTraceRegistry).toBeDefined()
303+
expect(options.resolvedSecretTraceRegistry).toBeUndefined()
295304
activateSecret(options)
296305
return successResult()
297306
}
@@ -322,6 +331,7 @@ describe('mothership private trace provenance transport', () => {
322331
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
323332
})
324333
expect(JSON.stringify(body.__resolvedSecretTraceProvenance)).not.toContain('secret-value')
334+
expect(mockGetPersonalAndWorkspaceEnv).toHaveBeenCalledTimes(1)
325335
})
326336

327337
it('imports MCP schema-discovery provenance before starting the lifecycle', async () => {
@@ -344,7 +354,10 @@ describe('mothership private trace provenance transport', () => {
344354
)
345355
mockRunHeadlessCopilotLifecycle.mockImplementation(
346356
async (payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
347-
expect(options.resolvedSecretTraceRegistry?.exportProvenance()).toEqual(provenance)
357+
const registry =
358+
options.environmentContext?.resolvedSecretTraceRegistry ??
359+
options.resolvedSecretTraceRegistry
360+
expect(registry?.exportProvenance()).toEqual(provenance)
348361
expect(JSON.stringify(payload)).not.toContain('encrypted-secret')
349362
expect(JSON.stringify(payload)).not.toContain('__resolvedSecretTraceProvenance')
350363
return successResult()
@@ -388,7 +401,10 @@ describe('mothership private trace provenance transport', () => {
388401
)
389402
mockRunHeadlessCopilotLifecycle.mockImplementation(
390403
async (_payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
391-
expect(options.resolvedSecretTraceRegistry?.isComplete()).toBe(false)
404+
const registry =
405+
options.environmentContext?.resolvedSecretTraceRegistry ??
406+
options.resolvedSecretTraceRegistry
407+
expect(registry?.isComplete()).toBe(false)
392408
return successResult()
393409
}
394410
)

apps/sim/app/api/mothership/execute/route.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
1010
import { processContextsServer } from '@/lib/copilot/chat/process-contents'
1111
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
1212
import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements'
13+
import {
14+
type CopilotEnvironmentContext,
15+
createCopilotEnvironmentContext,
16+
} from '@/lib/copilot/environment-context'
1317
import {
1418
MothershipStreamV1EventType,
1519
MothershipStreamV1TextChannel,
@@ -33,7 +37,6 @@ import {
3337
} from '@/lib/workspaces/permissions/utils'
3438
import {
3539
createIncompleteResolvedSecretTraceRegistry,
36-
createResolvedSecretTraceRegistry,
3740
ResolvedSecretTraceProvenanceAccumulator,
3841
type ResolvedSecretTraceRegistry,
3942
} from '@/executor/utils/resolved-secret-trace-registry'
@@ -135,6 +138,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
135138
let messageId: string | undefined
136139
let requestId: string | undefined
137140
let resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined
141+
let environmentContext: CopilotEnvironmentContext | undefined
138142
const includePrivateProvenance = requestsPrivateToolMetadata(
139143
req.headers,
140144
RESOLVED_SECRET_PROVENANCE_METADATA_V1
@@ -192,14 +196,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
192196
const environment = await getPersonalAndWorkspaceEnv(userId, workspaceId, {
193197
workspaceAccess,
194198
})
195-
resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({
196-
personalEncrypted: environment.personalEncrypted,
197-
workspaceEncrypted: environment.workspaceEncrypted,
198-
personalDecrypted: environment.personalDecrypted,
199-
workspaceDecrypted: environment.workspaceDecrypted,
200-
decryptionFailures: environment.decryptionFailures,
201-
scope,
202-
})
199+
environmentContext = await createCopilotEnvironmentContext(userId, workspaceId, environment)
200+
resolvedSecretTraceRegistry = environmentContext.resolvedSecretTraceRegistry
203201
} catch (error) {
204202
logger.warn('Failed to build Mothership trace secret catalog', {
205203
error: getErrorMessage(error),
@@ -376,7 +374,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
376374
interactive: false,
377375
abortSignal: lifecycleAbortController.signal,
378376
billingAttribution,
379-
resolvedSecretTraceRegistry,
377+
environmentContext,
378+
...(!environmentContext && resolvedSecretTraceRegistry
379+
? { resolvedSecretTraceRegistry }
380+
: {}),
380381
onEvent,
381382
})
382383

0 commit comments

Comments
 (0)