Skip to content

Commit 0a5b380

Browse files
feat(secrets): let workspace secrets opt out of redaction (#7045)
* feat(secrets): let workspace secrets opt out of redaction * fix(secrets): certify no sandbox exemptions once the registry is incomplete * feat(secrets): carry visible secret values on the v2 list and document visibility * fix(secrets): read visible values by own property so prototype-named secrets cannot poison the list
1 parent 3251bf1 commit 0a5b380

51 files changed

Lines changed: 21945 additions & 44 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/platform/credentials.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,27 @@ Click **Details** on any secret row to open its detail view.
119119
From here you can:
120120

121121
- View the **Key** and edit the **Value**
122+
- Toggle **Visibility** — show the value unmasked in run output; see [Visibility](#visibility)
122123
- 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
123124
- Manage **Members** — invite teammates by email and assign them an **Admin** or **Member** role
124125
- Open **See usage** — where this secret has actually been used
125126

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

129+
### Visibility
130+
131+
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.
132+
133+
**Show value in logs and Chat** turns masking off for one workspace secret. With it on:
134+
135+
- Run logs, Chat, and code output show the real value instead of `{{KEY}}`
136+
- Files a run writes with the value in them stay readable and attachable
137+
- The Secrets API list includes the value for this secret, so external agents can read it directly instead of scraping logs
138+
139+
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.
140+
141+
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.
142+
128143
### See usage
129144

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

apps/docs/openapi-v2-resources.json

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2447,7 +2447,7 @@
24472447
"get": {
24482448
"operationId": "listSecrets",
24492449
"summary": "List Secrets",
2450-
"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.",
2450+
"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.",
24512451
"tags": ["Secrets"],
24522452
"parameters": [
24532453
{
@@ -5581,7 +5581,7 @@
55815581
}
55825582
]
55835583
},
5584-
"V2Secret": {
5584+
"V2SecretWithValue": {
55855585
"type": "object",
55865586
"properties": {
55875587
"name": {
@@ -5607,6 +5607,10 @@
56075607
],
56085608
"description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience."
56095609
},
5610+
"unredacted": {
5611+
"type": "boolean",
5612+
"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."
5613+
},
56105614
"role": {
56115615
"type": "string",
56125616
"enum": ["admin", "member"],
@@ -5623,20 +5627,32 @@
56235627
"format": "date-time",
56245628
"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))$",
56255629
"description": "ISO 8601 timestamp when the secret was last updated."
5630+
},
5631+
"value": {
5632+
"description": "The stored secret value. Present only when the workspace secret is marked visible (unredacted); omitted for every other secret.",
5633+
"type": "string"
56265634
}
56275635
},
5628-
"required": ["name", "scope", "description", "role", "createdAt", "updatedAt"],
5636+
"required": [
5637+
"name",
5638+
"scope",
5639+
"description",
5640+
"unredacted",
5641+
"role",
5642+
"createdAt",
5643+
"updatedAt"
5644+
],
56295645
"additionalProperties": false,
5630-
"title": "Secret metadata",
5631-
"description": "Public secret metadata without the stored secret value."
5646+
"title": "Secret metadata with visible value",
5647+
"description": "Secret metadata; the stored value is included only for a workspace secret marked visible (unredacted)."
56325648
},
56335649
"ListSecretsResponse": {
56345650
"type": "object",
56355651
"properties": {
56365652
"data": {
56375653
"type": "array",
56385654
"items": {
5639-
"$ref": "#/components/schemas/V2Secret"
5655+
"$ref": "#/components/schemas/V2SecretWithValue"
56405656
},
56415657
"description": "Items in the current page."
56425658
},
@@ -5655,23 +5671,95 @@
56555671
"required": ["data", "nextCursor"],
56565672
"additionalProperties": false,
56575673
"title": "List secrets response",
5658-
"description": "Secret metadata visible to the caller without stored values.",
5674+
"description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.",
56595675
"examples": [
56605676
{
56615677
"data": [
56625678
{
56635679
"name": "STRIPE_API_KEY",
56645680
"scope": "workspace",
56655681
"description": "Production billing key — rotate quarterly.",
5682+
"unredacted": false,
56665683
"role": "admin",
56675684
"createdAt": "2026-06-01T09:14:00.000Z",
56685685
"updatedAt": "2026-06-20T14:02:11.000Z"
5686+
},
5687+
{
5688+
"name": "STAGING_BASE_URL",
5689+
"scope": "workspace",
5690+
"description": "Staging environment base URL.",
5691+
"unredacted": true,
5692+
"role": "member",
5693+
"createdAt": "2026-06-03T11:30:00.000Z",
5694+
"updatedAt": "2026-06-21T08:45:09.000Z",
5695+
"value": "https://staging.example.com"
56695696
}
56705697
],
56715698
"nextCursor": null
56725699
}
56735700
]
56745701
},
5702+
"V2Secret": {
5703+
"type": "object",
5704+
"properties": {
5705+
"name": {
5706+
"type": "string",
5707+
"minLength": 1,
5708+
"maxLength": 255,
5709+
"pattern": "^[A-Za-z0-9_]+$",
5710+
"description": "Secret name containing only letters, numbers, and underscores."
5711+
},
5712+
"scope": {
5713+
"type": "string",
5714+
"enum": ["workspace", "personal"],
5715+
"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."
5716+
},
5717+
"description": {
5718+
"anyOf": [
5719+
{
5720+
"type": "string"
5721+
},
5722+
{
5723+
"type": "null"
5724+
}
5725+
],
5726+
"description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience."
5727+
},
5728+
"unredacted": {
5729+
"type": "boolean",
5730+
"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."
5731+
},
5732+
"role": {
5733+
"type": "string",
5734+
"enum": ["admin", "member"],
5735+
"description": "Caller role for the secret."
5736+
},
5737+
"createdAt": {
5738+
"type": "string",
5739+
"format": "date-time",
5740+
"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))$",
5741+
"description": "ISO 8601 timestamp when the secret was created."
5742+
},
5743+
"updatedAt": {
5744+
"type": "string",
5745+
"format": "date-time",
5746+
"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))$",
5747+
"description": "ISO 8601 timestamp when the secret was last updated."
5748+
}
5749+
},
5750+
"required": [
5751+
"name",
5752+
"scope",
5753+
"description",
5754+
"unredacted",
5755+
"role",
5756+
"createdAt",
5757+
"updatedAt"
5758+
],
5759+
"additionalProperties": false,
5760+
"title": "Secret metadata",
5761+
"description": "Public secret metadata without the stored secret value."
5762+
},
56755763
"SetSecretResponse": {
56765764
"type": "object",
56775765
"properties": {
@@ -5690,6 +5778,7 @@
56905778
"name": "STRIPE_API_KEY",
56915779
"scope": "workspace",
56925780
"description": "Production billing key — rotate quarterly.",
5781+
"unredacted": false,
56935782
"role": "admin",
56945783
"createdAt": "2026-06-01T09:14:00.000Z",
56955784
"updatedAt": "2026-06-20T14:02:11.000Z"
@@ -5729,6 +5818,10 @@
57295818
"type": "null"
57305819
}
57315820
]
5821+
},
5822+
"unredacted": {
5823+
"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.",
5824+
"type": "boolean"
57325825
}
57335826
},
57345827
"required": ["workspaceId", "scope", "value"],

apps/sim/app/api/credentials/route.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ describe('GET /api/credentials', () => {
143143
type: 'env_personal',
144144
displayName: 'MY_API_KEY',
145145
description: null,
146+
unredacted: false,
146147
providerId: null,
147148
accountId: null,
148149
envKey: 'MY_API_KEY',
@@ -186,6 +187,7 @@ describe('GET /api/credentials', () => {
186187
type: 'service_account',
187188
displayName: 'Slack custom bot',
188189
description: null,
190+
unredacted: false,
189191
providerId: 'slack-custom-bot',
190192
accountId: null,
191193
envKey: null,
@@ -201,6 +203,7 @@ describe('GET /api/credentials', () => {
201203
type: 'oauth',
202204
displayName: 'Google account',
203205
description: null,
206+
unredacted: false,
204207
providerId: 'google-email',
205208
accountId: 'google-account',
206209
envKey: null,
@@ -317,6 +320,7 @@ describe('POST /api/credentials', () => {
317320
type: 'service_account',
318321
displayName: 'Service account',
319322
description: null,
323+
unredacted: false,
320324
providerId: 'zoom-service-account',
321325
accountId: null,
322326
envKey: null,
@@ -351,6 +355,7 @@ describe('POST /api/credentials', () => {
351355
type: 'service_account',
352356
displayName: 'Zoom account acct_123',
353357
description: null,
358+
unredacted: false,
354359
providerId: 'zoom-service-account',
355360
accountId: null,
356361
envKey: null,
@@ -404,6 +409,7 @@ describe('POST /api/credentials', () => {
404409
type: 'service_account',
405410
displayName: 'Oracle NetSuite 1234567',
406411
description: null,
412+
unredacted: false,
407413
providerId: 'netsuite-service-account',
408414
accountId: null,
409415
envKey: null,

apps/sim/app/api/function/execute/route.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,114 @@ describe('Function Execute API Route', () => {
865865
)
866866
})
867867

868+
it('classifies exports exact-empty when the only compiled secret is exempt, still reporting its name', async () => {
869+
envFlagsMock.isRemoteSandboxEnabled = true
870+
mockExecuteInSandbox.mockResolvedValueOnce({
871+
result: 'done',
872+
stdout: '',
873+
sandboxId: 'sandbox-123',
874+
exportedFiles: {
875+
'/home/user/secret.txt': 'Bearer secret-value',
876+
'/home/user/small.jpg': '/9j/4AAQ',
877+
},
878+
})
879+
880+
const response = await POST(
881+
createMockRequest(
882+
'POST',
883+
{
884+
code: 'print("{{API_KEY}}")',
885+
language: 'python',
886+
workspaceId: 'workspace-1',
887+
envVars: { API_KEY: 'secret-value' },
888+
unredactedSecretNames: ['API_KEY'],
889+
outputs: {
890+
files: [
891+
{
892+
path: 'files/secret.txt',
893+
sandboxPath: '/home/user/secret.txt',
894+
mimeType: 'text/plain',
895+
},
896+
{
897+
path: 'files/small.jpg',
898+
sandboxPath: '/home/user/small.jpg',
899+
mimeType: 'image/jpeg',
900+
},
901+
],
902+
},
903+
},
904+
{
905+
'x-sim-request-private-tool-metadata': 'resolved-secret-names-durable-files-v2',
906+
}
907+
)
908+
)
909+
const data = await response.json()
910+
911+
expect(response.status).toBe(200)
912+
// The text export carries the exempt plaintext yet records no entry for it.
913+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
914+
expect.objectContaining({
915+
target: expect.objectContaining({ path: 'files/secret.txt' }),
916+
secretProvenance: { status: 'exact', entries: [] },
917+
})
918+
)
919+
// With only exempt material in scope the binary export must not lock as unknown.
920+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
921+
expect.objectContaining({
922+
target: expect.objectContaining({ path: 'files/small.jpg' }),
923+
secretProvenance: { status: 'exact', entries: [] },
924+
})
925+
)
926+
// The exemption changes file classification only — the usage trail still sees the name.
927+
expect(data.__resolvedSecretNames).toEqual(['API_KEY'])
928+
})
929+
930+
it('keeps recording the non-exempt owner when an exempt name shares its plaintext', async () => {
931+
envFlagsMock.isRemoteSandboxEnabled = true
932+
mockExecuteInSandbox.mockResolvedValueOnce({
933+
result: 'done',
934+
stdout: '',
935+
sandboxId: 'sandbox-123',
936+
exportedFiles: { '/home/user/secret.txt': 'Bearer shared-value' },
937+
})
938+
939+
const response = await POST(
940+
createMockRequest('POST', {
941+
code: 'print("{{EXEMPT_KEY}}", "{{OTHER_KEY}}")',
942+
language: 'python',
943+
workspaceId: 'workspace-1',
944+
envVars: { EXEMPT_KEY: 'shared-value', OTHER_KEY: 'shared-value' },
945+
unredactedSecretNames: ['EXEMPT_KEY'],
946+
outputs: {
947+
files: [
948+
{
949+
path: 'files/secret.txt',
950+
sandboxPath: '/home/user/secret.txt',
951+
mimeType: 'text/plain',
952+
},
953+
],
954+
},
955+
})
956+
)
957+
958+
expect(response.status).toBe(200)
959+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
960+
expect.objectContaining({
961+
secretProvenance: {
962+
status: 'exact',
963+
entries: [
964+
{
965+
name: 'OTHER_KEY',
966+
encryptedValue: 'encrypted:shared-value',
967+
sourceUserId: 'user-123',
968+
sourceWorkspaceId: 'workspace-1',
969+
},
970+
],
971+
},
972+
})
973+
)
974+
})
975+
868976
it('classifies text exports against private mounted-file provenance', async () => {
869977
envFlagsMock.isRemoteSandboxEnabled = true
870978
mockExecuteInSandbox.mockResolvedValueOnce({

0 commit comments

Comments
 (0)