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
This commit is contained in:
Vikhyath Mondreti
2026-08-24 13:46:57 -07:00
committed by GitHub
parent 3251bf13a6
commit 0a5b3801ea
51 changed files with 21982 additions and 81 deletions
@@ -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.
+137 -44
View File
@@ -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": [
{
@@ -5581,6 +5581,124 @@
}
]
},
"V2SecretWithValue": {
"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."
},
"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",
"unredacted",
"role",
"createdAt",
"updatedAt"
],
"additionalProperties": false,
"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/V2SecretWithValue"
},
"description": "Items in the current page."
},
"nextCursor": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
}
},
"required": ["data", "nextCursor"],
"additionalProperties": false,
"title": "List secrets response",
"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": {
@@ -5607,6 +5725,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"],
@@ -5625,53 +5747,19 @@
"description": "ISO 8601 timestamp when the secret was last updated."
}
},
"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."
},
"ListSecretsResponse": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/V2Secret"
},
"description": "Items in the current page."
},
"nextCursor": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
}
},
"required": ["data", "nextCursor"],
"additionalProperties": false,
"title": "List secrets response",
"description": "Secret metadata visible to the caller without stored values.",
"examples": [
{
"data": [
{
"name": "STRIPE_API_KEY",
"scope": "workspace",
"description": "Production billing key — rotate quarterly.",
"role": "admin",
"createdAt": "2026-06-01T09:14:00.000Z",
"updatedAt": "2026-06-20T14:02:11.000Z"
}
],
"nextCursor": null
}
]
},
"SetSecretResponse": {
"type": "object",
"properties": {
@@ -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"
@@ -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"],
@@ -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',
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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({
+30 -3
View File
@@ -991,6 +991,13 @@ interface FunctionRouteExecutionContext {
outputSecretMatcher?: ResolvedSecretMatcher
outputSecretNamesByScanLiteral: Map<string, string[]>
outputSecretPlaintextsByName: Map<string, string>
/**
* In-scope names the caller's registry certified as redaction-exempt. They stay in
* `outputSecretPlaintextsByName` — the response's resolved-name reporting and the usage
* trail must not lose them — but contribute no scan literals, so exported files carrying
* only their values classify exact-empty instead of locking.
*/
unredactedSecretNames: Set<string>
mountedFileSecretProvenanceScanner?: MountedFileSecretProvenanceScanner
}
@@ -1191,13 +1198,23 @@ function activateReferencedSecretProvenance(context: FunctionRouteExecutionConte
}
}
/** Compiled secret names that still demand redaction — the exempt ones don't count. */
function countProtectedOutputSecretNames(context: FunctionRouteExecutionContext): number {
let count = 0
for (const name of context.outputSecretPlaintextsByName.keys()) {
if (!context.unredactedSecretNames.has(name)) count += 1
}
return count
}
/**
* True when this execution compiled a secret placeholder or received a mounted file with verified
* secret provenance. Ordinary mounts without a provenance envelope are user data, not evidence that
* a Sim secret was resolved in this call.
* a Sim secret was resolved in this call. Exempt names don't count: a binary export whose only
* in-scope secrets are redaction-exempt is deliberately classified exact-empty rather than locked.
*/
function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean {
if (context.outputSecretPlaintextsByName.size > 0) return true
if (countProtectedOutputSecretNames(context) > 0) return true
return context.mountedFileSecretProvenanceScanner?.hasSecrets ?? false
}
@@ -1225,7 +1242,7 @@ async function getOutputFileSecretProvenance(
status: 'exact' as const,
entries: [],
}
if (context.outputSecretPlaintextsByName.size === 0) {
if (countProtectedOutputSecretNames(context) === 0) {
return mountedFileProvenance
}
if (!context.outputSecretMatcher) return { status: 'unknown' }
@@ -1914,6 +1931,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
envVars: rawEnvVars = {},
secretScope,
mountedSecrets,
unredactedSecretNames = [],
sandboxId: selectedSandboxId,
blockData = {},
blockNameMapping = {},
@@ -2035,6 +2053,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
privateResolvedSecretNamesMetadataType,
outputSecretNamesByScanLiteral: new Map(),
outputSecretPlaintextsByName: new Map(),
unredactedSecretNames: new Set(
unredactedSecretNames.filter((name) => Object.hasOwn(envVars, name))
),
mountedFileSecretProvenanceScanner,
}
@@ -2069,6 +2090,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const plaintext = envVars[name]
if (!plaintext) continue
routeContext.outputSecretPlaintextsByName.set(name, plaintext)
/**
* Skipped per NAME, never per literal: a plaintext shared by an exempt and a non-exempt
* name keeps its literal through the non-exempt owner, so the export still records that
* owner's provenance and the file still locks.
*/
if (routeContext.unredactedSecretNames.has(name)) continue
const scanLiterals = new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)])
for (const scanLiteral of scanLiterals) {
const names = routeContext.outputSecretNamesByScanLiteral.get(scanLiteral) ?? []
@@ -83,6 +83,7 @@ const secret = {
updatedAt: new Date('2026-01-02T00:00:00Z'),
hasServiceAccountKey: false,
role: 'admin' as const,
unredacted: false,
}
const context = { params: Promise.resolve({ name: SECRET_NAME }) }
+73 -1
View File
@@ -81,6 +81,7 @@ const secret = {
updatedAt: new Date('2026-01-02T00:00:00Z'),
hasServiceAccountKey: false,
role: 'admin' as const,
unredacted: false,
}
describe('GET /api/v2/secrets', () => {
@@ -92,6 +93,7 @@ describe('GET /api/v2/secrets', () => {
mocks.gate.mockResolvedValue(null)
mocks.list.mockResolvedValue({
secrets: [secret],
values: {},
userId: 'user-1',
nextCursorKeys: null,
sortBy: 'name',
@@ -114,6 +116,7 @@ describe('GET /api/v2/secrets', () => {
name: 'STRIPE_API_KEY',
scope: 'workspace',
description: null,
unredacted: false,
role: 'admin',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
@@ -121,7 +124,7 @@ describe('GET /api/v2/secrets', () => {
],
nextCursor: null,
})
expect(JSON.stringify(body)).not.toContain('value')
expect(JSON.stringify(body)).not.toContain('"value"')
expect(mocks.list).toHaveBeenCalledWith({
principal: PRINCIPAL,
input: {
@@ -138,6 +141,72 @@ describe('GET /api/v2/secrets', () => {
})
})
it('carries the stored value for exactly the rows marked visible', async () => {
mocks.list.mockResolvedValue({
secrets: [
secret,
{
...secret,
id: 'secret-3',
displayName: 'STAGING_BASE_URL',
envKey: 'STAGING_BASE_URL',
unredacted: true,
},
],
values: { STAGING_BASE_URL: 'https://staging.example.com' },
userId: 'user-1',
nextCursorKeys: null,
sortBy: 'name',
sortOrder: 'asc',
})
const response = await GET(
new NextRequest(`http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}`, {
headers: { 'x-api-key': 'key' },
})
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data[0]).not.toHaveProperty('value')
expect(body.data[1]).toMatchObject({
name: 'STAGING_BASE_URL',
unredacted: true,
value: 'https://staging.example.com',
})
})
it('never attaches an inherited prototype member as a missing value', async () => {
mocks.list.mockResolvedValue({
secrets: [
{
...secret,
id: 'secret-proto',
displayName: 'constructor',
envKey: 'constructor',
unredacted: true,
},
],
/** The name is legal but its value is absent — a bare index would read Object's constructor. */
values: {},
userId: 'user-1',
nextCursorKeys: null,
sortBy: 'name',
sortOrder: 'asc',
})
const response = await GET(
new NextRequest(`http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}`, {
headers: { 'x-api-key': 'key' },
})
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data[0]).toMatchObject({ name: 'constructor', unredacted: true })
expect(body.data[0]).not.toHaveProperty('value')
})
/**
* Pins the binding end-to-end — the mint in `present` and the read in
* `mapInput` — because the contract-level sweep only checks a hand-maintained
@@ -157,6 +226,7 @@ describe('GET /api/v2/secrets', () => {
description: 'leaked from a workspace mirror',
},
],
values: {},
userId: 'user-1',
nextCursorKeys: null,
sortBy: 'name',
@@ -178,6 +248,7 @@ describe('GET /api/v2/secrets', () => {
it('refuses a cursor minted under a different filter', async () => {
mocks.list.mockResolvedValue({
secrets: [secret],
values: {},
userId: 'user-1',
nextCursorKeys: ['STRIPE_API_KEY', 'secret-1'],
sortBy: 'name',
@@ -209,6 +280,7 @@ describe('GET /api/v2/secrets', () => {
it('resumes a cursor replayed under the filters it was minted with', async () => {
mocks.list.mockResolvedValue({
secrets: [secret],
values: {},
userId: 'user-1',
nextCursorKeys: ['STRIPE_API_KEY', 'secret-1'],
sortBy: 'name',
+14 -3
View File
@@ -23,7 +23,7 @@ function secretCursorFilters(query: { workspaceId: string; scope?: string; searc
})
}
/** GET /api/v2/secrets — List secret names and metadata without reading their values. */
/** GET /api/v2/secrets — List secret metadata; visible (unredacted) secrets carry their value. */
export const GET = defineV2JsonRoute({
contract: v2ListSecretsContract,
operation: secretOperations.list,
@@ -40,8 +40,19 @@ export const GET = defineV2JsonRoute({
),
}),
useCase: listSecretsUseCase,
present: ({ secrets, userId, nextCursorKeys }, { query }) => ({
data: secrets.map((secret) => toV2Secret(secret, userId)),
present: ({ secrets, values, userId, nextCursorKeys }, { query }) => ({
data: secrets.map((secret) =>
toV2Secret(
secret,
userId,
/**
* Own-property read: a secret may legally be named `constructor` or `toString`,
* and a bare index on a missing key would hand the inherited function to the
* serializer and fail response validation for the whole page.
*/
secret.envKey && Object.hasOwn(values, secret.envKey) ? values[secret.envKey] : undefined
)
),
nextCursor: writeSortedCursor(
nextCursorKeys,
query.sortBy,
+15 -3
View File
@@ -1,8 +1,17 @@
import type { V2Secret } from '@/lib/api/contracts/v2/secrets'
import type { V2SecretWithValue } from '@/lib/api/contracts/v2/secrets'
import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries'
/** Serialize environment credential metadata as a secret without exposing its stored value. */
export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2Secret {
/**
* Serialize environment credential metadata as a secret. The stored value is
* attached only when supplied AND the row is a workspace secret marked visible
* (unredacted) — the guard here, not only at the caller, so no code path can
* hand a value to a row whose flag does not disclose it.
*/
export function toV2Secret(
row: VisibleWorkspaceCredential,
userId: string,
value?: string
): V2SecretWithValue {
if (!row.envKey || (row.type !== 'env_workspace' && row.type !== 'env_personal')) {
throw new Error(`Credential ${row.id} is not a secret`)
}
@@ -10,12 +19,15 @@ export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2S
throw new Error(`Personal secret ${row.id} is not owned by the caller`)
}
const unredacted = row.type === 'env_workspace' ? row.unredacted : false
return {
name: row.envKey,
scope: row.type === 'env_workspace' ? 'workspace' : 'personal',
description: row.type === 'env_workspace' ? row.description : null,
unredacted,
role: row.role,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(value !== undefined && unredacted ? { value } : {}),
}
}
@@ -56,15 +56,17 @@ export function useCredentialDetailForm({
const [displayNameDraft, setDisplayNameDraft] = useState('')
const [descriptionDraft, setDescriptionDraft] = useState('')
const [unredactedDraft, setUnredactedDraft] = useState(false)
const [seededCredentialId, setSeededCredentialId] = useState<string | null>(null)
// Seed drafts when the credential first resolves (or the route id changes); a
// background refetch of the same credential must not clobber an in-progress
// edit — Discard is the one way to reset.
/** Applies a credential to both drafts — the one definition of "reset to server state". */
/** Applies a credential to every draft — the one definition of "reset to server state". */
const seedDrafts = useCallback((source: WorkspaceCredential) => {
setDisplayNameDraft(source.displayName)
setDescriptionDraft(source.description ?? '')
setUnredactedDraft(source.unredacted)
}, [])
if (credential && credential.id !== seededCredentialId) {
@@ -76,7 +78,8 @@ export function useCredentialDetailForm({
const isDescriptionDirty = credential
? descriptionDraft !== (credential.description || '')
: false
const isMetadataDirty = isDisplayNameDirty || isDescriptionDirty
const isUnredactedDirty = credential ? unredactedDraft !== credential.unredacted : false
const isMetadataDirty = isDisplayNameDirty || isDescriptionDirty || isUnredactedDirty
const isSectionDirty = section?.isDirty ?? false
const isDirty = isMetadataDirty || isSectionDirty
const isSaving = updateCredential.isPending || (section?.isSaving ?? false)
@@ -93,6 +96,7 @@ export function useCredentialDetailForm({
credentialId: credential.id,
...(isDisplayNameDirty ? { displayName: displayNameDraft.trim() } : {}),
...(isDescriptionDirty ? { description: descriptionDraft.trim() || null } : {}),
...(isUnredactedDirty ? { unredacted: unredactedDraft } : {}),
})
if (isDisplayNameDirty) setDisplayNameDraft((value) => value.trim())
if (isDescriptionDirty) setDescriptionDraft((value) => value.trim())
@@ -111,8 +115,10 @@ export function useCredentialDetailForm({
section,
isDisplayNameDirty,
isDescriptionDirty,
isUnredactedDirty,
displayNameDraft,
descriptionDraft,
unredactedDraft,
updateCredential.mutateAsync,
])
@@ -126,6 +132,8 @@ export function useCredentialDetailForm({
setDisplayNameDraft,
descriptionDraft,
setDescriptionDraft,
unredactedDraft,
setUnredactedDraft,
isDirty,
save,
discard,
@@ -1,7 +1,15 @@
'use client'
import { useState } from 'react'
import { Chip, ChipCopyInput, ChipLink, ChipModalTabs, ChipTextarea } from '@sim/emcn'
import {
Chip,
ChipCopyInput,
ChipLink,
ChipModalTabs,
ChipTextarea,
Label,
Switch,
} from '@sim/emcn'
import { ArrowLeft, Clock, Key, Send } from '@sim/emcn/icons'
import { useQueryState } from 'nuqs'
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
@@ -246,6 +254,27 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
/>
</DetailSection>
{!isPersonal && (
<DetailSection title='Visibility'>
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-1'>
<Label htmlFor='secret-unredacted'>Show value in logs and Chat</Label>
<p className='text-[var(--text-muted)] text-caption'>
{
'The value is visible to anyone who can see this workspace’s runs — in logs, Chat, and the API, including shared log links.'
}
</p>
</div>
<Switch
id='secret-unredacted'
checked={form.unredactedDraft}
onCheckedChange={form.setUnredactedDraft}
disabled={!isWorkspaceSecretAdmin}
/>
</div>
</DetailSection>
)}
{!isPersonal && (
<DetailSection title='Description'>
<ChipTextarea
+1
View File
@@ -825,6 +825,7 @@ async function executeWebhookJobInternal(
workspaceDecrypted: secretEnvironment.workspaceDecrypted,
decryptionFailures: secretEnvironment.decryptionFailures,
personalOwners: secretEnvironment.personalOwners,
workspaceUnredactedKeys: secretEnvironment.workspaceUnredactedKeys,
scope: secretScope,
})
} catch (error) {
@@ -77,6 +77,8 @@ export class FunctionBlockHandler implements BlockHandler {
mountedSecrets: inputs.mountedSecrets,
})
const unredactedSecretNames = ctx.resolvedSecretTraceRegistry?.getUnredactedSecretNames() ?? []
const toolParams = {
code: codeContent,
...(sourceCode ? { sourceCode } : {}),
@@ -84,6 +86,7 @@ export class FunctionBlockHandler implements BlockHandler {
timeout,
...(inputs.sandboxId ? { sandboxId: inputs.sandboxId } : {}),
...(secretMountPolicy ?? {}),
...(unredactedSecretNames.length > 0 ? { unredactedSecretNames } : {}),
envVars: normalizeStringRecord(ctx.environmentVariables),
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData: {},
@@ -505,6 +505,7 @@ export class WorkflowBlockHandler implements BlockHandler {
workspaceDecrypted: ownerEnv.workspaceDecrypted,
decryptionFailures: ownerEnv.decryptionFailures,
personalOwners: ownerEnv.personalOwners,
workspaceUnredactedKeys: ownerEnv.workspaceUnredactedKeys,
scope: { userId: loadUserId, workspaceId: sourceWorkspaceId },
})
if (ctx.resolvedSecretTraceRegistry) {
@@ -2025,3 +2025,293 @@ describe('non-identifying literals in durable provenance', () => {
}
})
})
describe('unredacted catalog exemption', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({
decrypted: `decrypted:${encryptedValue}`,
}))
})
it('drops an exempt workspace secret from trace and model matches but keeps usage', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: {},
workspaceEncrypted: { STAGING_KEY: 'staging-encrypted' },
personalDecrypted: {},
workspaceDecrypted: { STAGING_KEY: 'staging-value-123' },
workspaceUnredactedKeys: ['STAGING_KEY'],
})
expect(registry.recordResolved('STAGING_KEY', 'staging-value-123')).toBe(true)
expect(registry.getActiveMatches()).toEqual([])
const snapshot = registry.getModelEgressSnapshot()
expect(snapshot.complete).toBe(true)
if (snapshot.complete) {
/** The legacy alias still substitutes; the value and its JSON encoding do not. */
expect(snapshot.matches).toEqual([
{ plaintext: '__var_STAGING_KEY', replacement: '{{STAGING_KEY}}' },
])
}
expect(registry.getResolvedSecretUsage()).toEqual([
{ name: 'STAGING_KEY', scope: 'workspace', ownerUserId: null },
])
})
it('never stamps a personal secret, even when its name is listed', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: { PERSONAL_KEY: 'personal-encrypted' },
workspaceEncrypted: {},
personalDecrypted: { PERSONAL_KEY: 'personal-value-123' },
workspaceDecrypted: {},
personalOwners: { PERSONAL_KEY: 'owner-1' },
workspaceUnredactedKeys: ['PERSONAL_KEY'],
})
expect(registry.recordResolved('PERSONAL_KEY', 'personal-value-123')).toBe(true)
expect(registry.getActiveMatches()).toEqual([
{ plaintext: 'personal-value-123', replacement: '{{PERSONAL_KEY}}' },
])
})
it('exempts only the workspace value when it shadows a same-named personal secret', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: { SHARED: 'personal-encrypted' },
workspaceEncrypted: { SHARED: 'workspace-encrypted' },
personalDecrypted: { SHARED: 'personal-value-123' },
workspaceDecrypted: { SHARED: 'workspace-value-123' },
workspaceUnredactedKeys: ['SHARED'],
})
expect(registry.recordResolved('SHARED', 'workspace-value-123')).toBe(true)
expect(registry.getActiveMatches()).toEqual([])
expect(registry.exportProvenance()).toEqual({ version: 1, complete: true, entries: [] })
})
it('keeps redacting a plaintext an exempt and a non-exempt secret share', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: { OTHER_KEY: 'other-encrypted' },
workspaceEncrypted: { EXEMPT_KEY: 'exempt-encrypted' },
personalDecrypted: { OTHER_KEY: 'shared-value-123' },
workspaceDecrypted: { EXEMPT_KEY: 'shared-value-123' },
personalOwners: { OTHER_KEY: 'owner-1' },
workspaceUnredactedKeys: ['EXEMPT_KEY'],
})
expect(registry.recordResolved('EXEMPT_KEY', 'shared-value-123')).toBe(true)
expect(registry.recordResolved('OTHER_KEY', 'shared-value-123')).toBe(true)
expect(registry.getActiveMatches()).toEqual([
{ plaintext: 'shared-value-123', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT },
])
expect(registry.exportProvenanceForValue('payload shared-value-123').entries).toEqual([
{ name: 'EXEMPT_KEY', encryptedValue: 'exempt-encrypted' },
{ name: 'OTHER_KEY', encryptedValue: 'other-encrypted' },
])
})
it('omits a solely-exempt entry from every envelope while staying complete', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: {},
workspaceEncrypted: { STAGING_KEY: 'staging-encrypted' },
personalDecrypted: {},
workspaceDecrypted: { STAGING_KEY: 'staging-value-123' },
workspaceUnredactedKeys: ['STAGING_KEY'],
})
expect(
registry.recordResolvedAtInputPath('STAGING_KEY', 'staging-value-123', ['params', 'apiKey'])
).toBe(true)
expect(registry.exportProvenance()).toEqual({ version: 1, complete: true, entries: [] })
expect(registry.exportCheckpointProvenance()).toEqual({
version: 1,
complete: true,
entries: [],
})
expect(registry.exportCommittedProvenanceForValue('body staging-value-123')).toEqual({
version: 1,
complete: true,
entries: [],
})
expect(registry.exportCommittedProvenanceForInputPaths([['params', 'apiKey']])).toEqual({
version: 1,
complete: true,
entries: [],
})
})
it('keeps an exempt entry, anonymized, in a forced-anonymous crossing envelope', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: {},
workspaceEncrypted: { STAGING_KEY: 'staging-encrypted' },
personalDecrypted: {},
workspaceDecrypted: { STAGING_KEY: 'staging-value-123' },
workspaceUnredactedKeys: ['STAGING_KEY'],
})
expect(registry.recordResolved('STAGING_KEY', 'staging-value-123')).toBe(true)
expect(
registry.exportCommittedProvenanceForValue('body staging-value-123', { anonymous: true })
).toEqual({
version: 1,
complete: true,
entries: [{ encryptedValue: 'staging-encrypted' }],
})
})
it('a fork keeps protecting a collider left behind in the parent', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: { OTHER_KEY: 'other-encrypted' },
workspaceEncrypted: { EXEMPT_KEY: 'exempt-encrypted' },
personalDecrypted: { OTHER_KEY: 'shared-value-123' },
workspaceDecrypted: { EXEMPT_KEY: 'shared-value-123' },
personalOwners: { OTHER_KEY: 'owner-1' },
workspaceUnredactedKeys: ['EXEMPT_KEY'],
})
expect(
registry.recordResolvedAtInputPath('EXEMPT_KEY', 'shared-value-123', ['params', 'apiKey'])
).toBe(true)
expect(registry.recordResolved('OTHER_KEY', 'shared-value-123')).toBe(true)
const fork = registry.forkForInputPaths([['params', 'apiKey']])
expect(fork.getActiveMatches()).toEqual([
{ plaintext: 'shared-value-123', replacement: '{{EXEMPT_KEY}}' },
])
expect(fork.exportProvenanceForValue('shared-value-123').entries).toEqual([
{ name: 'EXEMPT_KEY', encryptedValue: 'exempt-encrypted' },
])
})
it('exempts an imported duplicate only on an exact name and value match', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: {},
workspaceEncrypted: { EXEMPT_KEY: 'exempt-encrypted' },
personalDecrypted: {},
workspaceDecrypted: { EXEMPT_KEY: 'decrypted:exempt-encrypted' },
workspaceUnredactedKeys: ['EXEMPT_KEY'],
})
await registry.importProvenance(
{
version: 1,
complete: true,
entries: [{ name: 'EXEMPT_KEY', encryptedValue: 'exempt-encrypted' }],
},
{ trusted: true }
)
expect(registry.getActiveMatches()).toEqual([])
await registry.importProvenance(
{
version: 1,
complete: true,
entries: [{ name: 'EXEMPT_KEY', encryptedValue: 'rotated-encrypted' }],
},
{ trusted: true }
)
expect(registry.getActiveMatches()).toEqual([
{ plaintext: 'decrypted:rotated-encrypted', replacement: '{{EXEMPT_KEY}}' },
])
})
it('never bypasses fail-closed incompleteness', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: {},
workspaceEncrypted: { STAGING_KEY: 'staging-encrypted' },
personalDecrypted: {},
workspaceDecrypted: { STAGING_KEY: 'staging-value-123' },
workspaceUnredactedKeys: ['STAGING_KEY'],
})
registry.markIncomplete('unspecified')
expect(registry.getModelEgressSnapshot()).toEqual({ complete: false })
expect(registry.exportProvenance().complete).toBe(false)
/** A missing collider is indistinguishable from none — certify nothing once latched. */
expect(registry.getUnredactedSecretNames()).toEqual([])
})
it('releases an exempt-only recorded input leaf to the model raw', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: {},
workspaceEncrypted: { STAGING_KEY: 'staging-encrypted' },
personalDecrypted: {},
workspaceDecrypted: { STAGING_KEY: 'staging-value-123' },
workspaceUnredactedKeys: ['STAGING_KEY'],
})
expect(
registry.recordResolvedAtInputPath('STAGING_KEY', 'staging-value-123', ['params', 'apiKey'])
).toBe(true)
registry.recordResolvedInputProjection(
['params', 'apiKey'],
'staging-value-123',
'{{STAGING_KEY}}'
)
expect(
registry.projectResolvedInputSelection({ params: { apiKey: 'staging-value-123' } })
).toEqual({
complete: true,
value: { params: { apiKey: 'staging-value-123' } },
})
})
it('keeps projecting an input leaf whose plaintext a protected secret shares', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: { OTHER_KEY: 'other-encrypted' },
workspaceEncrypted: { EXEMPT_KEY: 'exempt-encrypted' },
personalDecrypted: { OTHER_KEY: 'shared-value-123' },
workspaceDecrypted: { EXEMPT_KEY: 'shared-value-123' },
personalOwners: { OTHER_KEY: 'owner-1' },
workspaceUnredactedKeys: ['EXEMPT_KEY'],
})
expect(
registry.recordResolvedAtInputPath('EXEMPT_KEY', 'shared-value-123', ['params', 'apiKey'])
).toBe(true)
expect(registry.recordResolved('OTHER_KEY', 'shared-value-123')).toBe(true)
registry.recordResolvedInputProjection(
['params', 'apiKey'],
'shared-value-123',
'{{EXEMPT_KEY}}'
)
expect(
registry.projectResolvedInputSelection({ params: { apiKey: 'shared-value-123' } })
).toEqual({
complete: true,
value: { params: { apiKey: '{{EXEMPT_KEY}}' } },
})
})
it('certifies only collision-free exempt names for the sandbox path', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: { OTHER_KEY: 'other-encrypted' },
workspaceEncrypted: { EXEMPT_KEY: 'exempt-encrypted', CLEAN_KEY: 'clean-encrypted' },
personalDecrypted: { OTHER_KEY: 'shared-value-123' },
workspaceDecrypted: {
EXEMPT_KEY: 'shared-value-123',
CLEAN_KEY: 'clean-value-456',
},
personalOwners: { OTHER_KEY: 'owner-1' },
workspaceUnredactedKeys: ['EXEMPT_KEY', 'CLEAN_KEY'],
})
expect(registry.getUnredactedSecretNames()).toEqual(['CLEAN_KEY'])
/** An anonymous entry activated mid-run withdraws the certification too. */
mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({
decrypted:
encryptedValue === 'anon-encrypted' ? 'clean-value-456' : `decrypted:${encryptedValue}`,
}))
await registry.importProvenance(
{ version: 1, complete: true, entries: [{ encryptedValue: 'anon-encrypted' }] },
{ trusted: true, anonymous: true }
)
expect(registry.getUnredactedSecretNames()).toEqual([])
})
})
@@ -235,6 +235,13 @@ export interface ResolvedSecretTraceCatalogEntry {
* Unset for workspace entries, which the workspace itself owns.
*/
ownerUserId?: string
/**
* Set only on workspace entries whose credential row opts the secret out of redaction.
* Never trusted off an entry directly at decision time — every exemption re-derives
* through the catalog by name AND plaintext, so an entry adopted from an imported
* envelope is exempt only when it is byte-identical to the run's own flagged secret.
*/
unredacted?: true
}
export interface ResolvedSecretTraceMatch {
@@ -381,6 +388,13 @@ export interface CreateResolvedSecretTraceRegistryOptions {
restoreTrusted?: boolean
requireRestoredProvenance?: boolean
scope?: ResolvedSecretTraceScopeV1
/**
* Workspace env keys flagged `unredacted` on their credential row, from
* {@link EnvironmentResolutionSnapshot.workspaceUnredactedKeys}. Stamps the matching
* workspace catalog entries so their resolved values render in plaintext instead of
* `{{NAME}}` and are omitted from exported provenance envelopes.
*/
workspaceUnredactedKeys?: readonly string[]
}
function compareStrings(left: string, right: string): number {
@@ -634,6 +648,7 @@ function isExactProvenanceEntriesArray(value: unknown): value is unknown[] {
function buildEffectiveCatalogEntry(
options: CreateResolvedSecretTraceRegistryOptions,
failedNames: ReadonlySet<string>,
unredactedNames: ReadonlySet<string>,
name: string,
encryptedValue: string
): ResolvedSecretTraceCatalogEntry | undefined {
@@ -649,7 +664,20 @@ function buildEffectiveCatalogEntry(
* precedence the merged environment applies. A name present in both must not be
* attributed to the personal secret it shadowed.
*/
if (fromWorkspace) return { name, plaintext, encryptedValue, scope: 'workspace' }
if (fromWorkspace) {
return {
name,
plaintext,
encryptedValue,
scope: 'workspace',
/**
* Stamped only on the workspace branch: a personal secret shadowed by a flagged
* workspace name must not inherit the flag, and a personal value that WON the
* name can never carry it.
*/
...(unredactedNames.has(name) ? { unredacted: true as const } : {}),
}
}
const ownerUserId = options.personalOwners?.[name]
return {
@@ -665,12 +693,19 @@ function* iterateEffectiveCatalogEntries(
options: CreateResolvedSecretTraceRegistryOptions,
failedNames: ReadonlySet<string>
): Generator<ResolvedSecretTraceCatalogEntry> {
const unredactedNames = new Set(options.workspaceUnredactedKeys ?? [])
for (const name in options.personalEncrypted) {
if (!hasOwn(options.personalEncrypted, name)) continue
const encryptedValue = hasOwn(options.workspaceEncrypted, name)
? options.workspaceEncrypted[name]
: options.personalEncrypted[name]
const entry = buildEffectiveCatalogEntry(options, failedNames, name, encryptedValue)
const entry = buildEffectiveCatalogEntry(
options,
failedNames,
unredactedNames,
name,
encryptedValue
)
if (entry) yield entry
}
@@ -681,6 +716,7 @@ function* iterateEffectiveCatalogEntries(
const entry = buildEffectiveCatalogEntry(
options,
failedNames,
unredactedNames,
name,
options.workspaceEncrypted[name]
)
@@ -886,13 +922,22 @@ export class ResolvedSecretTraceRegistry {
* Entry-level detail still logs — the caller cannot reconstruct it.
*/
private readonly staged: boolean
/**
* Plaintexts a fork's parent was protecting when the fork was cut. A fork copies an
* active-entry SUBSET, so a non-exempt entry sharing an unredacted entry's plaintext can be
* left behind — and without this set the fork would emit bytes the parent's own log renders
* redacted. Collision decisions consult this set alongside the fork's own entries, so the
* exemption can only ever narrow across a fork, never widen.
*/
private readonly inheritedProtectedPlaintexts: ReadonlySet<string>
constructor(
catalogEntries: Iterable<ResolvedSecretTraceCatalogEntry> = [],
scope?: ResolvedSecretTraceScopeV1,
options: { staged?: boolean } = {}
options: { staged?: boolean; inheritedProtectedPlaintexts?: ReadonlySet<string> } = {}
) {
this.staged = options.staged === true
this.inheritedProtectedPlaintexts = options.inheritedProtectedPlaintexts ?? new Set()
this.scope = scope ? cloneProvenanceScope(scope) : undefined
this.completeProvenanceEnvelopeBytes = serializedProvenanceEnvelopeByteSize(true, this.scope)
if (this.completeProvenanceEnvelopeBytes > MAX_SERIALIZED_PROVENANCE_BYTES) {
@@ -915,7 +960,9 @@ export class ResolvedSecretTraceRegistry {
* available to later calls in the run.
*/
forkForToolCall(): ResolvedSecretTraceRegistry {
const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope)
const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope, {
inheritedProtectedPlaintexts: this.collectProtectedPlaintexts(),
})
for (const entry of this.activeEntries.values()) {
fork.addActiveEntry(
{ ...entry },
@@ -933,7 +980,9 @@ export class ResolvedSecretTraceRegistry {
paths: readonly ResolvedSecretInputPath[],
options: { propagated?: boolean } = {}
): ResolvedSecretTraceRegistry {
const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope)
const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope, {
inheritedProtectedPlaintexts: this.collectProtectedPlaintexts(),
})
if (!this.complete) {
fork.markIncomplete('inherited-incomplete-source', { source: this })
return fork
@@ -958,7 +1007,9 @@ export class ResolvedSecretTraceRegistry {
/** Creates a model/output registry containing only provenance explicitly carried by a result. */
forkForPropagatedEntries(): ResolvedSecretTraceRegistry {
const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope)
const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope, {
inheritedProtectedPlaintexts: this.collectProtectedPlaintexts(),
})
for (const entry of this.activeEntries.values()) {
if (this.propagatedEntryKeys.has(activeEntryKey(entry))) {
fork.addActiveEntry({ ...entry }, { propagated: true })
@@ -1012,7 +1063,9 @@ export class ResolvedSecretTraceRegistry {
}
}
const registry = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope)
const registry = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope, {
inheritedProtectedPlaintexts: this.collectProtectedPlaintexts(),
})
let matched = false
const resolve = (candidate: unknown, path: string[]): unknown => {
if (typeof candidate === 'string') {
@@ -1321,17 +1374,39 @@ export class ResolvedSecretTraceRegistry {
}
}
/**
* Whether a recorded input leaf still needs its placeholder projection applied. A leaf is
* released to the model raw only when every entry that activated it is exempt and nothing
* else protects those bytes; an entry key that no longer resolves keeps the projection —
* fail closed, never open, on bookkeeping gaps.
*/
private shouldProjectInputState(
state: ResolvedInputPathState,
protectedPlaintexts: ReadonlySet<string>
): boolean {
if (state.entryKeys.size === 0) return true
for (const entryKey of state.entryKeys) {
const entry = this.activeEntries.get(entryKey)
if (!entry) return true
if (!this.isUnredactedEntry(entry)) return true
if (protectedPlaintexts.has(entry.plaintext)) return true
}
return false
}
private projectResolvedInputStates(
selected: Record<string, unknown>,
states: Iterable<ResolvedInputPathState>
): ResolvedSecretInputProjection {
const projected = { ...selected }
const projectedContainers = new WeakMap<object, object>([[selected, projected]])
const protectedPlaintexts = this.collectProtectedPlaintexts()
for (const state of states) {
if (state.rawValue === undefined || state.projectedValue === undefined) continue
if (!Object.hasOwn(selected, state.path[0])) continue
if (readInputPath(selected, state.path) !== state.rawValue) continue
if (!this.shouldProjectInputState(state, protectedPlaintexts)) continue
if (
!writeInputPathOnProjectedCopy(
selected,
@@ -1711,6 +1786,35 @@ export class ResolvedSecretTraceRegistry {
return [...usage.values()]
}
/**
* Names the sandbox path may treat as exempt when classifying its exported files. The route
* sees only names and cannot re-check collisions itself, so any flagged name whose plaintext
* another catalog entry or a non-exempt active entry shares is withheld — the file export
* then records the colliding owner's provenance exactly as before.
*
* Certifies nothing once the registry is permanently incomplete: the collision set is built
* from the active entries, and a failed import or capacity latch means entries — including a
* collider for a flagged plaintext — may be missing. The same bar model egress applies, and
* for the same reason: incompleteness must only ever widen protection.
*/
getUnredactedSecretNames(): readonly string[] {
if (this.isPermanentlyIncomplete()) return []
const protectedPlaintexts = this.collectProtectedPlaintexts()
const nonExemptCatalogPlaintexts = new Set<string>()
for (const entry of this.catalog.values()) {
if (entry.unredacted !== true) nonExemptCatalogPlaintexts.add(entry.plaintext)
}
const names: string[] = []
for (const entry of this.catalog.values()) {
if (entry.unredacted !== true) continue
if (protectedPlaintexts.has(entry.plaintext)) continue
if (nonExemptCatalogPlaintexts.has(entry.plaintext)) continue
names.push(entry.name)
}
return names
}
/**
* Returns committed literals that must be removed before content can cross into a model.
* Only entries activated by an exact resolver or trusted provenance boundary participate;
@@ -1774,6 +1878,32 @@ export class ResolvedSecretTraceRegistry {
)
}
/**
* Whether one active entry is exempt from redaction. Derived through the catalog at decision
* time rather than trusted off the entry, so an imported duplicate of a flagged secret is
* exempt only when byte-identical to the run's own catalog value, and replace-on-mismatch
* churn in {@link addActiveEntry} cannot change the answer.
*/
private isUnredactedEntry(entry: ActiveSecretEntry): boolean {
if (entry.anonymous || entry.name.length === 0) return false
const catalogEntry = this.catalog.get(entry.name)
return catalogEntry?.unredacted === true && catalogEntry.plaintext === entry.plaintext
}
/**
* Plaintexts that must stay redacted regardless of any exemption: every active entry that is
* not itself exempt, plus everything a fork's parent was protecting. Computed over the FULL
* active set, never a caller's selected subset — the bytes leaving a surface are the same no
* matter which selection vouched for them.
*/
private collectProtectedPlaintexts(): Set<string> {
const protectedPlaintexts = new Set(this.inheritedProtectedPlaintexts)
for (const entry of this.activeEntries.values()) {
if (!this.isUnredactedEntry(entry)) protectedPlaintexts.add(entry.plaintext)
}
return protectedPlaintexts
}
private buildMatches(entries: Iterable<ActiveSecretEntry>): readonly ResolvedSecretTraceMatch[] {
const candidatesByPlaintext = new Map<string, ActiveSecretEntry[]>()
for (const entry of entries) {
@@ -1788,6 +1918,18 @@ export class ResolvedSecretTraceRegistry {
candidatesByPlaintext.set(entry.plaintext, candidates)
}
/**
* A plaintext leaves the match set only when every owner of those bytes is exempt AND no
* fork parent was protecting them — any non-exempt owner (a personal secret, an anonymous
* token) keeps the substitution, failing toward redaction on collision.
*/
for (const [plaintext, candidates] of candidatesByPlaintext) {
if (this.inheritedProtectedPlaintexts.has(plaintext)) continue
if (candidates.every((candidate) => this.isUnredactedEntry(candidate))) {
candidatesByPlaintext.delete(plaintext)
}
}
const matches = [...candidatesByPlaintext.keys()].map((plaintext) => {
const candidates = candidatesByPlaintext.get(plaintext) ?? []
const anonymous = candidates.some((candidate) => candidate.anonymous)
@@ -2408,7 +2550,24 @@ export class ResolvedSecretTraceRegistry {
activeEntries: ActiveSecretEntry[],
forceAnonymous = false
): ResolvedSecretTraceProvenanceEntryV1[] {
/**
* Exempt entries are omitted from every envelope — that is what lets per-span display
* projection show the plaintext, durable sidecars leave files unlocked, and opaque
* model-input validation accept the value. EXCEPT under forced anonymity: that envelope
* crosses into a scope where the flagging workspace's decision carries no authority (the
* custom-block crossing runs the child in the source workflow's workspace), so exempt
* entries are kept — anonymized — rather than dropped. The protected set is computed over
* the full active set, not the caller's selection, so a colliding non-exempt owner keeps
* the entry no matter which paths vouched for the value.
*/
const protectedPlaintexts = forceAnonymous ? undefined : this.collectProtectedPlaintexts()
return activeEntries
.filter(
(entry) =>
protectedPlaintexts === undefined ||
protectedPlaintexts.has(entry.plaintext) ||
!this.isUnredactedEntry(entry)
)
.sort(
(left, right) =>
compareStrings(left.name, right.name) ||
+1
View File
@@ -174,6 +174,7 @@ export function useUpdateWorkspaceCredential() {
...(variables.description !== undefined
? { description: variables.description ?? null }
: {}),
...(variables.unredacted !== undefined ? { unredacted: variables.unredacted } : {}),
})
/*
@@ -0,0 +1,48 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
updateCredentialByIdBodySchema,
workspaceCredentialSchema,
} from '@/lib/api/contracts/credentials'
const credential = {
id: 'credential-1',
workspaceId: 'workspace-1',
type: 'env_workspace' as const,
displayName: 'STRIPE_API_KEY',
description: null,
providerId: null,
accountId: null,
envKey: 'STRIPE_API_KEY',
envOwnerUserId: null,
createdBy: 'user-1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
}
describe('updateCredentialByIdBodySchema unredacted', () => {
it('accepts unredacted alone as the one updated field', () => {
const parsed = updateCredentialByIdBodySchema.safeParse({ unredacted: true })
expect(parsed.success).toBe(true)
if (parsed.success) expect(parsed.data).toEqual({ unredacted: true })
})
it('still rejects an empty update body', () => {
expect(updateCredentialByIdBodySchema.safeParse({}).success).toBe(false)
})
})
describe('workspaceCredentialSchema unredacted', () => {
it('accepts a credential carrying unredacted: false', () => {
const parsed = workspaceCredentialSchema.parse({ ...credential, unredacted: false })
expect(parsed.unredacted).toBe(false)
})
it('requires the field so a response cannot silently drop it', () => {
expect(workspaceCredentialSchema.safeParse(credential).success).toBe(false)
})
})
@@ -31,6 +31,8 @@ export const workspaceCredentialSchema = z.object({
type: workspaceCredentialTypeSchema,
displayName: z.string(),
description: z.string().nullable(),
/** True when an env_workspace secret opts out of redaction; always false for other types. */
unredacted: z.boolean(),
providerId: z.string().nullable(),
accountId: z.string().nullable(),
envKey: z.string().nullable(),
@@ -222,6 +224,8 @@ export const updateCredentialByIdBodySchema = z
.object({
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullish(),
/** Workspace-secret redaction opt-out; rejected for every type but env_workspace. */
unredacted: z.boolean().optional(),
serviceAccountJson: z.string().min(1).optional(),
/** Slack custom-bot secret rotation (reconnect). */
signingSecret: z.string().trim().min(1).optional(),
@@ -244,6 +248,7 @@ export const updateCredentialByIdBodySchema = z
(data) =>
data.displayName !== undefined ||
data.description !== undefined ||
data.unredacted !== undefined ||
data.serviceAccountJson !== undefined ||
data.signingSecret !== undefined ||
data.botToken !== undefined ||
+6
View File
@@ -207,6 +207,12 @@ export const functionExecuteContract = defineRouteContract({
secretScope: z.enum(['all', 'selected']).optional(),
/** Secret names this execution may read when secretScope is `selected`. */
mountedSecrets: z.array(z.string()).optional(),
/**
* Secret names the caller's registry certifies as redaction-exempt (collision-free).
* Exported files containing only these values are not provenance-locked. Trusted the
* same way envVars is: the internal caller already holds the plaintexts.
*/
unredactedSecretNames: z.array(z.string()).optional(),
[PRIVATE_SECRET_PROVENANCE_FIELD]: privateSecretProvenanceBundleSchema.optional(),
_sandboxFiles: z
.array(
@@ -244,11 +244,23 @@ const SECRET_EXAMPLE = {
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',
} as const
const VISIBLE_SECRET_EXAMPLE = {
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',
} as const
type ResourceTag =
| 'Workspaces'
| 'MCP Servers'
@@ -1139,7 +1151,7 @@ const declaredRoutes = [
resourceOperation('Secrets', {
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. ${WORKSPACE_API_KEY_DENIED}`,
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. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_ERRORS,
success: { description: 'Secret metadata visible to the caller.' },
}),
@@ -1154,8 +1166,8 @@ const declaredRoutes = [
v2ListSecretsContract.response.schema,
'ListSecretsResponse',
'List secrets response',
'Secret metadata visible to the caller without stored values.',
[{ data: [SECRET_EXAMPLE], nextCursor: null }]
'Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.',
[{ data: [SECRET_EXAMPLE, VISIBLE_SECRET_EXAMPLE], nextCursor: null }]
),
}
),
@@ -0,0 +1,72 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
v2SecretSchema,
v2SecretWithValueSchema,
v2SetSecretBodySchema,
} from '@/lib/api/contracts/v2/secrets'
const secret = {
name: 'STRIPE_API_KEY',
scope: 'workspace' as const,
description: null,
role: 'admin' as const,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
}
describe('v2SetSecretBodySchema unredacted', () => {
it('rejects unredacted on a personal secret at the flag itself', () => {
const parsed = v2SetSecretBodySchema.safeParse({
workspaceId: 'workspace-1',
scope: 'personal',
value: 'secret-value',
unredacted: true,
})
expect(parsed.success).toBe(false)
if (!parsed.success) {
expect(parsed.error.issues).toEqual([
expect.objectContaining({
path: ['unredacted'],
message: 'unredacted is only supported for a workspace secret',
}),
])
}
})
it('accepts unredacted on a workspace secret', () => {
const parsed = v2SetSecretBodySchema.safeParse({
workspaceId: 'workspace-1',
scope: 'workspace',
value: 'secret-value',
unredacted: true,
})
expect(parsed.success).toBe(true)
if (parsed.success) expect(parsed.data.unredacted).toBe(true)
})
})
describe('v2SecretSchema unredacted', () => {
it('requires the field so a response cannot silently drop it', () => {
expect(v2SecretSchema.safeParse(secret).success).toBe(false)
expect(v2SecretSchema.safeParse({ ...secret, unredacted: false }).success).toBe(true)
})
})
describe('v2SecretWithValueSchema value', () => {
it('parses with and without a value, so only visible rows need to carry one', () => {
const metadata = { ...secret, unredacted: false }
expect(v2SecretWithValueSchema.safeParse(metadata).success).toBe(true)
expect(
v2SecretWithValueSchema.safeParse({
...secret,
unredacted: true,
value: 'https://staging.example.com',
}).success
).toBe(true)
})
})
+44 -3
View File
@@ -39,6 +39,11 @@ export const v2SecretSchema = z
.describe(
'What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience.'
),
unredacted: z
.boolean()
.describe(
'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: workspaceCredentialRoleSchema.describe('Caller role for the secret.'),
createdAt: v2TimestampSchema.describe('ISO 8601 timestamp when the secret was created.'),
updatedAt: v2TimestampSchema.describe('ISO 8601 timestamp when the secret was last updated.'),
@@ -50,6 +55,28 @@ export const v2SecretSchema = z
})
export type V2Secret = z.output<typeof v2SecretSchema>
/**
* List-row shape: metadata plus the stored value for exactly the secrets whose
* workspace marked them visible (unredacted). Every other secret stays
* metadata-only, and no other secret response ever carries a value.
*/
export const v2SecretWithValueSchema = v2SecretSchema
.extend({
value: z
.string()
.optional()
.describe(
'The stored secret value. Present only when the workspace secret is marked visible (unredacted); omitted for every other secret.'
),
})
.meta({
id: 'V2SecretWithValue',
title: 'Secret metadata with visible value',
description:
'Secret metadata; the stored value is included only for a workspace secret marked visible (unredacted).',
})
export type V2SecretWithValue = z.output<typeof v2SecretWithValueSchema>
export const v2SecretDeleteDataSchema = z
.object({
name: v2SecretNameSchema,
@@ -102,6 +129,12 @@ export const v2SetSecretBodySchema = z
.describe(
'What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.'
),
unredacted: z
.boolean()
.optional()
.describe(
'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.'
),
})
.strict()
.superRefine((data, ctx) => {
@@ -112,6 +145,13 @@ export const v2SetSecretBodySchema = z
message: 'description is only supported for a workspace secret',
})
}
if (data.scope === 'personal' && data.unredacted !== undefined) {
ctx.addIssue({
code: 'custom',
path: ['unredacted'],
message: 'unredacted is only supported for a workspace secret',
})
}
})
export type V2SetSecretBody = z.input<typeof v2SetSecretBodySchema>
@@ -126,8 +166,9 @@ export const v2DeleteSecretQuerySchema = z
export type V2DeleteSecretQuery = z.output<typeof v2DeleteSecretQuerySchema>
/**
* Lists names and metadata only, keyset-paginated over the active sort. There is
* deliberately no single-secret GET, and no response ever carries a value.
* Lists secret metadata, keyset-paginated over the active sort. There is
* deliberately no single-secret GET. Rows for workspace secrets marked visible
* (unredacted) carry the stored value; every other row is metadata-only.
*/
export const v2ListSecretsContract = defineRouteContract({
method: 'GET',
@@ -135,7 +176,7 @@ export const v2ListSecretsContract = defineRouteContract({
query: v2ListSecretsQuerySchema,
response: {
mode: 'json',
schema: v2CursorListResponse(v2SecretSchema),
schema: v2CursorListResponse(v2SecretWithValueSchema),
},
})
@@ -23,6 +23,7 @@ export async function createCopilotEnvironmentContext(
workspaceDecrypted: environment.workspaceDecrypted,
decryptionFailures: environment.decryptionFailures,
personalOwners: environment.personalOwners,
workspaceUnredactedKeys: environment.workspaceUnredactedKeys,
scope: { userId, workspaceId },
})
@@ -704,6 +704,14 @@ export async function executeFunctionExecute(
enrichedParams.envVars = mounted.envVars
enrichedParams.secretScope = 'selected'
enrichedParams.mountedSecrets = requestedNames
/**
* Certified by the mounted registry rather than read off the raw materializer entries, so
* a mounted secret sharing its plaintext with a protected one is withheld from the route.
*/
const unredactedSecretNames = mountedRegistry.getUnredactedSecretNames()
if (unredactedSecretNames.length > 0) {
enrichedParams.unredactedSecretNames = unredactedSecretNames
}
if (context.workspaceId) {
const inputs = enrichedParams.inputs as
@@ -143,6 +143,100 @@ describe('materializeCopilotCodeSecrets', () => {
])
})
it('stamps a member-granted workspace mount unredacted when its credential row carries the flag', async () => {
queueSources({
workspace: { API_KEY: 'workspace-cipher' },
credentials: [
credentialRow({
type: 'env_workspace',
envKey: 'API_KEY',
role: 'member',
status: 'active',
}),
],
})
/** The membership-free unredacted-flag query reads the credential table again. */
queueTableRows(credential, [{ envKey: 'API_KEY' }])
const result = await materializeCopilotCodeSecrets({
actorUserId: 'user-1',
workspaceId: 'workspace-1',
requestedNames: ['API_KEY'],
})
expect(result.catalogEntries).toEqual([
{
name: 'API_KEY',
plaintext: 'plain:workspace-cipher',
encryptedValue: 'workspace-cipher',
scope: 'workspace',
unredacted: true,
},
])
})
it('stamps an admin-authorized mount that has no credentialMember row at all', async () => {
mockCheckWorkspaceAccess.mockResolvedValue({
exists: true,
hasAccess: true,
canWrite: true,
canAdmin: true,
})
queueSources({ workspace: { API_KEY: 'workspace-cipher' }, credentials: [] })
queueTableRows(credential, [{ envKey: 'API_KEY' }])
const result = await materializeCopilotCodeSecrets({
actorUserId: 'user-1',
workspaceId: 'workspace-1',
requestedNames: ['API_KEY'],
})
expect(result.catalogEntries).toEqual([
expect.objectContaining({ name: 'API_KEY', scope: 'workspace', unredacted: true }),
])
})
it('never stamps a personal mount even when a flagged workspace row shares the name', async () => {
queueSources({ personal: { API_KEY: 'personal-cipher' } })
queueTableRows(credential, [{ envKey: 'API_KEY' }])
const result = await materializeCopilotCodeSecrets({
actorUserId: 'user-1',
workspaceId: 'workspace-1',
requestedNames: ['API_KEY'],
})
expect(result.catalogEntries).toEqual([
{
name: 'API_KEY',
plaintext: 'plain:personal-cipher',
encryptedValue: 'personal-cipher',
scope: 'personal',
ownerUserId: 'user-1',
},
])
expect(result.catalogEntries[0]).not.toHaveProperty('unredacted')
})
it('leaves a workspace mount unstamped when its credential row has no flag', async () => {
mockCheckWorkspaceAccess.mockResolvedValue({
exists: true,
hasAccess: true,
canWrite: true,
canAdmin: true,
})
queueSources({ workspace: { API_KEY: 'workspace-cipher' }, credentials: [] })
queueTableRows(credential, [])
const result = await materializeCopilotCodeSecrets({
actorUserId: 'user-1',
workspaceId: 'workspace-1',
requestedNames: ['API_KEY'],
})
expect(result.catalogEntries[0]).not.toHaveProperty('unredacted')
})
it('mounts an own __proto__ secret as data without mutating record prototypes', async () => {
queueSources({ personal: Object.fromEntries([['__proto__', 'personal-cipher']]) })
@@ -43,6 +43,8 @@ interface AuthorizedEncryptedSecret {
* a secret the borrower does not have.
*/
ownerUserId?: string
/** Workspace secrets whose credential row opts them out of redaction. */
unredacted?: true
}
export interface MaterializedCopilotCodeSecrets {
@@ -168,7 +170,7 @@ export async function materializeCopilotCodeSecrets(params: {
)
}
const [personalRows, workspaceRows, credentialRows] = await Promise.all([
const [personalRows, workspaceRows, credentialRows, unredactedRows] = await Promise.all([
db
.select({
variables: requestedVariables(environment.variables, requestedNames),
@@ -227,10 +229,32 @@ export async function materializeCopilotCodeSecrets(params: {
)
.orderBy(credential.type, credential.envKey, desc(credential.updatedAt))
.limit(MAX_SECRET_MOUNT_NAMES * 2),
/**
* Membership-free on purpose: the join above returns rows only for the actor's own
* credentialMember grants, but a workspace admin mounts through `access.canAdmin` with no
* membership row at all — the flag must not go silently inert for exactly the admins who
* set it. The flag is workspace-level metadata, not secret material, and the actor already
* passed the write-access check above.
*/
db
.select({ envKey: credential.envKey })
.from(credential)
.where(
and(
eq(credential.workspaceId, params.workspaceId),
eq(credential.type, 'env_workspace'),
inArray(credential.envKey, requestedNames),
eq(credential.unredacted, true)
)
)
.limit(MAX_SECRET_MOUNT_NAMES),
])
const ownPersonalEncrypted = encryptedVariables(personalRows[0])
const workspaceEncrypted = encryptedVariables(workspaceRows[0])
const unredactedWorkspaceKeys = new Set(
unredactedRows.flatMap((row) => (row.envKey === null ? [] : [row.envKey]))
)
const ownPersonalOverLimit = overLimitNames(personalRows[0])
const workspaceOverLimit = overLimitNames(workspaceRows[0])
const envCredentialRows = credentialRows.filter(
@@ -264,7 +288,12 @@ export async function materializeCopilotCodeSecrets(params: {
overLimit.push(name)
continue
}
authorizedSources.push({ name, encryptedValue: workspaceValue, scope: 'workspace' })
authorizedSources.push({
name,
encryptedValue: workspaceValue,
scope: 'workspace',
...(unredactedWorkspaceKeys.has(name) ? { unredacted: true as const } : {}),
})
continue
}
@@ -318,7 +347,7 @@ export async function materializeCopilotCodeSecrets(params: {
let decryptedEntries: ResolvedSecretTraceCatalogEntry[]
try {
decryptedEntries = await Promise.all(
authorizedSources.map(async ({ name, encryptedValue, scope, ownerUserId }) => {
authorizedSources.map(async ({ name, encryptedValue, scope, ownerUserId, unredacted }) => {
const { decrypted } = await decryptSecret(encryptedValue)
return {
name,
@@ -326,6 +355,7 @@ export async function materializeCopilotCodeSecrets(params: {
encryptedValue,
scope,
...(ownerUserId ? { ownerUserId } : {}),
...(unredacted ? { unredacted } : {}),
}
})
)
@@ -173,6 +173,24 @@ describe('setEnvironmentVariablesServerTool', () => {
)
})
it('forwards only the describe fields — never the unredacted flag — through the legacy path', async () => {
await setEnvironmentVariablesServerTool.execute(
{ variables: [{ name: 'API_KEY', value: 'secret', description: 'Stripe live key' }] },
{ userId: 'user-1', workspaceId: 'ws-1' }
)
// This tool must not be a path for Sim to flip per-secret redaction: the
// call carries exactly the describe surface and no unredacted key at all.
const call = performUpdateCredentialMock.mock.calls[0][0] as Record<string, unknown>
expect(Object.keys(call).sort()).toEqual([
'allowedTypes',
'credentialId',
'description',
'userId',
])
expect(call).not.toHaveProperty('unredacted')
})
it('describes a secret that already exists without touching its value', async () => {
const result = await setEnvironmentVariablesServerTool.execute(
{ variables: [{ name: 'API_KEY', description: 'Stripe live key' }] },
+6 -2
View File
@@ -853,16 +853,20 @@ export function serializeApiKeyIntegrations(
/**
* Serialize environment variables for VFS environment/variables.json.
* Shows variable NAMES only — NOT values.
* Shows variable NAMES only — NOT values. `unredactedWorkspace` names the workspace
* secrets whose values appear in plaintext in run output instead of `{{NAME}}`; the
* values themselves are still never written into the VFS.
*/
export function serializeEnvironmentVariables(
personalVarNames: string[],
workspaceVarNames: string[]
workspaceVarNames: string[],
unredactedWorkspaceVarNames: string[] = []
): string {
return JSON.stringify(
{
personal: personalVarNames,
workspace: workspaceVarNames,
unredactedWorkspace: unredactedWorkspaceVarNames,
},
null,
2
+10 -1
View File
@@ -3164,9 +3164,18 @@ export class WorkspaceVFS {
Object.keys(envData.workspaceEncrypted),
secretMountPolicy
)
/** Intersected with the policy-filtered names, so the mount policy applies here too. */
const workspaceVarNameSet = new Set(workspaceVarNames)
const unredactedWorkspaceVarNames = envData.workspaceUnredactedKeys.filter((name) =>
workspaceVarNameSet.has(name)
)
this.files.set(
'environment/variables.json',
serializeEnvironmentVariables(personalVarNames, workspaceVarNames)
serializeEnvironmentVariables(
personalVarNames,
workspaceVarNames,
unredactedWorkspaceVarNames
)
)
const envKeys = [...visibleEnvCredentialNames]
@@ -46,6 +46,7 @@ export function toWorkspaceCredential(
type,
displayName: row.displayName,
description: row.description,
unredacted: row.unredacted,
providerId: row.providerId,
accountId: row.accountId,
envKey: row.envKey,
+4
View File
@@ -251,6 +251,8 @@ interface AccessibleEnvCredential {
envOwnerUserId: string | null
/** Always null on `env_personal`: a mirror row cannot own a user-global secret's note. */
description: string | null
/** Always false on `env_personal`: only workspace secrets can opt out of redaction. */
unredacted: boolean
updatedAt: Date
}
@@ -775,6 +777,7 @@ export async function getAccessibleEnvCredentials(
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
description: credential.description,
unredacted: credential.unredacted,
updatedAt: credential.updatedAt,
})
.from(credential)
@@ -808,6 +811,7 @@ export async function getAccessibleEnvCredentials(
envKey: row.envKey,
envOwnerUserId: row.envOwnerUserId,
description: row.type === 'env_workspace' ? row.description : null,
unredacted: row.type === 'env_workspace' ? row.unredacted : false,
updatedAt: row.updatedAt,
}))
}
@@ -4,6 +4,7 @@
import {
auditMock,
dbChainMockFns,
environmentUtilsMockFns,
queueTableRows,
resetDbChainMock,
schemaMock,
@@ -476,6 +477,72 @@ describe('performUpdateCredential — description scope', () => {
})
})
describe('performUpdateCredential — unredacted scope', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockIsClientCredentialAccountProviderId.mockReturnValue(false)
mockGetClientCredentialAccountDescriptor.mockReturnValue(undefined)
})
it('applies the flag to a workspace secret and invalidates its environment cache', async () => {
mockCredential({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', providerId: null })
const result = await performUpdateCredential({
credentialId: 'cred-1',
userId: 'user-1',
unredacted: true,
})
expect(result.success).toBe(true)
expect(updatePayload().unredacted).toBe(true)
expect(result.updatedFields).toContain('unredacted')
expect(auditMetadata().unredacted).toBe(true)
// The flag rides the environment snapshot into every run's redaction catalog,
// so a flip must not serve stale from the same-process snapshot cache.
expect(environmentUtilsMockFns.mockInvalidateEffectiveDecryptedEnvCache).toHaveBeenCalledWith({
workspaceId: 'ws-1',
})
})
it.each(['oauth', 'env_personal', 'service_account'] as const)(
'rejects the flag on a %s credential instead of writing dead data',
async (type) => {
mockCredential({ type, envKey: type === 'env_personal' ? 'MY_KEY' : null })
const result = await performUpdateCredential({
credentialId: 'cred-1',
userId: 'user-1',
unredacted: true,
})
expect(result).toMatchObject({
success: false,
errorCode: 'validation',
error: 'Only workspace secrets can be marked visible (unredacted).',
})
expect(dbChainMockFns.update).not.toHaveBeenCalled()
expect(
environmentUtilsMockFns.mockInvalidateEffectiveDecryptedEnvCache
).not.toHaveBeenCalled()
}
)
it('does not invalidate the environment cache for a pure description change', async () => {
mockCredential({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', providerId: null })
const result = await performUpdateCredential({
credentialId: 'cred-1',
userId: 'user-1',
description: 'Prod billing key',
})
expect(result.success).toBe(true)
expect(updatePayload()).not.toHaveProperty('unredacted')
expect(environmentUtilsMockFns.mockInvalidateEffectiveDecryptedEnvCache).not.toHaveBeenCalled()
})
})
describe('createServiceAccountCredential', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -39,6 +39,7 @@ import {
verifyAndBuildServiceAccountSecret,
} from '@/lib/credentials/service-account-secret'
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils'
import {
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
@@ -153,6 +154,8 @@ interface CredentialActorParams {
export interface PerformUpdateCredentialParams extends CredentialActorParams {
displayName?: string
description?: string | null
/** Workspace-secret redaction opt-out; rejected for every type but env_workspace. */
unredacted?: boolean
serviceAccountJson?: string
/** Slack custom-bot secret rotation (reconnect). */
signingSecret?: string
@@ -207,10 +210,24 @@ export async function updateCredentialRecord(
}
}
// Redaction only guards a shared workspace value, so the opt-out is meaningless on any
// other credential type. Rejected here, at the same layer as the description rule, so no
// surface can write a flag every reader would then have to special-case away.
if (params.unredacted !== undefined && params.credential.type !== 'env_workspace') {
return {
success: false,
error: 'Only workspace secrets can be marked visible (unredacted).',
errorCode: 'validation',
}
}
const updates: Record<string, unknown> = {}
if (params.description !== undefined) {
updates.description = params.description ?? null
}
if (params.unredacted !== undefined) {
updates.unredacted = params.unredacted
}
if (
params.displayName !== undefined &&
(params.credential.type === 'oauth' || params.credential.type === 'service_account')
@@ -383,6 +400,13 @@ export async function updateCredentialRecord(
updates.updatedAt = new Date()
await db.update(credential).set(updates).where(eq(credential.id, params.credentialId))
// The flag rides the environment snapshot into every run's redaction catalog, so a flip
// must not serve stale from the same-process snapshot cache. Cross-process readers are
// bounded by that cache's short TTL instead.
if (updates.unredacted !== undefined) {
invalidateEffectiveDecryptedEnvCache({ workspaceId: params.credential.workspaceId })
}
// Reconnecting to a recreated Slack app changes the bot user id, but each
// deployed webhook cached the old one at deploy for reaction self-drop.
// Propagate the rotated id to the credential's live custom-bot webhooks so
@@ -398,12 +422,16 @@ export async function updateCredentialRecord(
}
const updatedFields = auditUpdatedFields(updates)
const auditMetadata =
params.unredacted === undefined
? rotatedAuditMetadata
: { ...(rotatedAuditMetadata ?? {}), unredacted: params.unredacted }
return {
success: true,
workspaceId: params.credential.workspaceId,
updatedFields,
previousDisplayName: params.credential.displayName,
auditMetadata: rotatedAuditMetadata,
auditMetadata,
}
} catch (error) {
if (error instanceof Error && error.message.includes('unique')) {
+4
View File
@@ -36,6 +36,8 @@ export interface VisibleWorkspaceCredential {
type: CredentialRow['type']
displayName: string
description: string | null
/** True only on env_workspace secrets that opt out of redaction. */
unredacted: boolean
providerId: string | null
accountId: string | null
envKey: string | null
@@ -163,6 +165,7 @@ export async function listVisibleWorkspaceCredentials(params: {
type: credential.type,
displayName: credential.displayName,
description: credential.description,
unredacted: credential.unredacted,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
@@ -254,6 +257,7 @@ export async function listWorkspacePrincipalCredentials(params: {
type: credential.type,
displayName: credential.displayName,
description: credential.description,
unredacted: credential.unredacted,
providerId: credential.providerId,
accountId: credential.accountId,
createdBy: credential.createdBy,
+125 -1
View File
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockEncryptSecret,
mockDecryptSecret,
mockCreateWorkspaceEnvCredentials,
mockDeleteWorkspaceEnvCredentials,
mockUpsertPersonalEnvCredentialForUser,
@@ -13,6 +14,7 @@ const {
mockInvalidateEffectiveDecryptedEnvCache,
} = vi.hoisted(() => ({
mockEncryptSecret: vi.fn(),
mockDecryptSecret: vi.fn(),
mockCreateWorkspaceEnvCredentials: vi.fn(),
mockDeleteWorkspaceEnvCredentials: vi.fn(),
mockUpsertPersonalEnvCredentialForUser: vi.fn(),
@@ -20,7 +22,10 @@ const {
mockInvalidateEffectiveDecryptedEnvCache: vi.fn(),
}))
vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret }))
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mockDecryptSecret,
encryptSecret: mockEncryptSecret,
}))
vi.mock('@/lib/credentials/environment', () => ({
createWorkspaceEnvCredentials: mockCreateWorkspaceEnvCredentials,
deleteWorkspaceEnvCredentials: mockDeleteWorkspaceEnvCredentials,
@@ -34,6 +39,7 @@ vi.mock('@/lib/environment/utils', () => ({
import {
deletePersonalSecret,
deleteWorkspaceSecret,
readWorkspaceSecretValues,
setPersonalSecret,
setWorkspaceSecret,
} from '@/lib/credentials/secret-values'
@@ -104,6 +110,71 @@ describe('secret value storage', () => {
expect(result.created).toBe(false)
})
it('writes the unredacted flag onto the credential row without touching the description', async () => {
queueTableRows(schemaMock.workspaceEnvironment, [
{
id: 'env-1',
variables: { STRIPE_KEY: 'encrypted-existing-value' },
createdAt: new Date('2024-01-01T00:00:00Z'),
},
])
await setWorkspaceSecret({
workspaceId: 'workspace-1',
name: 'STRIPE_KEY',
value: 'rotated',
userId: 'user-1',
unredacted: true,
})
const credentialUpdate = dbChainMockFns.set.mock.calls[0][0] as Record<string, unknown>
expect(credentialUpdate.unredacted).toBe(true)
expect(credentialUpdate).not.toHaveProperty('description')
})
it('leaves the stored unredacted flag alone when the caller omits it', async () => {
queueTableRows(schemaMock.workspaceEnvironment, [
{
id: 'env-1',
variables: { STRIPE_KEY: 'encrypted-existing-value' },
createdAt: new Date('2024-01-01T00:00:00Z'),
},
])
await setWorkspaceSecret({
workspaceId: 'workspace-1',
name: 'STRIPE_KEY',
value: 'rotated',
userId: 'user-1',
})
const credentialUpdate = dbChainMockFns.set.mock.calls[0][0] as Record<string, unknown>
expect(credentialUpdate).not.toHaveProperty('unredacted')
expect(credentialUpdate).not.toHaveProperty('description')
})
it('carries a description without touching the unredacted flag', async () => {
queueTableRows(schemaMock.workspaceEnvironment, [
{
id: 'env-1',
variables: { STRIPE_KEY: 'encrypted-existing-value' },
createdAt: new Date('2024-01-01T00:00:00Z'),
},
])
await setWorkspaceSecret({
workspaceId: 'workspace-1',
name: 'STRIPE_KEY',
value: 'rotated',
userId: 'user-1',
description: 'Prod billing key',
})
const credentialUpdate = dbChainMockFns.set.mock.calls[0][0] as Record<string, unknown>
expect(credentialUpdate.description).toBe('Prod billing key')
expect(credentialUpdate).not.toHaveProperty('unredacted')
})
it('sets a personal value through caller-owned metadata only', async () => {
queueTableRows(schemaMock.environment, [])
@@ -157,3 +228,56 @@ describe('secret value storage', () => {
expect(mockInvalidateEffectiveDecryptedEnvCache).not.toHaveBeenCalled()
})
})
describe('readWorkspaceSecretValues', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockDecryptSecret.mockImplementation(async (encrypted: string) => ({
decrypted: `decrypted:${encrypted}`,
}))
})
it('decrypts only the requested names and omits absent or undecryptable ones', async () => {
queueTableRows(schemaMock.workspaceEnvironment, [
{
id: 'env-1',
variables: {
VISIBLE_KEY: 'encrypted-visible',
BROKEN_KEY: 'encrypted-broken',
OTHER_KEY: 'encrypted-other',
},
},
])
mockDecryptSecret.mockImplementation(async (encrypted: string) => {
if (encrypted === 'encrypted-broken') throw new Error('cannot decrypt')
return { decrypted: `decrypted:${encrypted}` }
})
await expect(
readWorkspaceSecretValues({
workspaceId: 'workspace-1',
names: ['VISIBLE_KEY', 'BROKEN_KEY', 'MISSING_KEY'],
})
).resolves.toEqual({ VISIBLE_KEY: 'decrypted:encrypted-visible' })
expect(mockDecryptSecret).not.toHaveBeenCalledWith('encrypted-other')
})
it('reads nothing when no names are requested', async () => {
await expect(
readWorkspaceSecretValues({ workspaceId: 'workspace-1', names: [] })
).resolves.toEqual({})
expect(mockDecryptSecret).not.toHaveBeenCalled()
})
it('never reads an inherited prototype member for a missing key', async () => {
queueTableRows(schemaMock.workspaceEnvironment, [
{ id: 'env-1', variables: { OTHER_KEY: 'encrypted-other' } },
])
await expect(
readWorkspaceSecretValues({ workspaceId: 'workspace-1', names: ['constructor', 'toString'] })
).resolves.toEqual({})
expect(mockDecryptSecret).not.toHaveBeenCalled()
})
})
+47 -3
View File
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { credential, environment, workspaceEnvironment } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, sql } from 'drizzle-orm'
import { encryptSecret } from '@/lib/core/security/encryption'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import {
createWorkspaceEnvCredentials,
deletePersonalEnvCredentialForUser,
@@ -26,6 +26,44 @@ async function lockSecretMap(tx: DbOrTx, lockKey: string): Promise<void> {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`)
}
/**
* Decrypts the stored values for the requested workspace secret names.
*
* Exists for exactly one read path: rows a workspace marked visible (unredacted),
* whose values already print into every run log the caller can open. Every other
* secret read stays metadata-only — callers gate on the flag BEFORE asking. A
* name that is absent or fails to decrypt is omitted rather than failing the
* batch, since the value is optional on the wire.
*/
export async function readWorkspaceSecretValues(params: {
workspaceId: string
names: readonly string[]
}): Promise<Record<string, string>> {
if (params.names.length === 0) return {}
const [row] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, params.workspaceId))
.limit(1)
const variables = (row?.variables as Record<string, string> | null) ?? {}
const values: Record<string, string> = {}
await Promise.all(
params.names.map(async (name) => {
const encrypted = Object.hasOwn(variables, name) ? variables[name] : undefined
if (!encrypted) return
try {
const { decrypted } = await decryptSecret(encrypted)
values[name] = decrypted
} catch {
// Omitted from the result; the caller's wire shape treats the value as optional.
}
})
)
return values
}
/** Stores one workspace secret without decrypting any existing value. */
export async function setWorkspaceSecret(params: {
workspaceId: string
@@ -38,8 +76,10 @@ export async function setWorkspaceSecret(params: {
* clears it.
*/
description?: string | null
/** Redaction opt-out on the credential row. `undefined` leaves the current setting. */
unredacted?: boolean
}): Promise<SecretMutationResult> {
const { workspaceId, name, value, userId, description } = params
const { workspaceId, name, value, userId, description, unredacted } = params
const { encrypted } = await encryptSecret(value)
const updatedAt = new Date()
@@ -82,7 +122,11 @@ export async function setWorkspaceSecret(params: {
})
await tx
.update(credential)
.set(description === undefined ? { updatedAt } : { updatedAt, description })
.set({
updatedAt,
...(description !== undefined ? { description } : {}),
...(unredacted !== undefined ? { unredacted } : {}),
})
.where(
and(
eq(credential.workspaceId, workspaceId),
+77
View File
@@ -100,6 +100,40 @@ describe('getPersonalAndWorkspaceEnv access filtering', () => {
expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledOnce()
})
it('collects workspaceUnredactedKeys only from flagged env_workspace credential rows', async () => {
mockGetAccessibleEnvCredentials.mockResolvedValue([
{
type: 'env_workspace',
envKey: 'VISIBLE_KEY',
envOwnerUserId: null,
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
unredacted: true,
},
{
type: 'env_workspace',
envKey: 'HIDDEN_KEY',
envOwnerUserId: null,
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
unredacted: false,
},
{
type: 'env_personal',
envKey: 'PERSONAL_KEY',
envOwnerUserId: 'user-1',
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
unredacted: true,
},
])
queueTableRows(environment, [{ variables: {} }])
queueTableRows(workspaceEnvironment, [
{ variables: { VISIBLE_KEY: 'visible-cipher', HIDDEN_KEY: 'hidden-cipher' } },
])
const snapshot = await getPersonalAndWorkspaceEnv('user-1', 'workspace-1')
expect(snapshot.workspaceUnredactedKeys).toEqual(['VISIBLE_KEY'])
})
it('preserves shared-personal precedence when an accessible owner shares the same name', async () => {
mockGetAccessibleEnvCredentials.mockResolvedValue([
{
@@ -183,6 +217,49 @@ describe('getExecutionEnvironment', () => {
expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' })
})
it('carries the ACTOR workspaceUnredactedKeys on a split-identity run', async () => {
grantAdminTo('actor-1')
mockGetAccessibleEnvCredentials.mockImplementation(
async (_workspaceId: string, userId: string) => [
{
type: 'env_workspace',
envKey: userId === 'actor-1' ? 'ACTOR_VISIBLE' : 'OWNER_VISIBLE',
envOwnerUserId: null,
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
unredacted: true,
},
]
)
queueTableRows(environment, [{ variables: {} }])
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
queueTableRows(environment, [{ variables: {} }])
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
const snapshot = await getExecutionEnvironment('owner-1', 'actor-1', 'workspace-1')
expect(snapshot.workspaceUnredactedKeys).toEqual(['ACTOR_VISIBLE'])
})
it('keeps workspaceUnredactedKeys on an anonymous workspace-only run', async () => {
grantAdminTo('billing-account')
mockGetAccessibleEnvCredentials.mockResolvedValue([
{
type: 'env_workspace',
envKey: 'WORKSPACE_VISIBLE',
envOwnerUserId: null,
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
unredacted: true,
},
])
queueTableRows(environment, [{ variables: {} }])
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_VISIBLE: 'workspace-cipher' } }])
const snapshot = await getExecutionEnvironment(undefined, 'billing-account', 'workspace-1')
expect(snapshot.personalDecrypted).toEqual({})
expect(snapshot.workspaceUnredactedKeys).toEqual(['WORKSPACE_VISIBLE'])
})
it('falls back to the personal identity when the actor cannot reach the workspace', async () => {
mockCheckWorkspaceAccess.mockImplementation(async (_workspaceId: string, userId: string) => ({
exists: true,
+11
View File
@@ -51,6 +51,12 @@ export interface EnvironmentResolutionSnapshot {
personalOwners: Record<string, string>
conflicts: string[]
decryptionFailures: string[]
/**
* Workspace env keys whose credential row opts them out of resolved-secret redaction.
* Only ever workspace keys — personal secrets cannot carry the flag — and only keys
* with a credential row: a legacy jsonb key without one stays redacted by omission.
*/
workspaceUnredactedKeys: string[]
}
interface EffectiveEnvironmentCacheEntry {
@@ -79,6 +85,7 @@ function cloneEnvironmentResolutionSnapshot(
personalOwners: { ...snapshot.personalOwners },
conflicts: [...snapshot.conflicts],
decryptionFailures: [...snapshot.decryptionFailures],
workspaceUnredactedKeys: [...snapshot.workspaceUnredactedKeys],
}
}
@@ -279,6 +286,9 @@ export async function getPersonalAndWorkspaceEnv(
personalOwners,
conflicts,
decryptionFailures,
workspaceUnredactedKeys: accessibleEnvCredentials
.filter((row) => row.type === 'env_workspace' && row.unredacted)
.map((row) => row.envKey),
}
}
@@ -371,6 +381,7 @@ export async function getExecutionEnvironment(
(key) => key in actor.workspaceEncrypted
),
decryptionFailures,
workspaceUnredactedKeys: actor.workspaceUnredactedKeys,
}
}
@@ -30,6 +30,7 @@ import {
} from '@/lib/logs/execution/trace-secret-projection'
import type { TraceSpan } from '@/lib/logs/types'
import {
createResolvedSecretTraceRegistry,
type ResolvedSecretTraceMatch,
ResolvedSecretTraceRegistry,
} from '@/executor/utils/resolved-secret-trace-registry'
@@ -266,6 +267,58 @@ describe('projectTraceSpansForSecrets', () => {
expect(result[0]).not.toBe(source)
})
it('passes content holding only an exempt secret through projection and invariant verbatim', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: {},
workspaceEncrypted: { NAME: 'encrypted-exempt' },
personalDecrypted: {},
workspaceDecrypted: { NAME: 'exempt-value-123' },
workspaceUnredactedKeys: ['NAME'],
})
expect(registry.recordResolved('NAME', 'exempt-value-123')).toBe(true)
const source = [
createSpan({
input: { code: 'return {{NAME}}' },
output: { token: 'Bearer exempt-value-123' },
}),
]
const [projected] = await projectTraceSpansForSecrets(source, { registry, store: STORE })
expect(projected.input).toEqual({ code: 'return {{NAME}}' })
expect(projected.output).toEqual({ token: 'Bearer exempt-value-123' })
const enforced = await enforceTraceSpanSecretInvariant(source, { registry, store: STORE })
expect(enforced).toBe(source)
expect(enforced[0].output).toEqual({ token: 'Bearer exempt-value-123' })
})
it('still substitutes a non-exempt secret alongside an exempt one', async () => {
const registry = await createResolvedSecretTraceRegistry({
personalEncrypted: {},
workspaceEncrypted: {
EXEMPT_KEY: 'encrypted-exempt',
PROTECTED_KEY: 'encrypted-protected',
},
personalDecrypted: {},
workspaceDecrypted: {
EXEMPT_KEY: 'exempt-value-123',
PROTECTED_KEY: 'protected-value-456',
},
workspaceUnredactedKeys: ['EXEMPT_KEY'],
})
expect(registry.recordResolved('EXEMPT_KEY', 'exempt-value-123')).toBe(true)
expect(registry.recordResolved('PROTECTED_KEY', 'protected-value-456')).toBe(true)
const [projected] = await projectTraceSpansForSecrets(
[createSpan({ output: { value: 'exempt-value-123 protected-value-456' } })],
{ registry, store: STORE }
)
expect(projected.output).toEqual({ value: 'exempt-value-123 {{PROTECTED_KEY}}' })
})
it('uses deterministic longest matches and does not rescan replacements', async () => {
const result = await projectTraceSpansForSecrets(
[createSpan({ output: { value: 'secret-suffix secretval A_SECRET' } })],
+1
View File
@@ -77,6 +77,7 @@ export async function resolveMcpConfigEnvVars(
workspaceDecrypted: env.workspaceDecrypted,
decryptionFailures: env.decryptionFailures,
personalOwners: env.personalOwners,
workspaceUnredactedKeys: env.workspaceUnredactedKeys,
scope,
})
} catch (error) {
@@ -20,6 +20,7 @@ const { mocks } = vi.hoisted(() => ({
setPersonal: vi.fn(),
deletePersonal: vi.fn(),
listCredentials: vi.fn(),
readWorkspaceValues: vi.fn(),
secretUsage: vi.fn(),
workspaceEnvValue: vi.fn(),
scanReferences: vi.fn(),
@@ -63,6 +64,7 @@ vi.mock('@/lib/secrets/usage/queries', () => ({
vi.mock('@/lib/credentials/secret-values', () => ({
deletePersonalSecret: mocks.deletePersonal,
deleteWorkspaceSecret: vi.fn(),
readWorkspaceSecretValues: mocks.readWorkspaceValues,
setPersonalSecret: mocks.setPersonal,
setWorkspaceSecret: mocks.setWorkspace,
}))
@@ -71,6 +73,7 @@ import {
deleteSecretUseCase,
type ListSecretReferencesInput,
listSecretReferencesUseCase,
listSecretsUseCase,
listSecretUsageUseCase,
setSecretUseCase,
} from '@/lib/secrets/application/use-cases'
@@ -96,6 +99,15 @@ const secret = {
updatedAt: new Date('2026-01-02T00:00:00Z'),
hasServiceAccountKey: false,
role: 'admin' as const,
unredacted: false,
}
const visibleSecret = {
...secret,
id: 'secret-3',
displayName: 'STAGING_BASE_URL',
envKey: 'STAGING_BASE_URL',
unredacted: true,
}
const personalUpdatedAt = new Date('2026-02-01T00:00:00Z')
@@ -122,9 +134,59 @@ describe('secret application use cases', () => {
mocks.personalMetadata.mockResolvedValue(null)
mocks.deletePersonal.mockResolvedValue(true)
mocks.listCredentials.mockResolvedValue({ data: [secret], nextCursorKeys: null })
mocks.readWorkspaceValues.mockResolvedValue({})
mocks.secretUsage.mockResolvedValue({ entries: [] })
})
it('reads values for exactly the visible (unredacted) workspace rows on the page', async () => {
mocks.listCredentials.mockResolvedValue({
data: [secret, visibleSecret, personalSecret],
nextCursorKeys: null,
})
mocks.readWorkspaceValues.mockResolvedValue({
[visibleSecret.envKey]: 'https://staging.example.com',
})
const result = await listSecretsUseCase.execute({
principal: session,
input: {
workspaceId: workspace.workspaceId,
sortBy: 'name',
sortOrder: 'asc',
limit: 50,
},
})
expect(mocks.readWorkspaceValues).toHaveBeenCalledWith({
workspaceId: workspace.workspaceId,
names: [visibleSecret.envKey],
})
expect(result.values).toEqual({ [visibleSecret.envKey]: 'https://staging.example.com' })
})
it('never asks for values when no row on the page is marked visible', async () => {
mocks.listCredentials.mockResolvedValue({
data: [secret, personalSecret],
nextCursorKeys: null,
})
const result = await listSecretsUseCase.execute({
principal: session,
input: {
workspaceId: workspace.workspaceId,
sortBy: 'name',
sortOrder: 'asc',
limit: 50,
},
})
expect(mocks.readWorkspaceValues).toHaveBeenCalledWith({
workspaceId: workspace.workspaceId,
names: [],
})
expect(result.values).toEqual({})
})
it('rejects workspace keys before resolving or reading secret state', async () => {
const execute = setSecretUseCase.execute as (args: {
principal: Principal
@@ -216,6 +278,76 @@ describe('secret application use cases', () => {
).rejects.toThrow(/only supported for a workspace secret/)
})
it('refuses unredacted on a personal secret in the use case, not just the contract', async () => {
await expect(
setSecretUseCase.execute({
principal: session,
input: {
workspaceId: workspace.workspaceId,
name: secret.envKey,
scope: 'personal',
value: 'secret-value',
unredacted: true,
},
})
).rejects.toThrow(/unredacted is only supported for a workspace secret/)
expect(mocks.setPersonal).not.toHaveBeenCalled()
expect(mocks.audit).not.toHaveBeenCalled()
})
it('forwards the workspace unredacted flag to the manager and audits its value', async () => {
await setSecretUseCase.execute({
principal: session,
input: {
workspaceId: workspace.workspaceId,
name: secret.envKey,
scope: 'workspace',
value: 'secret-value',
unredacted: true,
},
})
expect(mocks.setWorkspace).toHaveBeenCalledWith(expect.objectContaining({ unredacted: true }))
expect(mocks.audit).toHaveBeenCalledWith(
expect.objectContaining({ metadata: expect.objectContaining({ unredacted: true }) })
)
})
it('audits the recorded value when the flag is explicitly turned off', async () => {
await setSecretUseCase.execute({
principal: session,
input: {
workspaceId: workspace.workspaceId,
name: secret.envKey,
scope: 'workspace',
value: 'secret-value',
unredacted: false,
},
})
expect(mocks.setWorkspace).toHaveBeenCalledWith(expect.objectContaining({ unredacted: false }))
expect(mocks.audit).toHaveBeenCalledWith(
expect.objectContaining({ metadata: expect.objectContaining({ unredacted: false }) })
)
})
it('omits unredacted from the audit metadata when it was not provided', async () => {
await setSecretUseCase.execute({
principal: session,
input: {
workspaceId: workspace.workspaceId,
name: secret.envKey,
scope: 'workspace',
value: 'secret-value',
},
})
const metadata = (mocks.audit.mock.calls[0][0] as { metadata: Record<string, unknown> })
.metadata
expect(metadata).not.toHaveProperty('unredacted')
})
it('still fails a workspace write whose metadata never materialized', async () => {
mocks.listCredentials.mockResolvedValue({ data: [], nextCursorKeys: null })
@@ -16,6 +16,7 @@ import {
import {
deletePersonalSecret,
deleteWorkspaceSecret,
readWorkspaceSecretValues,
setPersonalSecret,
setWorkspaceSecret,
} from '@/lib/credentials/secret-values'
@@ -193,6 +194,7 @@ async function getPersonalSecretMetadata(params: {
type: 'env_personal',
displayName: params.name,
description: null,
unredacted: false,
providerId: null,
accountId: null,
envKey: params.name,
@@ -238,8 +240,22 @@ export const listSecretsUseCase = defineAuthorizedWorkspaceUseCase({
workspaceId: context.workspaceId,
userId,
})
/**
* The one place a secret value rides a read response: rows the workspace marked
* visible (unredacted) — whose values already print into every run log this
* caller can open — so external agents don't have to scrape logs for them.
* Bounded by the page, and read from one environment row.
*/
const visibleNames = page.data.flatMap((row) =>
row.type === 'env_workspace' && row.unredacted && row.envKey ? [row.envKey] : []
)
const values = await readWorkspaceSecretValues({
workspaceId: context.workspaceId,
names: visibleNames,
})
return {
secrets: page.data,
values,
nextCursorKeys: page.nextCursorKeys,
userId,
sortBy: input.sortBy,
@@ -259,6 +275,8 @@ export interface SetSecretInput {
* description written here would exist in this workspace alone.
*/
description?: string | null
/** Redaction opt-out; workspace scope only, for the same mirror-row reason as description. */
unredacted?: boolean
}
export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({
@@ -274,6 +292,12 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({
'description is only supported for a workspace secret'
)
}
if (input.scope === 'personal' && input.unredacted !== undefined) {
throw new OrchestrationError(
'validation',
'unredacted is only supported for a workspace secret'
)
}
if (input.scope === 'workspace') {
await requireWorkspaceSecretMutationAccess({
workspaceId: context.workspaceId,
@@ -289,6 +313,7 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({
value: input.value,
userId,
description: input.description,
unredacted: input.unredacted,
})
const secret = await getWorkspaceSecretMetadata({
workspaceId: context.workspaceId,
@@ -317,6 +342,8 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({
scope: input.scope,
name: input.name,
...(input.description !== undefined ? { descriptionUpdated: true } : {}),
/** The value, not just a marker: enabling visibility is the security-relevant event. */
...(input.unredacted !== undefined ? { unredacted: input.unredacted } : {}),
},
}),
})
@@ -543,6 +543,7 @@ async function executeWorkflowCoreImpl(
workspaceDecrypted,
decryptionFailures,
personalOwners,
workspaceUnredactedKeys,
} = env
// Use encrypted values for logging (don't log decrypted secrets)
@@ -566,6 +567,7 @@ async function executeWorkflowCoreImpl(
workspaceDecrypted,
decryptionFailures,
personalOwners,
workspaceUnredactedKeys,
restoredProvenance: restoreTrusted ? restoredState?.resolvedSecretTraceProvenance : undefined,
restoredCheckpointVersion: restoredState?.resolvedSecretTraceCheckpointVersion,
restoreTrusted,
+1
View File
@@ -173,6 +173,7 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
sandboxId: params.sandboxId,
secretScope: params.secretScope,
mountedSecrets: params.mountedSecrets,
unredactedSecretNames: params.unredactedSecretNames,
overwriteFileId: params.overwriteFileId,
inputs: params.inputs,
outputs: params.outputs,
+2
View File
@@ -44,6 +44,8 @@ export interface CodeExecutionInput {
secretScope?: 'all' | 'selected'
/** Secret names visible to the code when {@link secretScope} is `'selected'`. */
mountedSecrets?: string[]
/** Names the caller's registry certifies as redaction-exempt; exported files carrying only these values are not provenance-locked. */
unredactedSecretNames?: string[]
envVars?: Record<string, string>
workflowVariables?: Record<string, unknown>
blockData?: Record<string, unknown>
@@ -0,0 +1 @@
ALTER TABLE "credential" ADD COLUMN "unredacted" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -2122,6 +2122,13 @@
"when": 1787506599710,
"tag": "0303_colorful_gressill",
"breakpoints": true
},
{
"idx": 304,
"version": "7",
"when": 1787598098228,
"tag": "0304_slippery_carmella_unuscione",
"breakpoints": true
}
]
}
+7
View File
@@ -3924,6 +3924,13 @@ export const credential = pgTable(
type: credentialTypeEnum('type').notNull(),
displayName: text('display_name').notNull(),
description: text('description'),
/**
* Opts an env_workspace secret out of resolved-secret redaction: its value renders in
* plaintext across surfaces (logs, model-visible content, sandbox file exports) instead
* of `{{NAME}}`, and is not recorded into durable provenance. Meaningful only for
* type = 'env_workspace'; writes for other types are rejected in the orchestration layer.
*/
unredacted: boolean('unredacted').notNull().default(false),
providerId: text('provider_id'),
accountId: text('account_id').references(() => account.id, { onDelete: 'cascade' }),
envKey: text('env_key'),
+9
View File
@@ -4015,9 +4015,11 @@ type ListSecretsResponseRef0 = {
name: string
scope: 'workspace' | 'personal'
description: string | null
unredacted: boolean
role: 'admin' | 'member'
createdAt: string
updatedAt: string
value?: string
}
export type ListSecretsResponse = {
@@ -5129,12 +5131,14 @@ export type SetSecretBody = {
scope: 'workspace' | 'personal'
value: string
description?: string | null
unredacted?: boolean
}
type SetSecretResponseRef0 = {
name: string
scope: 'workspace' | 'personal'
description: string | null
unredacted: boolean
role: 'admin' | 'member'
createdAt: string
updatedAt: string
@@ -9241,6 +9245,11 @@ export const V2_OPERATIONS = {
describe:
'What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.',
},
unredacted: {
kind: 'boolean',
describe:
'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.',
},
},
},
syncKnowledgeConnector: {