mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9ff1d77306
commit
81ff24a2fc
@@ -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, 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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
<Callout type="info">
|
||||
When a workspace secret and a personal secret share the same key name, the **workspace secret takes precedence**.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user