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
15 changes: 15 additions & 0 deletions apps/docs/content/docs/en/platform/credentials.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,27 @@ Click **Details** on any secret row to open its detail view.
From here you can:

- View the **Key** and edit the **Value**
- Toggle **Visibility** — show the value unmasked in run output; see [Visibility](#visibility)
- Edit the **Description** — an optional note telling teammates what the secret is for. Workspace secrets only; a personal secret is not shared, so it has none
- Manage **Members** — invite teammates by email and assign them an **Admin** or **Member** role
- Open **See usage** — where this secret has actually been used

Click **Save** to apply changes, or **Back** to return to the list.

### Visibility

By default, a secret's resolved value is masked everywhere Sim shows run output (see [Execution log protection](#execution-log-protection)). For values that aren't actually sensitive — a staging key, a shared base URL — that masking makes your own logs harder to read.

**Show value in logs and Chat** turns masking off for one workspace secret. With it on:

- Run logs, Chat, and code output show the real value instead of `{{KEY}}`
- Files a run writes with the value in them stay readable and attachable
- The Secrets API list includes the value for this secret, so external agents can read it directly instead of scraping logs

The value becomes visible to **anyone who can see this workspace's runs** — including publicly shared log links and log exports, and regardless of member restrictions on the secret itself. Only turn it on for values you'd be comfortable printing in a log.

The switch applies to future runs only. Logs written while the secret was masked stay masked, and anything written while it was visible keeps the value even if you turn masking back on. If another secret holds the same value, that value stays masked — masking always wins a conflict. Workspace secrets only; the same people who can edit the description can flip it.

### See usage

**See usage** lists the runs that resolved this secret: when it was last used, what used it (a workflow, the Sim agent, or an MCP server), how it was triggered, who it resolved under, and a link to the most recent run in Logs. Rows are grouped by day, so a workflow on a schedule reads as one row per day rather than thousands.
Expand Down
107 changes: 100 additions & 7 deletions apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -2447,7 +2447,7 @@
"get": {
"operationId": "listSecrets",
"summary": "List Secrets",
"description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.",
"description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Rows for workspace secrets marked visible (unredacted) include the stored value; every other row is metadata-only and no other response ever carries a value. A workspace API key is rejected with `403`; use a personal API key.",
"tags": ["Secrets"],
"parameters": [
{
Expand Down Expand Up @@ -5581,7 +5581,7 @@
}
]
},
"V2Secret": {
"V2SecretWithValue": {
"type": "object",
"properties": {
"name": {
Expand All @@ -5607,6 +5607,10 @@
],
"description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience."
},
"unredacted": {
"type": "boolean",
"description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret."
},
"role": {
"type": "string",
"enum": ["admin", "member"],
Expand All @@ -5623,20 +5627,32 @@
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"description": "ISO 8601 timestamp when the secret was last updated."
},
"value": {
"description": "The stored secret value. Present only when the workspace secret is marked visible (unredacted); omitted for every other secret.",
"type": "string"
}
},
"required": ["name", "scope", "description", "role", "createdAt", "updatedAt"],
"required": [
"name",
"scope",
"description",
"unredacted",
"role",
"createdAt",
"updatedAt"
],
"additionalProperties": false,
"title": "Secret metadata",
"description": "Public secret metadata without the stored secret value."
"title": "Secret metadata with visible value",
"description": "Secret metadata; the stored value is included only for a workspace secret marked visible (unredacted)."
},
"ListSecretsResponse": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/V2Secret"
"$ref": "#/components/schemas/V2SecretWithValue"
},
"description": "Items in the current page."
},
Expand All @@ -5655,23 +5671,95 @@
"required": ["data", "nextCursor"],
"additionalProperties": false,
"title": "List secrets response",
"description": "Secret metadata visible to the caller without stored values.",
"description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.",
"examples": [
{
"data": [
{
"name": "STRIPE_API_KEY",
"scope": "workspace",
"description": "Production billing key — rotate quarterly.",
"unredacted": false,
"role": "admin",
"createdAt": "2026-06-01T09:14:00.000Z",
"updatedAt": "2026-06-20T14:02:11.000Z"
},
{
"name": "STAGING_BASE_URL",
"scope": "workspace",
"description": "Staging environment base URL.",
"unredacted": true,
"role": "member",
"createdAt": "2026-06-03T11:30:00.000Z",
"updatedAt": "2026-06-21T08:45:09.000Z",
"value": "https://staging.example.com"
}
],
"nextCursor": null
}
]
},
"V2Secret": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"pattern": "^[A-Za-z0-9_]+$",
"description": "Secret name containing only letters, numbers, and underscores."
},
"scope": {
"type": "string",
"enum": ["workspace", "personal"],
"description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace."
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience."
},
"unredacted": {
"type": "boolean",
"description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret."
},
"role": {
"type": "string",
"enum": ["admin", "member"],
"description": "Caller role for the secret."
},
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"description": "ISO 8601 timestamp when the secret was created."
},
"updatedAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"description": "ISO 8601 timestamp when the secret was last updated."
}
},
"required": [
"name",
"scope",
"description",
"unredacted",
"role",
"createdAt",
"updatedAt"
],
"additionalProperties": false,
"title": "Secret metadata",
"description": "Public secret metadata without the stored secret value."
},
"SetSecretResponse": {
"type": "object",
"properties": {
Expand All @@ -5690,6 +5778,7 @@
"name": "STRIPE_API_KEY",
"scope": "workspace",
"description": "Production billing key — rotate quarterly.",
"unredacted": false,
"role": "admin",
"createdAt": "2026-06-01T09:14:00.000Z",
"updatedAt": "2026-06-20T14:02:11.000Z"
Expand Down Expand Up @@ -5729,6 +5818,10 @@
"type": "null"
}
]
},
"unredacted": {
"description": "Opt the workspace secret out of redaction: its value then appears in plaintext in run logs, model-visible content, and files, including publicly shared log links. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave the current setting untouched.",
"type": "boolean"
}
},
"required": ["workspaceId", "scope", "value"],
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/app/api/credentials/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ describe('GET /api/credentials', () => {
type: 'env_personal',
displayName: 'MY_API_KEY',
description: null,
unredacted: false,
providerId: null,
accountId: null,
envKey: 'MY_API_KEY',
Expand Down Expand Up @@ -186,6 +187,7 @@ describe('GET /api/credentials', () => {
type: 'service_account',
displayName: 'Slack custom bot',
description: null,
unredacted: false,
providerId: 'slack-custom-bot',
accountId: null,
envKey: null,
Expand All @@ -201,6 +203,7 @@ describe('GET /api/credentials', () => {
type: 'oauth',
displayName: 'Google account',
description: null,
unredacted: false,
providerId: 'google-email',
accountId: 'google-account',
envKey: null,
Expand Down Expand Up @@ -317,6 +320,7 @@ describe('POST /api/credentials', () => {
type: 'service_account',
displayName: 'Service account',
description: null,
unredacted: false,
providerId: 'zoom-service-account',
accountId: null,
envKey: null,
Expand Down Expand Up @@ -351,6 +355,7 @@ describe('POST /api/credentials', () => {
type: 'service_account',
displayName: 'Zoom account acct_123',
description: null,
unredacted: false,
providerId: 'zoom-service-account',
accountId: null,
envKey: null,
Expand Down Expand Up @@ -404,6 +409,7 @@ describe('POST /api/credentials', () => {
type: 'service_account',
displayName: 'Oracle NetSuite 1234567',
description: null,
unredacted: false,
providerId: 'netsuite-service-account',
accountId: null,
envKey: null,
Expand Down
108 changes: 108 additions & 0 deletions apps/sim/app/api/function/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,114 @@ describe('Function Execute API Route', () => {
)
})

it('classifies exports exact-empty when the only compiled secret is exempt, still reporting its name', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
result: 'done',
stdout: '',
sandboxId: 'sandbox-123',
exportedFiles: {
'/home/user/secret.txt': 'Bearer secret-value',
'/home/user/small.jpg': '/9j/4AAQ',
},
})

const response = await POST(
createMockRequest(
'POST',
{
code: 'print("{{API_KEY}}")',
language: 'python',
workspaceId: 'workspace-1',
envVars: { API_KEY: 'secret-value' },
unredactedSecretNames: ['API_KEY'],
outputs: {
files: [
{
path: 'files/secret.txt',
sandboxPath: '/home/user/secret.txt',
mimeType: 'text/plain',
},
{
path: 'files/small.jpg',
sandboxPath: '/home/user/small.jpg',
mimeType: 'image/jpeg',
},
],
},
},
{
'x-sim-request-private-tool-metadata': 'resolved-secret-names-durable-files-v2',
}
)
)
const data = await response.json()

expect(response.status).toBe(200)
// The text export carries the exempt plaintext yet records no entry for it.
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({ path: 'files/secret.txt' }),
secretProvenance: { status: 'exact', entries: [] },
})
)
// With only exempt material in scope the binary export must not lock as unknown.
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({ path: 'files/small.jpg' }),
secretProvenance: { status: 'exact', entries: [] },
})
)
// The exemption changes file classification only — the usage trail still sees the name.
expect(data.__resolvedSecretNames).toEqual(['API_KEY'])
})

it('keeps recording the non-exempt owner when an exempt name shares its plaintext', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
result: 'done',
stdout: '',
sandboxId: 'sandbox-123',
exportedFiles: { '/home/user/secret.txt': 'Bearer shared-value' },
})

const response = await POST(
createMockRequest('POST', {
code: 'print("{{EXEMPT_KEY}}", "{{OTHER_KEY}}")',
language: 'python',
workspaceId: 'workspace-1',
envVars: { EXEMPT_KEY: 'shared-value', OTHER_KEY: 'shared-value' },
unredactedSecretNames: ['EXEMPT_KEY'],
outputs: {
files: [
{
path: 'files/secret.txt',
sandboxPath: '/home/user/secret.txt',
mimeType: 'text/plain',
},
],
},
})
)

expect(response.status).toBe(200)
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
expect.objectContaining({
secretProvenance: {
status: 'exact',
entries: [
{
name: 'OTHER_KEY',
encryptedValue: 'encrypted:shared-value',
sourceUserId: 'user-123',
sourceWorkspaceId: 'workspace-1',
},
],
},
})
)
})

it('classifies text exports against private mounted-file provenance', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
Expand Down
Loading
Loading