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:
Vikhyath Mondreti
2026-08-22 18:33:04 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 9ff1d77306
commit 81ff24a2fc
3 changed files with 103 additions and 31 deletions
@@ -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) {