Skip to content

Commit d293432

Browse files
icecrasher321claude
andcommitted
fix(secrets): apply the rebinding rule to shell, and say when a run's log is gone
Review round 3, plus the docs that were left claiming the old behavior. - shell.ts: a script that writes a configured name (API_KEY=local, export/local/ readonly, read, for, unset) expands its own value from that point on, not the mounted secret, so recording it claimed a use that never happened. Every mention of the name must now be a `$NAME` / `${NAME}` expansion, matching the allowlist shape the Python detector already uses. Applied per name rather than per file: JavaScript and Python shadow one object holding every secret, whereas rebinding one shell variable says nothing about the rest. - The usage trail deliberately outlives execution logs, so a row routinely names a run whose log has been pruned. The read now left-joins workflow_execution_logs on its unique execution_id and reports availability, and the panel renders the chip disabled with the platform tooltip instead of linking into an empty Logs view. Three states: no run to link, a run whose log is gone, and a live link. - Docs said a direct environmentVariables/$KEY read does not activate masking, which this branch changes. Corrected in credentials.mdx, function.mdx and the logging FAQ, and the recognition limits are now written down: runtime-built names, reassigned bindings, and reads that cannot be told apart from text. Added a "See usage" section covering who can see it and why an empty trail means "nothing recognized" rather than "never used". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a7a5fff commit d293432

9 files changed

Lines changed: 149 additions & 16 deletions

File tree

apps/docs/content/docs/en/logs-debugging/logging.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ import { FAQ } from '@/components/ui/faq'
9494
<FAQ items={[
9595
{ 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." },
9696
{ 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." },
97-
{ 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." },
97+
{ 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." },
9898
{ 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." },
9999
{ 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." },
100100
{ 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." },

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,18 @@ When a saved secret is successfully substituted through a `{{KEY}}` reference, S
7171

7272
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.
7373

74+
Code that reads a secret straight off the runtime environment — `environmentVariables['KEY']` or `environmentVariables.KEY` 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.
75+
7476
<Callout type="warn">
75-
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.
77+
Direct reads are found by reading the code, not by running it, so recognition stops where the code stops being readable ahead of time. Sim reports a direct read only when it can attribute one with certainty, and skips it otherwise — an unrecognized read is not masked, and does not appear under **See usage**.
78+
79+
A read is **not** recognized when:
80+
81+
- **The name is built at runtime.** `environmentVariables[keyName]`, `$@`, `${!indirect}`, `eval`, `printenv`, or a sourced file hide which secret is being read.
82+
- **The binding is reassigned.** If JavaScript or Python code declares its own `environmentVariables` — a variable, parameter, destructured binding, loop target, or assignment — reads off it are no longer the mounted environment, so Sim stops reporting direct reads in that file. In shell the same applies per variable: after `KEY=something`, `$KEY` is the script's own value, so that name is skipped while others are unaffected.
83+
- **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.
84+
85+
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.
7686
</Callout>
7787

7888
### Copilot code execution
@@ -105,9 +115,22 @@ From here you can:
105115
- View the **Key** and edit the **Value**
106116
- 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
107117
- Manage **Members** — invite teammates by email and assign them an **Admin** or **Member** role
118+
- Open **See usage** — where this secret has actually been used
108119

109120
Click **Save** to apply changes, or **Back** to return to the list.
110121

122+
### See usage
123+
124+
**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.
125+
126+
This answers the question worth asking before rotating a key: who has been using it, inside what, and how recently.
127+
128+
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.
129+
130+
<Callout>
131+
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.
132+
</Callout>
133+
111134
## Workspace vs. Personal
112135

113136
| | Workspace | Personal |

apps/docs/content/docs/en/workflows/blocks/function.mdx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,13 @@ packages, and 10 managed CLI tools.
279279

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

287290
To narrow that, set **Secret access** to *Selected secrets* in the block's
288291
tool configuration and pick the names the code may read. Two things change:

apps/sim/app/api/secrets/usage/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ describe('GET /api/secrets/usage', () => {
4949
actorName: 'Ada',
5050
actorEmail: 'ada@example.com',
5151
lastExecutionId: 'execution-1',
52+
lastExecutionAvailable: true,
5253
lastTrigger: 'schedule',
5354
},
5455
],

apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel/secret-usage-panel.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useMemo } from 'react'
44
import { ChipLink } from '@sim/emcn'
55
import { formatDateTime } from '@sim/utils/formatting'
6+
import { SettingsActionChip } from '@/components/settings/settings-header'
67
import type { SecretUsageEntryPayload, SecretUsageScope } from '@/lib/api/contracts'
78
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components'
89
import { DELETED_WORKFLOW_LABEL, TriggerBadge } from '@/app/workspace/[workspaceId]/logs/utils'
@@ -13,6 +14,19 @@ import {
1314
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
1415
import { useSecretUsage } from '@/hooks/queries/credentials'
1516

17+
/**
18+
* The disabled twin of the View log chip, for a run whose log has been pruned. Routed through
19+
* the shared settings chip so it carries the platform's disabled tooltip treatment — including
20+
* the pointer-events handling a disabled button needs for the tooltip to fire at all.
21+
*/
22+
const EXPIRED_LOG_ACTION = {
23+
id: 'view-log',
24+
text: 'View log',
25+
disabled: true,
26+
tooltip: 'This run\u2019s log is past your workspace\u2019s retention window',
27+
onSelect: () => {},
28+
} as const
29+
1630
interface SecretUsagePanelProps {
1731
workspaceId: string
1832
secretName: string
@@ -56,7 +70,10 @@ export function SecretUsagePanel({ workspaceId, secretName, scope }: SecretUsage
5670
),
5771
actor: entry.actorName ?? 'Unknown',
5872
/**
59-
* A run only exists for an execution; MCP config resolution has none.
73+
* Three states, not two. A row with no execution id never had a run to link (Sim agent
74+
* and MCP resolutions have none). A row whose run has since been pruned — usage
75+
* outlives logs on purpose — keeps the chip but disables it, so the reader learns the
76+
* log expired instead of clicking into an empty Logs view.
6077
*
6178
* `border` is the outline-only variant: a bare chip renders as unadorned
6279
* `--text-body` text at `text-sm`, which next to the Actor cell's `--text-secondary`
@@ -66,13 +83,19 @@ export function SecretUsagePanel({ workspaceId, secretName, scope }: SecretUsage
6683
* growing it, so a row with a link is the same height as one without.
6784
*/
6885
trailing: entry.lastExecutionId ? (
69-
<ChipLink
70-
href={`/workspace/${workspaceId}/logs?executionId=${entry.lastExecutionId}`}
71-
variant='border'
72-
className='-my-1'
73-
>
74-
View log
75-
</ChipLink>
86+
entry.lastExecutionAvailable ? (
87+
<ChipLink
88+
href={`/workspace/${workspaceId}/logs?executionId=${entry.lastExecutionId}`}
89+
variant='border'
90+
className='-my-1'
91+
>
92+
View log
93+
</ChipLink>
94+
) : (
95+
<span className='-my-1 inline-flex'>
96+
<SettingsActionChip action={EXPIRED_LOG_ACTION} />
97+
</span>
98+
)
7699
) : undefined,
77100
})),
78101
[data?.entries, workspaceId]

apps/sim/lib/api/contracts/secrets.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export const secretUsageEntrySchema = z.object({
3232
actorName: z.string().nullable(),
3333
actorEmail: z.string().nullable(),
3434
lastExecutionId: z.string().nullable(),
35+
/** False once that run's log has aged out of the workspace's retention window. */
36+
lastExecutionAvailable: z.boolean(),
3537
lastTrigger: z.string().nullable(),
3638
})
3739

apps/sim/lib/execution/code-placeholders/compiler.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1421,6 +1421,40 @@ describe('a shadowed environment binding disables direct-read detection', () =>
14211421
})
14221422
})
14231423

1424+
describe('a rebound shell variable is not the mounted secret', () => {
1425+
/**
1426+
* Each shell secret is its own variable, so a script that writes the name is expanding its
1427+
* own value from that point on. Recording it would claim a use of the injected secret that
1428+
* never happened.
1429+
*/
1430+
it.each([
1431+
['plain assignment', 'API_KEY=local\necho "$API_KEY"'],
1432+
['export assignment', 'export API_KEY=local\necho "$API_KEY"'],
1433+
['local assignment', 'f() { local API_KEY=x; echo "$API_KEY"; }\nf'],
1434+
['readonly assignment', 'readonly API_KEY=x\necho "$API_KEY"'],
1435+
['append assignment', 'API_KEY+=suffix\necho "$API_KEY"'],
1436+
['read into the name', 'read API_KEY\necho "$API_KEY"'],
1437+
['for loop target', 'for API_KEY in a b; do echo "$API_KEY"; done'],
1438+
['unset', 'unset API_KEY\necho "$API_KEY"'],
1439+
])('%s', async (_label, code) => {
1440+
expect(await directReadNames(code, CodeLanguage.Shell)).toEqual([])
1441+
})
1442+
1443+
/** Rebinding one variable says nothing about the others, unlike the single object JS and Python share. */
1444+
it('drops only the rebound name', async () => {
1445+
const compiled = await compileCodePlaceholders({
1446+
code: 'API_KEY=local\necho "$API_KEY $OTHER_KEY"',
1447+
language: CodeLanguage.Shell,
1448+
environmentVariables: { API_KEY: 'a-value', OTHER_KEY: 'b-value' },
1449+
})
1450+
expect(compiled.resolvedSecretNames).toEqual(['OTHER_KEY'])
1451+
})
1452+
1453+
it('still reports a name the script only expands', async () => {
1454+
expect(await directReadNames('echo "$API_KEY"', CodeLanguage.Shell)).toEqual(['API_KEY'])
1455+
})
1456+
})
1457+
14241458
describe('shell true positives survive the fail-closed rule', () => {
14251459
it.each([
14261460
['bare unquoted', 'echo $API_KEY'],

apps/sim/lib/execution/code-placeholders/shell.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,31 @@ function collectShellOccurrenceContexts(
597597
/** `$NAME` and `${NAME}` — including `${NAME:-default}`, whose name still ends at `:`. */
598598
const SHELL_PARAMETER_EXPANSION = /\$(?:\{\s*([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*))/g
599599

600+
/**
601+
* Whether every mention of `name` in the script is a parameter expansion of it.
602+
*
603+
* Shell has no injected environment object to shadow — each secret is its own variable — so a
604+
* script that writes the name (`API_KEY=local`, `export`/`local`/`readonly`, `read API_KEY`,
605+
* `for API_KEY in …`, `unset`) is expanding its own value from that point on, not the mounted
606+
* secret. There is no shell parser here to resolve that with, so this takes the same allowlist
607+
* shape as the Python detector: a mention that is not preceded by `$` or `${` is something
608+
* this scanner cannot attribute, and the name is dropped.
609+
*
610+
* Per name rather than per file, unlike JavaScript and Python: those shadow one object holding
611+
* every secret, so losing it loses them all, whereas rebinding one shell variable says nothing
612+
* about the rest.
613+
*/
614+
function isOnlyExpanded(code: string, name: string): boolean {
615+
const mention = new RegExp(`(?<![A-Za-z0-9_])${name}(?![A-Za-z0-9_])`, 'g')
616+
let found: RegExpExecArray | null
617+
while ((found = mention.exec(code)) !== null) {
618+
const before = code[found.index - 1]
619+
const expanded = before === '$' || (before === '{' && code[found.index - 2] === '$')
620+
if (!expanded) return false
621+
}
622+
return true
623+
}
624+
600625
/**
601626
* Reports secrets a shell script expands straight out of the process environment.
602627
*
@@ -652,6 +677,9 @@ function recordShellDirectEnvironmentReads(
652677
}
653678
if (candidates.length === 0) return
654679

680+
/** Computed once per name — a script may expand the same secret many times. */
681+
const attributable = new Map<string, boolean>()
682+
655683
const contexts = collectShellOccurrenceContexts(code, candidates, 0, code.length, false)
656684
for (const candidate of candidates) {
657685
const shellContext = contexts.get(candidate)
@@ -662,6 +690,12 @@ function recordShellDirectEnvironmentReads(
662690
* expansion outright.
663691
*/
664692
if (!shellContext || shellContext.quote === 'single') continue
693+
let onlyExpanded = attributable.get(candidate.name)
694+
if (onlyExpanded === undefined) {
695+
onlyExpanded = isOnlyExpanded(code, candidate.name)
696+
attributable.set(candidate.name, onlyExpanded)
697+
}
698+
if (!onlyExpanded) continue
665699
context.recordDirectEnvironmentRead(candidate.name, candidate.start)
666700
}
667701
}

apps/sim/lib/secrets/usage/queries.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { secretUsage, user, workflow } from '@sim/db/schema'
2+
import { secretUsage, user, workflow, workflowExecutionLogs } from '@sim/db/schema'
33
import { and, desc, eq } from 'drizzle-orm'
44
import type { ResolvedSecretScope } from '@/executor/utils/resolved-secret-trace-registry'
55

@@ -16,6 +16,12 @@ export interface SecretUsageEntry {
1616
actorName: string | null
1717
actorEmail: string | null
1818
lastExecutionId: string | null
19+
/**
20+
* Whether that run's log still exists. Usage outlives logs by design — the trail is not
21+
* bound by `logRetentionHours` — so a row routinely names a run whose log has since been
22+
* pruned, and the UI has to say so rather than link into an empty view.
23+
*/
24+
lastExecutionAvailable: boolean
1925
lastTrigger: string | null
2026
}
2127

@@ -55,11 +61,17 @@ export async function getSecretUsage(query: SecretUsageQuery): Promise<SecretUsa
5561
actorName: user.name,
5662
actorEmail: user.email,
5763
lastExecutionId: secretUsage.lastExecutionId,
64+
/** At most one row: `execution_id` carries a unique index, so this cannot fan out. */
65+
lastExecutionLogId: workflowExecutionLogs.id,
5866
lastTrigger: secretUsage.lastTrigger,
5967
})
6068
.from(secretUsage)
6169
.leftJoin(workflow, eq(workflow.id, secretUsage.workflowId))
6270
.leftJoin(user, eq(user.id, secretUsage.actorUserId))
71+
.leftJoin(
72+
workflowExecutionLogs,
73+
eq(workflowExecutionLogs.executionId, secretUsage.lastExecutionId)
74+
)
6375
.where(
6476
and(
6577
eq(secretUsage.workspaceId, query.workspaceId),
@@ -73,10 +85,11 @@ export async function getSecretUsage(query: SecretUsageQuery): Promise<SecretUsa
7385

7486
return {
7587
/** The storage sentinel is an implementation detail of the unique key, not a value. */
76-
entries: rows.map((row) => ({
88+
entries: rows.map(({ lastExecutionLogId, ...row }) => ({
7789
...row,
7890
workflowId: row.workflowId || null,
7991
actorUserId: row.actorUserId || null,
92+
lastExecutionAvailable: lastExecutionLogId !== null,
8093
})),
8194
}
8295
}

0 commit comments

Comments
 (0)