Skip to content

Commit 81ff24a

Browse files
improvement(secrets): gate Copilot code mounting at use level (#7004)
* improvement(secrets): gate Copilot code mounting at use level Mounting a saved secret into Copilot code required credential-admin on that key, while a workflow Function block resolves the same secret for the same person at use level through getPersonalAndWorkspaceEnv. Copilot reaches that path itself — edit_workflow plus run_workflow — so the admin bar contained nothing. It redirected a Credential Member through a detour that mutates a persisted workflow, while the direct path is ephemeral and files a usage row. The inconsistency was also internal to Copilot: the secret names advertised to the model come from getAccessibleEnvCredentials and getPersonalAndWorkspaceEnv, both role-agnostic, so Copilot listed every secret the caller could use and then refused to mount all but the admin ones. Widen the workspace and shared-personal predicates to any active grant, and drop the matching role filter from the query. Workspace write is still required, revoked and pending grants are still refused, and a caller with no grant still gets nothing. The view gate stays where Copilot cannot route around it: values remain masked under Settings, and See usage remains admin-only, so a member's use is recorded for whoever can rotate the key. Model-egress projection is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(secrets): stop implying Personal secrets are shareable The Copilot code-execution paragraph listed "any secret shared with you as a Credential Member or Credential Admin" among what mounts, which reads as though a Personal secret can be shared. It cannot through any product surface: CredentialMembersSection renders only for workspace secrets and OAuth credentials, and the personal-credential sync only ever grants the owner. Narrow the sentence to Workspace grants. The comparison table's "Only you can use" row for Personal was correct and is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9ff1d77 commit 81ff24a

3 files changed

Lines changed: 103 additions & 31 deletions

File tree

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,17 @@ Both masking and model-bound projection match only exact values in either case.
9191

9292
### Copilot code execution
9393

94-
Copilot's Function and code-execution tools receive a saved secret only when their code explicitly contains a valid `{{KEY}}` reference. Direct `environmentVariables.KEY` access, shell `$KEY`, dynamic names, literals, and configured-but-unused secrets do not mount a value. Code execution requires workspace write access, and the caller must also be allowed to view the raw value: your own Personal secrets, any secret for which you are a Credential Admin, and Workspace secrets when you are a workspace admin. Credential Members can continue using shared secrets through normal workflow and tool resolution, but cannot mount their plaintext into arbitrary Copilot code.
94+
Copilot's Function and code-execution tools receive a saved secret only when their code explicitly contains a valid `{{KEY}}` reference. Direct `environmentVariables.KEY` access, shell `$KEY`, dynamic names, literals, and configured-but-unused secrets do not mount a value. Code execution requires workspace write access, and the caller must be allowed to **use** the secret — the same set a workflow resolves for them: your own Personal secrets, and Workspace secrets you hold an active grant on as a Credential Member or Credential Admin, which a workspace admin holds on every key. A secret you hold no grant on does not mount, and neither does one whose grant is revoked or still pending.
95+
96+
This matches what a workflow Function block already resolves for the same person, deliberately. Being able to run a secret is not the same as being able to read it: the value stays masked under **Settings → Secrets**, and **See usage** stays visible only to that secret's admins, so a Credential Member using a secret in code is recorded for whoever can rotate it.
9597

9698
Headless surfaces use their saved **Secret access** setting:
9799

98100
- **Sim Chat block** — under **Show additional fields**
99101
- **Scheduled Tasks** — in the task modal
100102
- **Inbox** — under **Settings → Inbox → Secrets**
101103

102-
Choose **All secrets** or **Selected secrets**. Existing configurations default to **All secrets** for compatibility. **All secrets** still means only secrets explicitly referenced with `{{KEY}}` that the execution actor may view; it never injects the full environment. Inbox messages from allowed external senders do not receive raw-secret access.
104+
Choose **All secrets** or **Selected secrets**. Existing configurations default to **All secrets** for compatibility. **All secrets** still means only secrets explicitly referenced with `{{KEY}}` that the execution actor may use; it never injects the full environment. Inbox messages from allowed external senders do not receive raw-secret access at all — an inbound message that Sim cannot match to a workspace member runs with no secret actor, so no `{{KEY}}` resolves for it.
103105

104106
Code receives the real authorized value at runtime. Before any Copilot-visible tool result is returned, exact occurrences of activated secret values are replaced with `{{KEY}}`; local side effects and runtime results are not rewritten. Encoded, hashed, URL-encoded, otherwise transformed, or network-exfiltrated values cannot be inferred and masked reliably, so code should not deliberately return, transform, print, or transmit secrets to unintended destinations.
105107

@@ -139,10 +141,11 @@ Usage is recorded independently of execution logs, so it outlives them: logs exp
139141

140142
| | Workspace | Personal |
141143
|---|---|---|
142-
| **Visibility** | All workspace members, including external workspace members | Only you |
143-
| **Use in workflows** | Any member can use | Only you can use |
144+
| **Who sees the name** | All workspace members, including external workspace members | Only you |
145+
| **Who sees the value** | Workspace admins and that secret's Credential Admins | Only you |
146+
| **Use in workflows and code** | Any member can use | Only you can use |
144147
| **Best for** | Production workflows, shared services | Testing, personal API keys |
145-
| **Who can edit** | Workspace admins | Only you |
148+
| **Who can edit** | Workspace admins and that secret's Credential Admins | Only you |
146149

147150
<Callout type="info">
148151
When a workspace secret and a personal secret share the same key name, the **workspace secret takes precedence**.

apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,38 @@ describe('materializeCopilotCodeSecrets', () => {
221221
expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' })
222222
})
223223

224+
/**
225+
* Use-level on purpose: a workflow Function block resolves the same secret for the same
226+
* member, and Copilot can author and run such a workflow itself. The admin bar stays on the
227+
* Settings mask and the usage trail, which Copilot cannot route around.
228+
*/
229+
it('lets an active per-secret member mount a workspace secret', async () => {
230+
queueSources({
231+
workspace: { API_KEY: 'workspace-cipher' },
232+
credentials: [
233+
credentialRow({
234+
type: 'env_workspace',
235+
envKey: 'API_KEY',
236+
role: 'member',
237+
status: 'active',
238+
}),
239+
],
240+
})
241+
242+
const result = await materializeCopilotCodeSecrets({
243+
actorUserId: 'user-1',
244+
workspaceId: 'workspace-1',
245+
requestedNames: ['API_KEY'],
246+
})
247+
248+
expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' })
249+
})
250+
224251
it.each([
225-
['member', 'active'],
226252
['admin', 'revoked'],
227253
['admin', 'pending'],
254+
['member', 'revoked'],
255+
['member', 'pending'],
228256
] as const)('denies a workspace secret for a %s/%s credential grant', async (role, status) => {
229257
queueSources({
230258
workspace: { API_KEY: 'workspace-cipher' },
@@ -263,7 +291,7 @@ describe('materializeCopilotCodeSecrets', () => {
263291
type: 'env_workspace',
264292
envKey: 'API_KEY',
265293
role: 'member',
266-
status: 'active',
294+
status: 'revoked',
267295
}),
268296
],
269297
})
@@ -286,7 +314,7 @@ describe('materializeCopilotCodeSecrets', () => {
286314
type: 'env_workspace',
287315
envKey: 'API_KEY',
288316
role: 'member',
289-
status: 'active',
317+
status: 'revoked',
290318
}),
291319
],
292320
})
@@ -348,36 +376,64 @@ describe('materializeCopilotCodeSecrets', () => {
348376
expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled()
349377
})
350378

351-
it('mounts another owner personal secret only for an active per-secret admin', async () => {
379+
it.each(['admin', 'member'] as const)(
380+
'mounts another owner personal secret for an active per-secret %s',
381+
async (role) => {
382+
queueSources({
383+
credentials: [
384+
credentialRow({
385+
type: 'env_personal',
386+
envKey: 'SHARED_KEY',
387+
envOwnerUserId: 'owner-2',
388+
role,
389+
status: 'active',
390+
encryptedValue: 'shared-cipher',
391+
encryptedValueBytes: 13,
392+
}),
393+
],
394+
})
395+
396+
const result = await materializeCopilotCodeSecrets({
397+
actorUserId: 'user-1',
398+
workspaceId: 'workspace-1',
399+
requestedNames: ['SHARED_KEY'],
400+
})
401+
402+
expect(result.envVars).toEqual({ SHARED_KEY: 'plain:shared-cipher' })
403+
/**
404+
* The usage trail is read per owner, so a borrowed secret has to be filed under the
405+
* sharer. Attributing it to the actor would surface it under the actor's own
406+
* same-named secret and hide it from the person who can actually rotate it.
407+
*/
408+
expect(result.catalogEntries).toEqual([
409+
expect.objectContaining({ name: 'SHARED_KEY', scope: 'personal', ownerUserId: 'owner-2' }),
410+
])
411+
}
412+
)
413+
414+
it('does not mount another owner personal secret on a revoked grant', async () => {
352415
queueSources({
353416
credentials: [
354417
credentialRow({
355418
type: 'env_personal',
356419
envKey: 'SHARED_KEY',
357420
envOwnerUserId: 'owner-2',
358-
role: 'admin',
359-
status: 'active',
421+
role: 'member',
422+
status: 'revoked',
360423
encryptedValue: 'shared-cipher',
361424
encryptedValueBytes: 13,
362425
}),
363426
],
364427
})
365428

366-
const result = await materializeCopilotCodeSecrets({
367-
actorUserId: 'user-1',
368-
workspaceId: 'workspace-1',
369-
requestedNames: ['SHARED_KEY'],
370-
})
371-
372-
expect(result.envVars).toEqual({ SHARED_KEY: 'plain:shared-cipher' })
373-
/**
374-
* The usage trail is read per owner, so a borrowed secret has to be filed under the
375-
* sharer. Attributing it to the actor would surface it under the actor's own
376-
* same-named secret and hide it from the person who can actually rotate it.
377-
*/
378-
expect(result.catalogEntries).toEqual([
379-
expect.objectContaining({ name: 'SHARED_KEY', scope: 'personal', ownerUserId: 'owner-2' }),
380-
])
429+
await expect(
430+
materializeCopilotCodeSecrets({
431+
actorUserId: 'user-1',
432+
workspaceId: 'workspace-1',
433+
requestedNames: ['SHARED_KEY'],
434+
})
435+
).rejects.toThrow('Copilot code cannot access the requested secret: SHARED_KEY')
436+
expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled()
381437
})
382438

383439
it('uses the current encrypted value on every call so rotation is observed', async () => {

apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,22 @@ function overLimitNames(row: { overLimitNames?: unknown } | undefined): Set<stri
125125
return new Set(row.overLimitNames.filter((name): name is string => typeof name === 'string'))
126126
}
127127

128-
function activeAdmin(row: CredentialAccessRow): boolean {
129-
return row.role === 'admin' && row.status === 'active'
128+
/**
129+
* Whether the actor holds a live grant on this credential, at any role.
130+
*
131+
* Deliberately looser than the credential-admin predicate that reveals a value under
132+
* Settings → Secrets, and deliberately equal to what a workflow resolves. A Function block
133+
* reads the same secret at use level through {@link getPersonalAndWorkspaceEnv}, and Copilot
134+
* reaches that path itself via `edit_workflow` + `run_workflow`, so an admin-only bar here
135+
* contained nothing — it redirected a member through a detour that mutates a persisted
136+
* workflow, while the direct path is ephemeral and files a usage row. The view gate stays
137+
* where it can still hold: the Settings mask and the usage trail.
138+
*
139+
* Rechecked in memory even though the query already filters on it, so a later edit to that
140+
* `where` cannot silently widen this.
141+
*/
142+
function activeGrant(row: CredentialAccessRow): boolean {
143+
return row.status === 'active'
130144
}
131145

132146
function unavailableError(names: readonly string[]): CopilotCodeSecretAccessError {
@@ -204,7 +218,6 @@ export async function materializeCopilotCodeSecrets(params: {
204218
eq(credential.workspaceId, params.workspaceId),
205219
inArray(credential.type, ['env_workspace', 'env_personal']),
206220
inArray(credential.envKey, requestedNames),
207-
eq(credentialMember.role, 'admin'),
208221
eq(credentialMember.status, 'active'),
209222
or(
210223
eq(credential.type, 'env_workspace'),
@@ -230,7 +243,7 @@ export async function materializeCopilotCodeSecrets(params: {
230243
row.type === 'env_personal' &&
231244
row.envOwnerUserId !== null &&
232245
row.envOwnerUserId !== params.actorUserId &&
233-
activeAdmin(row)
246+
activeGrant(row)
234247
)
235248

236249
const authorizedSources: AuthorizedEncryptedSecret[] = []
@@ -243,7 +256,7 @@ export async function materializeCopilotCodeSecrets(params: {
243256
workspaceExists &&
244257
(access.canAdmin ||
245258
envCredentialRows.some(
246-
(row) => row.type === 'env_workspace' && row.envKey === name && activeAdmin(row)
259+
(row) => row.type === 'env_workspace' && row.envKey === name && activeGrant(row)
247260
))
248261

249262
if (workspaceAuthorized) {

0 commit comments

Comments
 (0)