Skip to content

Commit dde3fb8

Browse files
fix(security): document fail-closed tool redaction
1 parent 4a57f6d commit dde3fb8

10 files changed

Lines changed: 56 additions & 35 deletions

File tree

apps/docs/openapi-v2-logs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1206,7 +1206,7 @@
12061206
"type": "null"
12071207
}
12081208
],
1209-
"description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained."
1209+
"description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained."
12101210
},
12111211
"traceSpans": {
12121212
"type": "array",

apps/docs/openapi-v2-workflows.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2876,7 +2876,7 @@
28762876
"format": "date-time"
28772877
},
28782878
"state": {
2879-
"description": "Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null, and sensitive nested tool parameters are null.",
2879+
"description": "Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.",
28802880
"$ref": "#/components/schemas/DeployedWorkflowState"
28812881
}
28822882
},

apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,14 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({
3030
getWorkflowDeploymentVersion: mocks.readVersion,
3131
}))
3232
vi.mock('@/lib/workflows/search-replace/indexer', () => ({
33-
getToolInputParamConfigs: ({ tool }: { tool: { params?: Record<string, unknown> } }) =>
33+
getToolInputParamConfigs: ({
34+
tool,
35+
}: {
36+
tool: { type: string; params?: Record<string, unknown> }
37+
}) =>
3438
Object.entries(tool.params ?? {}).map(([paramId, value]) => ({
3539
paramId,
36-
authoritative: true,
40+
authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp',
3741
value,
3842
config: {
3943
id: paramId,
@@ -182,7 +186,7 @@ describe('GET /api/v2/workflows/[id]/versions/[version]', () => {
182186
expect(subBlocks.tools.value).toEqual([
183187
{
184188
type: 'custom-tool',
185-
params: { apiKey: null, query: 'safe input' },
189+
params: { apiKey: null, query: null },
186190
},
187191
])
188192
expect(subBlocks.headers.value).toBeNull()

apps/sim/lib/api/contracts/v2/logs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const v2LogWorkflowStateSchema = z
7474
)
7575
.nullable()
7676
.describe(
77-
'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained.'
77+
'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained.'
7878
)
7979

8080
const v2LogWorkflowSummarySchema = z.object({

apps/sim/lib/api/contracts/v2/workflows.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ export const v2WorkflowVersionDetailSchema = z
614614
.describe('ISO 8601 timestamp when this version was created.')
615615
.meta({ format: 'date-time' }),
616616
state: deployedWorkflowStateSchema.describe(
617-
'Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null, and sensitive nested tool parameters are null.'
617+
'Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.'
618618
),
619619
})
620620
.meta({

apps/sim/lib/logs/application/get-public-log.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ type PublicWorkflowLog = NonNullable<Awaited<ReturnType<typeof getPublicWorkflow
2424
*
2525
* `preserveEnvVars` keeps `{{VAR}}` references, which name a workspace environment variable
2626
* rather than carrying its value — resolution happens at execution time — so the reference is
27-
* not a secret and is what keeps consecutive run snapshots diffable.
27+
* not a secret and is what keeps consecutive run snapshots diffable. Tool parameters without
28+
* authoritative codec metadata are withheld rather than guessed safe.
2829
*
2930
* A run with no retained snapshot projects as `null`, and so does a stored value that is not an
3031
* object: the sanitizer can make no guarantee about a shape it cannot walk, so it is withheld

apps/sim/lib/logs/application/public-log-use-cases.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,14 @@ vi.mock('@/lib/logs/execution/trace-store', () => ({
4444
}))
4545

4646
vi.mock('@/lib/workflows/search-replace/indexer', () => ({
47-
getToolInputParamConfigs: ({ tool }: { tool: { params?: Record<string, unknown> } }) =>
47+
getToolInputParamConfigs: ({
48+
tool,
49+
}: {
50+
tool: { type: string; params?: Record<string, unknown> }
51+
}) =>
4852
Object.entries(tool.params ?? {}).map(([paramId, value]) => ({
4953
paramId,
50-
authoritative: true,
54+
authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp',
5155
value,
5256
config: {
5357
id: paramId,
@@ -211,7 +215,7 @@ describe('public log application use cases', () => {
211215
expect(subBlocks.tools.value).toEqual([
212216
{
213217
type: 'custom-tool',
214-
params: { apiKey: null, query: 'safe input' },
218+
params: { apiKey: null, query: null },
215219
},
216220
])
217221
expect(subBlocks.headers.value).toBeNull()

apps/sim/lib/workflows/application/read-workflow-version.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ function isWorkflowState(value: unknown): value is WorkflowState {
2121
* `preserveEnvVars` keeps `{{VAR}}` references: those name a workspace environment variable
2222
* rather than carrying its value — resolution happens at execution time — so the reference is
2323
* not a secret and is what keeps the pinned graph diffable. Literal inline secrets, opaque table
24-
* cells, and sensitive nested tool parameters are nulled.
24+
* cells, sensitive nested tool parameters, and tool parameters without authoritative codec
25+
* metadata are nulled.
2526
*/
2627
function sanitizeVersionState(state: WorkflowState): WorkflowState {
2728
const sanitized = sanitizeWorkflowForSharing(state, {

apps/sim/lib/workflows/credentials/credential-extractor.test.ts

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,21 @@ import { getBlock } from '@/blocks/registry'
1212
import type { WorkflowState } from '@/stores/workflows/workflow/types'
1313

1414
vi.mock('@/lib/workflows/search-replace/indexer', () => ({
15-
getToolInputParamConfigs: ({ tool }: { tool: { params?: Record<string, unknown> } }) =>
16-
Object.entries(tool.params ?? {})
17-
.filter(([paramId]) => paramId !== 'unclassified')
18-
.map(([paramId, value]) => ({
19-
paramId,
20-
authoritative: true,
21-
value,
22-
config: {
23-
id: paramId,
24-
type: 'short-input',
25-
password: paramId === 'apiKey' || paramId === 'token',
26-
},
27-
})),
15+
getToolInputParamConfigs: ({
16+
tool,
17+
}: {
18+
tool: { type: string; params?: Record<string, unknown> }
19+
}) =>
20+
Object.entries(tool.params ?? {}).map(([paramId, value]) => ({
21+
paramId,
22+
authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp',
23+
value,
24+
config: {
25+
id: paramId,
26+
type: 'short-input',
27+
password: paramId === 'apiKey' || paramId === 'token',
28+
},
29+
})),
2830
}))
2931

3032
function stateWithSubBlock(type: string, value: unknown): Partial<WorkflowState> {
@@ -111,15 +113,15 @@ describe('export sanitizer resource coverage', () => {
111113
expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBeNull()
112114
})
113115

114-
it('uses tool-input codecs to withhold secret params while preserving safe config', () => {
116+
it('uses authoritative tool-input codecs to withhold secrets while preserving safe config', () => {
115117
const value = [
116118
{
117-
type: 'custom-tool',
118-
customToolId: 'tool-1',
119+
type: 'gmail',
120+
toolId: 'gmail_send',
121+
operation: 'send_gmail',
119122
params: {
120123
apiKey: 'sk-plaintext-secret',
121124
query: 'safe input',
122-
unclassified: 'must-not-pass-through',
123125
},
124126
},
125127
]
@@ -137,9 +139,10 @@ describe('export sanitizer resource coverage', () => {
137139

138140
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
139141
{
140-
type: 'custom-tool',
141-
customToolId: 'tool-1',
142-
params: { apiKey: null, query: 'safe input', unclassified: null },
142+
type: 'gmail',
143+
toolId: 'gmail_send',
144+
operation: 'send_gmail',
145+
params: { apiKey: null, query: 'safe input' },
143146
},
144147
])
145148
})
@@ -164,18 +167,21 @@ describe('export sanitizer resource coverage', () => {
164167
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toBeNull()
165168
})
166169

167-
it('withholds an opaque persisted type when the block is no longer registered', () => {
170+
it('withholds every unclassified custom-tool parameter', () => {
168171
vi.mocked(getBlock).mockReturnValue(undefined as never)
169172

170173
const sanitized = sanitizeWorkflowForSharing(
171174
stateWithSubBlock('tool-input', [
172-
{ type: 'custom-tool', params: { token: 'plaintext-secret' } },
175+
{
176+
type: 'custom-tool',
177+
params: { token: 'plaintext-secret', query: 'ordinary configuration' },
178+
},
173179
]),
174180
{ redactOpaqueCredentialInputs: true }
175181
)
176182

177183
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
178-
{ type: 'custom-tool', params: { token: null } },
184+
{ type: 'custom-tool', params: { token: null, query: null } },
179185
])
180186
})
181187
})

apps/sim/lib/workflows/credentials/credential-extractor.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,11 @@ function isEnvironmentVariableReference(value: unknown): value is string {
278278
return typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')
279279
}
280280

281+
/**
282+
* Sanitizes nested tool parameters using the same codecs as workflow search and fork remapping.
283+
* Only parameters resolved from a registered definition retain non-sensitive values. Custom, MCP,
284+
* and unknown schemas lack reliable secret annotations, so their generic parameters are withheld.
285+
*/
281286
function sanitizeToolInputValue(value: unknown, options: WorkflowSanitizationOptions): unknown {
282287
const tools = parseStoredToolInputValue(value)
283288
if (!Array.isArray(value)) return null

0 commit comments

Comments
 (0)