From e16f36dda89f624f6727fbd8d2f062c5289dc583 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 22 Aug 2026 18:16:35 -0700 Subject: [PATCH 1/2] improvement(secrets): gate Copilot code mounting at use level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../content/docs/en/platform/credentials.mdx | 13 ++- .../secret-mount-materializer.server.test.ts | 98 +++++++++++++++---- .../tools/secret-mount-materializer.server.ts | 23 ++++- 3 files changed, 103 insertions(+), 31 deletions(-) diff --git a/apps/docs/content/docs/en/platform/credentials.mdx b/apps/docs/content/docs/en/platform/credentials.mdx index fbc34584af2..3a63703ef30 100644 --- a/apps/docs/content/docs/en/platform/credentials.mdx +++ b/apps/docs/content/docs/en/platform/credentials.mdx @@ -91,7 +91,9 @@ 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, any secret shared with you as a Credential Member or Credential Admin, and every Workspace secret when you are a workspace admin. 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: @@ -99,7 +101,7 @@ Headless surfaces use their saved **Secret access** setting: - **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. @@ -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 | | **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 | When a workspace secret and a personal secret share the same key name, the **workspace secret takes precedence**. diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts index 59fb021140a..597599c9cb1 100644 --- a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts @@ -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' }, @@ -263,7 +291,7 @@ describe('materializeCopilotCodeSecrets', () => { type: 'env_workspace', envKey: 'API_KEY', role: 'member', - status: 'active', + status: 'revoked', }), ], }) @@ -286,7 +314,7 @@ describe('materializeCopilotCodeSecrets', () => { type: 'env_workspace', envKey: 'API_KEY', role: 'member', - status: 'active', + status: 'revoked', }), ], }) @@ -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 () => { diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts index 4bfefe692a1..f0798ef8fe3 100644 --- a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts @@ -125,8 +125,22 @@ function overLimitNames(row: { overLimitNames?: unknown } | undefined): Set 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 { @@ -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'), @@ -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[] = [] @@ -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) { From 3ea842f8bd05d77cc233836c510d422725328297 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 22 Aug 2026 18:24:33 -0700 Subject: [PATCH 2/2] 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) --- apps/docs/content/docs/en/platform/credentials.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/docs/en/platform/credentials.mdx b/apps/docs/content/docs/en/platform/credentials.mdx index 3a63703ef30..2584a353ac1 100644 --- a/apps/docs/content/docs/en/platform/credentials.mdx +++ b/apps/docs/content/docs/en/platform/credentials.mdx @@ -91,7 +91,7 @@ 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 be allowed to **use** the secret — the same set a workflow resolves for them: your own Personal secrets, any secret shared with you as a Credential Member or Credential Admin, and every Workspace secret when you are a workspace admin. A secret you hold no grant on does not mount, and neither does one whose grant is revoked or still pending. +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.