Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions apps/docs/content/docs/en/platform/credentials.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,17 @@ Both masking and model-bound projection match only exact values in either case.

### Copilot code execution

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.
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.

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.

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

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

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.
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.

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.

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

| | Workspace | Personal |
|---|---|---|
| **Visibility** | All workspace members, including external workspace members | Only you |
| **Use in workflows** | Any member can use | Only you can use |
| **Who sees the name** | All workspace members, including external workspace members | Only you |
| **Who sees the value** | Workspace admins and that secret's Credential Admins | Only you |
| **Use in workflows and code** | Any member can use | Only you can use |
Comment thread
icecrasher321 marked this conversation as resolved.
| **Best for** | Production workflows, shared services | Testing, personal API keys |
| **Who can edit** | Workspace admins | Only you |
| **Who can edit** | Workspace admins and that secret's Credential Admins | Only you |

<Callout type="info">
When a workspace secret and a personal secret share the same key name, the **workspace secret takes precedence**.
Expand Down
98 changes: 77 additions & 21 deletions apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,38 @@ describe('materializeCopilotCodeSecrets', () => {
expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' })
})

/**
* Use-level on purpose: a workflow Function block resolves the same secret for the same
* member, and Copilot can author and run such a workflow itself. The admin bar stays on the
* Settings mask and the usage trail, which Copilot cannot route around.
*/
it('lets an active per-secret member mount a workspace secret', async () => {
queueSources({
workspace: { API_KEY: 'workspace-cipher' },
credentials: [
credentialRow({
type: 'env_workspace',
envKey: 'API_KEY',
role: 'member',
status: 'active',
}),
],
})

const result = await materializeCopilotCodeSecrets({
actorUserId: 'user-1',
workspaceId: 'workspace-1',
requestedNames: ['API_KEY'],
})

expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' })
})

it.each([
['member', 'active'],
['admin', 'revoked'],
['admin', 'pending'],
['member', 'revoked'],
['member', 'pending'],
] as const)('denies a workspace secret for a %s/%s credential grant', async (role, status) => {
queueSources({
workspace: { API_KEY: 'workspace-cipher' },
Expand Down Expand Up @@ -263,7 +291,7 @@ describe('materializeCopilotCodeSecrets', () => {
type: 'env_workspace',
envKey: 'API_KEY',
role: 'member',
status: 'active',
status: 'revoked',
}),
],
})
Expand All @@ -286,7 +314,7 @@ describe('materializeCopilotCodeSecrets', () => {
type: 'env_workspace',
envKey: 'API_KEY',
role: 'member',
status: 'active',
status: 'revoked',
}),
],
})
Expand Down Expand Up @@ -348,36 +376,64 @@ describe('materializeCopilotCodeSecrets', () => {
expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled()
})

it('mounts another owner personal secret only for an active per-secret admin', async () => {
it.each(['admin', 'member'] as const)(
'mounts another owner personal secret for an active per-secret %s',
async (role) => {
queueSources({
credentials: [
credentialRow({
type: 'env_personal',
envKey: 'SHARED_KEY',
envOwnerUserId: 'owner-2',
role,
status: 'active',
encryptedValue: 'shared-cipher',
encryptedValueBytes: 13,
}),
],
})

const result = await materializeCopilotCodeSecrets({
actorUserId: 'user-1',
workspaceId: 'workspace-1',
requestedNames: ['SHARED_KEY'],
})

expect(result.envVars).toEqual({ SHARED_KEY: 'plain:shared-cipher' })
/**
* The usage trail is read per owner, so a borrowed secret has to be filed under the
* sharer. Attributing it to the actor would surface it under the actor's own
* same-named secret and hide it from the person who can actually rotate it.
*/
expect(result.catalogEntries).toEqual([
expect.objectContaining({ name: 'SHARED_KEY', scope: 'personal', ownerUserId: 'owner-2' }),
])
}
)

it('does not mount another owner personal secret on a revoked grant', async () => {
queueSources({
credentials: [
credentialRow({
type: 'env_personal',
envKey: 'SHARED_KEY',
envOwnerUserId: 'owner-2',
role: 'admin',
status: 'active',
role: 'member',
status: 'revoked',
encryptedValue: 'shared-cipher',
encryptedValueBytes: 13,
}),
],
})

const result = await materializeCopilotCodeSecrets({
actorUserId: 'user-1',
workspaceId: 'workspace-1',
requestedNames: ['SHARED_KEY'],
})

expect(result.envVars).toEqual({ SHARED_KEY: 'plain:shared-cipher' })
/**
* The usage trail is read per owner, so a borrowed secret has to be filed under the
* sharer. Attributing it to the actor would surface it under the actor's own
* same-named secret and hide it from the person who can actually rotate it.
*/
expect(result.catalogEntries).toEqual([
expect.objectContaining({ name: 'SHARED_KEY', scope: 'personal', ownerUserId: 'owner-2' }),
])
await expect(
materializeCopilotCodeSecrets({
actorUserId: 'user-1',
workspaceId: 'workspace-1',
requestedNames: ['SHARED_KEY'],
})
).rejects.toThrow('Copilot code cannot access the requested secret: SHARED_KEY')
expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled()
})

it('uses the current encrypted value on every call so rotation is observed', async () => {
Expand Down
23 changes: 18 additions & 5 deletions apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,22 @@ function overLimitNames(row: { overLimitNames?: unknown } | undefined): Set<stri
return new Set(row.overLimitNames.filter((name): name is string => typeof name === 'string'))
}

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

function unavailableError(names: readonly string[]): CopilotCodeSecretAccessError {
Expand Down Expand Up @@ -204,7 +218,6 @@ export async function materializeCopilotCodeSecrets(params: {
eq(credential.workspaceId, params.workspaceId),
inArray(credential.type, ['env_workspace', 'env_personal']),
inArray(credential.envKey, requestedNames),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active'),
or(
eq(credential.type, 'env_workspace'),
Expand All @@ -230,7 +243,7 @@ export async function materializeCopilotCodeSecrets(params: {
row.type === 'env_personal' &&
row.envOwnerUserId !== null &&
row.envOwnerUserId !== params.actorUserId &&
activeAdmin(row)
activeGrant(row)
)

const authorizedSources: AuthorizedEncryptedSecret[] = []
Expand All @@ -243,7 +256,7 @@ export async function materializeCopilotCodeSecrets(params: {
workspaceExists &&
(access.canAdmin ||
envCredentialRows.some(
(row) => row.type === 'env_workspace' && row.envKey === name && activeAdmin(row)
(row) => row.type === 'env_workspace' && row.envKey === name && activeGrant(row)
))

if (workspaceAuthorized) {
Expand Down
Loading