Skip to content

Commit b279c1c

Browse files
committed
feat(secrets): let workspace secrets opt out of redaction
1 parent 445ef62 commit b279c1c

46 files changed

Lines changed: 21498 additions & 23 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/openapi-v2-resources.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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"],
@@ -5625,7 +5629,15 @@
56255629
"description": "ISO 8601 timestamp when the secret was last updated."
56265630
}
56275631
},
5628-
"required": ["name", "scope", "description", "role", "createdAt", "updatedAt"],
5632+
"required": [
5633+
"name",
5634+
"scope",
5635+
"description",
5636+
"unredacted",
5637+
"role",
5638+
"createdAt",
5639+
"updatedAt"
5640+
],
56295641
"additionalProperties": false,
56305642
"title": "Secret metadata",
56315643
"description": "Public secret metadata without the stored secret value."
@@ -5663,6 +5675,7 @@
56635675
"name": "STRIPE_API_KEY",
56645676
"scope": "workspace",
56655677
"description": "Production billing key — rotate quarterly.",
5678+
"unredacted": false,
56665679
"role": "admin",
56675680
"createdAt": "2026-06-01T09:14:00.000Z",
56685681
"updatedAt": "2026-06-20T14:02:11.000Z"
@@ -5690,6 +5703,7 @@
56905703
"name": "STRIPE_API_KEY",
56915704
"scope": "workspace",
56925705
"description": "Production billing key — rotate quarterly.",
5706+
"unredacted": false,
56935707
"role": "admin",
56945708
"createdAt": "2026-06-01T09:14:00.000Z",
56955709
"updatedAt": "2026-06-20T14:02:11.000Z"
@@ -5729,6 +5743,10 @@
57295743
"type": "null"
57305744
}
57315745
]
5746+
},
5747+
"unredacted": {
5748+
"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.",
5749+
"type": "boolean"
57325750
}
57335751
},
57345752
"required": ["workspaceId", "scope", "value"],

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({

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,13 @@ interface FunctionRouteExecutionContext {
991991
outputSecretMatcher?: ResolvedSecretMatcher
992992
outputSecretNamesByScanLiteral: Map<string, string[]>
993993
outputSecretPlaintextsByName: Map<string, string>
994+
/**
995+
* In-scope names the caller's registry certified as redaction-exempt. They stay in
996+
* `outputSecretPlaintextsByName` — the response's resolved-name reporting and the usage
997+
* trail must not lose them — but contribute no scan literals, so exported files carrying
998+
* only their values classify exact-empty instead of locking.
999+
*/
1000+
unredactedSecretNames: Set<string>
9941001
mountedFileSecretProvenanceScanner?: MountedFileSecretProvenanceScanner
9951002
}
9961003

@@ -1191,13 +1198,23 @@ function activateReferencedSecretProvenance(context: FunctionRouteExecutionConte
11911198
}
11921199
}
11931200

1201+
/** Compiled secret names that still demand redaction — the exempt ones don't count. */
1202+
function countProtectedOutputSecretNames(context: FunctionRouteExecutionContext): number {
1203+
let count = 0
1204+
for (const name of context.outputSecretPlaintextsByName.keys()) {
1205+
if (!context.unredactedSecretNames.has(name)) count += 1
1206+
}
1207+
return count
1208+
}
1209+
11941210
/**
11951211
* True when this execution compiled a secret placeholder or received a mounted file with verified
11961212
* secret provenance. Ordinary mounts without a provenance envelope are user data, not evidence that
1197-
* a Sim secret was resolved in this call.
1213+
* a Sim secret was resolved in this call. Exempt names don't count: a binary export whose only
1214+
* in-scope secrets are redaction-exempt is deliberately classified exact-empty rather than locked.
11981215
*/
11991216
function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean {
1200-
if (context.outputSecretPlaintextsByName.size > 0) return true
1217+
if (countProtectedOutputSecretNames(context) > 0) return true
12011218
return context.mountedFileSecretProvenanceScanner?.hasSecrets ?? false
12021219
}
12031220

@@ -1225,7 +1242,7 @@ async function getOutputFileSecretProvenance(
12251242
status: 'exact' as const,
12261243
entries: [],
12271244
}
1228-
if (context.outputSecretPlaintextsByName.size === 0) {
1245+
if (countProtectedOutputSecretNames(context) === 0) {
12291246
return mountedFileProvenance
12301247
}
12311248
if (!context.outputSecretMatcher) return { status: 'unknown' }
@@ -1914,6 +1931,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
19141931
envVars: rawEnvVars = {},
19151932
secretScope,
19161933
mountedSecrets,
1934+
unredactedSecretNames = [],
19171935
sandboxId: selectedSandboxId,
19181936
blockData = {},
19191937
blockNameMapping = {},
@@ -2035,6 +2053,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
20352053
privateResolvedSecretNamesMetadataType,
20362054
outputSecretNamesByScanLiteral: new Map(),
20372055
outputSecretPlaintextsByName: new Map(),
2056+
unredactedSecretNames: new Set(
2057+
unredactedSecretNames.filter((name) => Object.hasOwn(envVars, name))
2058+
),
20382059
mountedFileSecretProvenanceScanner,
20392060
}
20402061

@@ -2069,6 +2090,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
20692090
const plaintext = envVars[name]
20702091
if (!plaintext) continue
20712092
routeContext.outputSecretPlaintextsByName.set(name, plaintext)
2093+
/**
2094+
* Skipped per NAME, never per literal: a plaintext shared by an exempt and a non-exempt
2095+
* name keeps its literal through the non-exempt owner, so the export still records that
2096+
* owner's provenance and the file still locks.
2097+
*/
2098+
if (routeContext.unredactedSecretNames.has(name)) continue
20722099
const scanLiterals = new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)])
20732100
for (const scanLiteral of scanLiterals) {
20742101
const names = routeContext.outputSecretNamesByScanLiteral.get(scanLiteral) ?? []

apps/sim/app/api/v2/secrets/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2S
1414
name: row.envKey,
1515
scope: row.type === 'env_workspace' ? 'workspace' : 'personal',
1616
description: row.type === 'env_workspace' ? row.description : null,
17+
unredacted: row.type === 'env_workspace' ? row.unredacted : false,
1718
role: row.role,
1819
createdAt: row.createdAt.toISOString(),
1920
updatedAt: row.updatedAt.toISOString(),

apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,17 @@ export function useCredentialDetailForm({
5656

5757
const [displayNameDraft, setDisplayNameDraft] = useState('')
5858
const [descriptionDraft, setDescriptionDraft] = useState('')
59+
const [unredactedDraft, setUnredactedDraft] = useState(false)
5960
const [seededCredentialId, setSeededCredentialId] = useState<string | null>(null)
6061

6162
// Seed drafts when the credential first resolves (or the route id changes); a
6263
// background refetch of the same credential must not clobber an in-progress
6364
// edit — Discard is the one way to reset.
64-
/** Applies a credential to both drafts — the one definition of "reset to server state". */
65+
/** Applies a credential to every draft — the one definition of "reset to server state". */
6566
const seedDrafts = useCallback((source: WorkspaceCredential) => {
6667
setDisplayNameDraft(source.displayName)
6768
setDescriptionDraft(source.description ?? '')
69+
setUnredactedDraft(source.unredacted)
6870
}, [])
6971

7072
if (credential && credential.id !== seededCredentialId) {
@@ -76,7 +78,8 @@ export function useCredentialDetailForm({
7678
const isDescriptionDirty = credential
7779
? descriptionDraft !== (credential.description || '')
7880
: false
79-
const isMetadataDirty = isDisplayNameDirty || isDescriptionDirty
81+
const isUnredactedDirty = credential ? unredactedDraft !== credential.unredacted : false
82+
const isMetadataDirty = isDisplayNameDirty || isDescriptionDirty || isUnredactedDirty
8083
const isSectionDirty = section?.isDirty ?? false
8184
const isDirty = isMetadataDirty || isSectionDirty
8285
const isSaving = updateCredential.isPending || (section?.isSaving ?? false)
@@ -93,6 +96,7 @@ export function useCredentialDetailForm({
9396
credentialId: credential.id,
9497
...(isDisplayNameDirty ? { displayName: displayNameDraft.trim() } : {}),
9598
...(isDescriptionDirty ? { description: descriptionDraft.trim() || null } : {}),
99+
...(isUnredactedDirty ? { unredacted: unredactedDraft } : {}),
96100
})
97101
if (isDisplayNameDirty) setDisplayNameDraft((value) => value.trim())
98102
if (isDescriptionDirty) setDescriptionDraft((value) => value.trim())
@@ -111,8 +115,10 @@ export function useCredentialDetailForm({
111115
section,
112116
isDisplayNameDirty,
113117
isDescriptionDirty,
118+
isUnredactedDirty,
114119
displayNameDraft,
115120
descriptionDraft,
121+
unredactedDraft,
116122
updateCredential.mutateAsync,
117123
])
118124

@@ -126,6 +132,8 @@ export function useCredentialDetailForm({
126132
setDisplayNameDraft,
127133
descriptionDraft,
128134
setDescriptionDraft,
135+
unredactedDraft,
136+
setUnredactedDraft,
129137
isDirty,
130138
save,
131139
discard,

apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
'use client'
22

33
import { useState } from 'react'
4-
import { Chip, ChipCopyInput, ChipLink, ChipModalTabs, ChipTextarea } from '@sim/emcn'
4+
import {
5+
Chip,
6+
ChipCopyInput,
7+
ChipLink,
8+
ChipModalTabs,
9+
ChipTextarea,
10+
Label,
11+
Switch,
12+
} from '@sim/emcn'
513
import { ArrowLeft, Clock, Key, Send } from '@sim/emcn/icons'
614
import { useQueryState } from 'nuqs'
715
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
@@ -246,6 +254,27 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
246254
/>
247255
</DetailSection>
248256

257+
{!isPersonal && (
258+
<DetailSection title='Visibility'>
259+
<div className='flex items-center justify-between'>
260+
<div className='flex flex-col gap-1'>
261+
<Label htmlFor='secret-unredacted'>Show value in logs and Chat</Label>
262+
<p className='text-[var(--text-muted)] text-caption'>
263+
{
264+
'The value is visible to anyone who can see this workspace’s runs, including shared log links.'
265+
}
266+
</p>
267+
</div>
268+
<Switch
269+
id='secret-unredacted'
270+
checked={form.unredactedDraft}
271+
onCheckedChange={form.setUnredactedDraft}
272+
disabled={!isWorkspaceSecretAdmin}
273+
/>
274+
</div>
275+
</DetailSection>
276+
)}
277+
249278
{!isPersonal && (
250279
<DetailSection title='Description'>
251280
<ChipTextarea

apps/sim/background/webhook-execution.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,7 @@ async function executeWebhookJobInternal(
825825
workspaceDecrypted: secretEnvironment.workspaceDecrypted,
826826
decryptionFailures: secretEnvironment.decryptionFailures,
827827
personalOwners: secretEnvironment.personalOwners,
828+
workspaceUnredactedKeys: secretEnvironment.workspaceUnredactedKeys,
828829
scope: secretScope,
829830
})
830831
} catch (error) {

apps/sim/executor/handlers/function/function-handler.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,16 @@ export class FunctionBlockHandler implements BlockHandler {
7777
mountedSecrets: inputs.mountedSecrets,
7878
})
7979

80+
const unredactedSecretNames = ctx.resolvedSecretTraceRegistry?.getUnredactedSecretNames() ?? []
81+
8082
const toolParams = {
8183
code: codeContent,
8284
...(sourceCode ? { sourceCode } : {}),
8385
language: inputs.language || DEFAULT_CODE_LANGUAGE,
8486
timeout,
8587
...(inputs.sandboxId ? { sandboxId: inputs.sandboxId } : {}),
8688
...(secretMountPolicy ?? {}),
89+
...(unredactedSecretNames.length > 0 ? { unredactedSecretNames } : {}),
8790
envVars: normalizeStringRecord(ctx.environmentVariables),
8891
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
8992
blockData: {},

apps/sim/executor/handlers/workflow/workflow-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ export class WorkflowBlockHandler implements BlockHandler {
505505
workspaceDecrypted: ownerEnv.workspaceDecrypted,
506506
decryptionFailures: ownerEnv.decryptionFailures,
507507
personalOwners: ownerEnv.personalOwners,
508+
workspaceUnredactedKeys: ownerEnv.workspaceUnredactedKeys,
508509
scope: { userId: loadUserId, workspaceId: sourceWorkspaceId },
509510
})
510511
if (ctx.resolvedSecretTraceRegistry) {

0 commit comments

Comments
 (0)