Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
85930d7
feat(secrets): record which secrets each run resolves and surface it …
icecrasher321 Aug 18, 2026
aadee19
Merge remote-tracking branch 'origin/staging' into feat/secret-usage-…
icecrasher321 Aug 18, 2026
a7539cf
chore(audit): register the secret-usage route in the validation baseline
icecrasher321 Aug 18, 2026
cfc14e4
fix(secrets): keep rollup metadata with its run, and stop shadowed bi…
icecrasher321 Aug 18, 2026
a20c61d
chore(db): format the generated migration snapshot
icecrasher321 Aug 18, 2026
a7a5fff
fix(secrets): detect every rebinding of the environment identifier, n…
icecrasher321 Aug 18, 2026
d293432
fix(secrets): apply the rebinding rule to shell, and say when a run's…
icecrasher321 Aug 18, 2026
7f0c29d
Merge remote-tracking branch 'origin/staging' into feat/secret-usage-…
icecrasher321 Aug 18, 2026
3998ac6
fix(secrets): writing a name is not reading it, and a bare mention is…
icecrasher321 Aug 18, 2026
0e71127
fix(secrets): an update reads before it stores, and a del target may …
icecrasher321 Aug 18, 2026
f6c137c
fix(secrets): stop excluding Python writes, which kept leaking in the…
icecrasher321 Aug 18, 2026
edad2e8
fix(secrets): report recognized reads instead of proving they are not…
icecrasher321 Aug 18, 2026
57d1217
refactor(secrets): drop the last write-vs-read special case
icecrasher321 Aug 19, 2026
1e93f40
refactor(secrets): ship only the fields the trail actually shows
icecrasher321 Aug 19, 2026
ba4e194
fix(secrets): report referenced code secrets, not only ones that surf…
icecrasher321 Aug 19, 2026
4e66984
fix(secrets): shell escaping is backslash parity, not adjacency
icecrasher321 Aug 19, 2026
c00bf47
Merge remote-tracking branch 'origin/staging' into feat/secret-usage-…
icecrasher321 Aug 19, 2026
2ad0882
fix(secrets): recognize destructured environment reads
icecrasher321 Aug 19, 2026
a040b2d
fix(secrets): one receiver rule for destructured reads, parentheses i…
icecrasher321 Aug 19, 2026
01fa6ca
fix(secrets): a dot in prose is not a qualifier, and a literal comput…
icecrasher321 Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/docs/en/logs-debugging/logging.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ import { FAQ } from '@/components/ui/faq'
<FAQ items={[
{ question: "How long are run logs retained?", answer: "Free plans retain logs for 7 days — after that, logs are archived to cloud storage and deleted from the database. Pro, Team, and Enterprise plans retain logs indefinitely with no automatic cleanup." },
{ question: "What data is captured in each run log?", answer: "Each log entry includes the run ID, workflow ID, trigger type, start and end timestamps, total duration in milliseconds, cost breakdown (total cost, token counts, and per-model breakdowns), run data with trace spans, final output, and any associated files. The log details sidebar lets you inspect block-level inputs and outputs." },
{ question: "Are saved secrets visible in logs?", answer: "When a value saved under Secrets is successfully substituted through {{KEY}}, exact, case-sensitive occurrences are masked throughout the log-facing copy, including the live block-log display, Logs Overview input and output, Trace, log-read APIs, and the Logs block's Get Run Details output. This is not a general redactor: hardcoded or directly read values do not activate masking by themselves, and encoded, hashed, or transformed values are not matched. Functional execution responses, streams, and callbacks remain unchanged. See Execution log protection under Secrets for details." },
{ question: "Are saved secrets visible in logs?", answer: "When a value saved under Secrets is successfully substituted through {{KEY}}, exact, case-sensitive occurrences are masked throughout the log-facing copy, including the live block-log display, Logs Overview input and output, Trace, log-read APIs, and the Logs block's Get Run Details output. Direct reads such as environmentVariables['KEY'] or shell $KEY also activate masking when Sim can recognize the read in the code beforehand; a name built at runtime, a reassigned binding, or a hardcoded literal is not recognized. This is not a general redactor: encoded, hashed, or transformed values are not matched. Functional execution responses, streams, and callbacks remain unchanged. See Execution log protection under Secrets for details." },
{ question: "What is a workflow snapshot?", answer: "A frozen copy of the workflow's structure (blocks, connections, and configuration) captured at run time, so you can see the exact state behind a particular run — useful for debugging workflows that have been modified since." },
{ question: "Can I access logs programmatically?", answer: "Yes. The External API provides endpoints to query logs with filtering by workflow, time range, trigger type, duration, cost, and model. You can also set up webhook, email, or Slack notifications for real-time alerts when runs complete." },
{ question: "What does Live mode do on the Logs page?", answer: "It refreshes the Logs page in real time so new entries appear as they are recorded — useful during deployments or when monitoring active workflows." },
Expand Down
29 changes: 28 additions & 1 deletion apps/docs/content/docs/en/platform/credentials.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,22 @@ When a saved secret is successfully substituted through a `{{KEY}}` reference, S

Secret resolution and functional workflow behavior are unchanged: blocks, tools, and downstream steps receive the real runtime value. Stored functional execution data, workflow execution responses, streams, callbacks, block state, and snapshots are not rewritten. Log-facing views and read APIs receive a separate protected copy, so the Logs Overview **Workflow Input** and **Workflow Output** are masked without changing the underlying workflow result. Model requests receive another protected projection: exact secret values known to the run are replaced with `{{KEY}}` before model-visible messages, prompts, tool arguments, or tool continuations leave Sim.

Code that reads a secret straight off the runtime environment — `environmentVariables['KEY']`, `environmentVariables.KEY`, or `const { KEY } = environmentVariables` in JavaScript, `environmentVariables['KEY']` or `environmentVariables.get('KEY')` in Python, `$KEY` or `${KEY}` in shell — also activates masking, provided Sim can see the read in the code before it runs. A hardcoded literal never does: Sim has no way to know it came from a secret.

<Callout type="warn">
Execution-log masking is activated only when Sim successfully resolves a value from **Settings → Secrets** through `{{KEY}}`. A hardcoded literal, direct `environmentVariables['KEY']` read, or shell `$KEY` read does not activate log masking by itself. Model-bound projection also checks the run's authorized secret catalog, including direct reads, but both protections match only exact values. Encoded, hashed, fragmented, or otherwise transformed versions are not matched. Do not deliberately return or print secrets.
Direct reads are found by reading the code, not by running it, so recognition stops where the code stops being readable ahead of time. An unrecognized read is not masked, and does not appear under **See usage**.

Where a read is recognized, Sim reports it rather than trying to prove it is not one. Code that shadows the environment binding with its own object, overwrites a variable before reading it, or assigns to the name instead of reading it is still reported. Naming a secret costs only an exact value the code never emits; failing to name one leaves it unmasked. **See usage** can therefore occasionally list a secret the code had available but did not read.

Assigning to the injected binding does not change the stored secret — it is an ordinary object built from the run's payload and discarded when the run ends. Edit a secret under **Settings → Secrets**.

A read is **not** recognized when:

- **The name is built at runtime.** `environmentVariables[keyName]`, `$@`, `${!indirect}`, `eval`, `printenv`, or a sourced file hide which secret is being read.
- **The read is of a different object.** `other.environmentVariables['KEY']` reads something that merely shares the name.
- **The read cannot be told apart from text.** A `$KEY` inside single quotes or a quoted heredoc (`<<'EOF'`) never expands, and Sim treats anything its scanner cannot place as not running.

Both masking and model-bound projection match only exact values in either case. Encoded, hashed, fragmented, or otherwise transformed versions are not matched, and a value assembled or emitted piece by piece cannot be matched at all — determining whether arbitrary code will eventually reveal a value is not decidable in general. Treat these as a safety net, not a boundary: do not deliberately return, print, or transmit secrets.
</Callout>

### Copilot code execution
Expand Down Expand Up @@ -105,9 +119,22 @@ From here you can:
- View the **Key** and edit the **Value**
- 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.

### 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.

This answers the question worth asking before rotating a key: who has been using it, inside what, and how recently.

Only people who can read the value can see it — a Credential Admin on a workspace secret, or the owner of a personal one. For everyone else the action is visible but disabled, because the trail names workflows, people, and run IDs, which is the same information masking withholds. Two people who each hold a personal secret under the same name see only their own runs.

<Callout>
Usage is recorded independently of execution logs, so it outlives them: logs expire under your workspace's retention setting, while the record of who touched a credential does not. It records what a run resolved, subject to the recognition limits under [Execution log protection](#execution-log-protection) — a read Sim cannot attribute is left out rather than guessed at, so treat an empty trail as "nothing recognized," not proof a secret was never used.
</Callout>

## Workspace vs. Personal

| | Workspace | Personal |
Expand Down
11 changes: 7 additions & 4 deletions apps/docs/content/docs/en/workflows/blocks/function.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -279,10 +279,13 @@ packages, and 10 managed CLI tools.

When a Function block is used as an Agent tool, its code can read every workspace
secret by default — both `{{MY_SECRET}}` and `environmentVariables['MY_SECRET']`.
Use `{{MY_SECRET}}` when the value may appear in execution logs: a successful
double-brace substitution activates [execution-trace masking](/platform/credentials#execution-log-protection),
while direct `environmentVariables['MY_SECRET']` access alone does not activate
it by itself.
Prefer `{{MY_SECRET}}` when the value may appear in execution logs. A successful
double-brace substitution always activates
[execution-trace masking](/platform/credentials#execution-log-protection). A direct
`environmentVariables['MY_SECRET']` read activates it too, but only when Sim can
recognize the read in the code beforehand — a name built at runtime, or a file that
reassigns `environmentVariables` itself, is not recognized. See
[the recognition limits](/platform/credentials#execution-log-protection).

To narrow that, set **Secret access** to *Selected secrets* in the block's
tool configuration and pick the names the code may read. Two things change:
Expand Down
73 changes: 65 additions & 8 deletions apps/sim/app/api/function/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2441,6 +2441,50 @@ describe('Function Execute API Route', () => {
expect(sandboxRequest.privateInputs[0].content).toContain('$UNRELATED `touch /tmp/nope`')
})

/**
* The founding scenario of the usage trail: code that reads a secret and emits it only in
* transformed form. No output ever matches the value, so an output-gated report said
* "never used" for exactly the run an admin needs to see. A referenced secret reports
* whether or not its value surfaces.
*/
it('reports a secret exfiltrated character by character', async () => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({
result: 's|e|c|r|e|t|-|v|a|l|u|e|-|1|2|3|4',
stdout: '',
})
const response = await POST(
createMockRequest(
'POST',
{
code: "const k = '{{API_KEY}}'; return k.split('').join('|')",
envVars: { API_KEY: 'secret-value-1234' },
},
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
)
)

expect(response.status).toBe(200)
expect((await response.json()).__resolvedSecretNames).toEqual(['API_KEY'])
})

/** The ordinary silent use: the key authenticates a call and never appears in output. */
it('reports a secret used without appearing in the output', async () => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: { status: 200 }, stdout: '' })
const response = await POST(
createMockRequest(
'POST',
{
code: "await fetch('https://api.example.com', { headers: { auth: environmentVariables['API_KEY'] } }); return { status: 200 }",
envVars: { API_KEY: 'secret-value-1234' },
},
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
)
)

expect(response.status).toBe(200)
expect((await response.json()).__resolvedSecretNames).toEqual(['API_KEY'])
})

it('does not report a reference when validation rejects before code resolution', async () => {
const response = await POST(
createMockRequest(
Expand Down Expand Up @@ -2469,7 +2513,12 @@ describe('Function Execute API Route', () => {
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
})

it('reports exact secret values returned through placeholders without inferring direct environment reads', async () => {
/**
* A direct read is a factual reference to the environment binding, not the value-coincidence
* inference #6374 removed — that one claimed a secret because its plaintext happened to equal
* an unrelated output. Reporting it is what activates execution-log masking for the value.
*/
it('reports secrets reached through placeholders and through direct environment reads', async () => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({
result: 'secret-valueother-secret',
stdout: '',
Expand Down Expand Up @@ -2507,14 +2556,14 @@ describe('Function Execute API Route', () => {

expect(envData.__resolvedSecretNames).toEqual(['ENV_ONLY', 'SHARED'])
expect(directData.output.result).toBe('secret-value')
expect(directData.__resolvedSecretNames).toEqual([])
expect(directData.__resolvedSecretNames).toEqual(['API_KEY'])
})

it.each([
{ name: 'numeric', secret: '123', result: 123 },
{ name: 'boolean', secret: 'true', result: true },
])(
'preserves a typed $name value returned through legacy direct environment access without inferred provenance',
'preserves a typed $name value returned through a direct environment read while reporting it',
async ({ secret, result }) => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result, stdout: '' })

Expand All @@ -2532,12 +2581,13 @@ describe('Function Execute API Route', () => {
)
const data = await response.json()

/** The typed value survives: a secret this short is never substitutable. */
expect(data.output.result).toBe(result)
expect(data.__resolvedSecretNames).toEqual([])
expect(data.__resolvedSecretNames).toEqual(['API_KEY'])
}
)

it('reports placeholder output without inferring provenance from legacy shell environment access', async () => {
it('reports placeholder output and a shell environment expansion alike', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteShellInSandbox.mockResolvedValueOnce({
result: null,
Expand Down Expand Up @@ -2582,7 +2632,7 @@ describe('Function Execute API Route', () => {

expect(referencedData.__resolvedSecretNames).toEqual(['API_KEY'])
expect(directData.output.stdout).toBe('secret-value')
expect(directData.__resolvedSecretNames).toEqual([])
expect(directData.__resolvedSecretNames).toEqual(['API_KEY'])
})

it('returns nonzero shell stderr as a visible 422 error and diagnostic output', async () => {
Expand Down Expand Up @@ -2672,7 +2722,14 @@ describe('Function Execute API Route', () => {
expect((await response.json()).__resolvedSecretNames).toEqual(['__proto__'])
})

it('does not activate a referenced secret that does not cross the Function result', async () => {
/**
* Previously asserted the inverse: a referenced secret whose value stayed out of the
* result reported nothing. That gate made the trail miss silent use — the ordinary
* API-call case and the transformed-exfiltration case alike — so activation now follows
* the referenced set. The value never appearing costs nothing downstream; the masking
* matcher simply never fires on it.
*/
it('activates a referenced secret even when its value never crosses the result', async () => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'safe-result', stdout: '' })

const response = await POST(
Expand All @@ -2686,7 +2743,7 @@ describe('Function Execute API Route', () => {
)
)

expect((await response.json()).__resolvedSecretNames).toEqual([])
expect((await response.json()).__resolvedSecretNames).toEqual(['API_KEY'])
})

it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
Expand Down
Loading
Loading