Skip to main content
← Back to list
01Issue
BugShippedSwamp CLI
Assigneesstack72

Relationships

#1362 Pre-flight checks receive unresolved vault.get() expression text, not the resolved secret

Opened by shelson · 7/23/2026· Shipped 7/23/2026

Summary

For a custom extension model, a globalArguments field set to a vault CEL expression (e.g. password: ${{ vault.get('my-vault', 'MY_SECRET') }}) resolves correctly when the model's method execute() reads [HOST-1] — but a pre-flight check on the same model receives the literal, unresolved ${{ vault.get(...) }} string instead of the resolved secret.

This makes it impossible to write a "live" pre-flight check that needs a vault-stored credential (e.g. verifying connectivity to a service using the real password) — the check always operates on the unresolved expression text, never the actual secret, so it can only ever fail (or silently misbehave).

Steps to Reproduce

  1. Create a vault and store a secret: swamp vault create local_encryption my-vault --json swamp vault put my-vault MY_SECRET
  2. Create a custom extension model (extensions/models/repro.ts) with:
    • globalArguments: z.object({ password: z.string() })
    • a checks entry whose execute inspects [HOST-1]
    • a mutating method whose execute also reads [HOST-1] (e.g. to authenticate against something)
  3. Create a model instance and set in the instance YAML: password: ${{ vault.get('my-vault', 'MY_SECRET') }} (Reproduced identically whether hand-edited into the YAML or passed via swamp model create ... --global-arg "password=[REDACTED-SECRET-1] vault.get('my-vault', 'MY_SECRET') }}" — both produce the same broken behavior.)
  4. Run swamp model validate <name> --json (or swamp model method run <name> <method> --json without --skip-checks). Inside the check's execute, [HOST-1] is literally the string ${{ vault.get('my-vault', 'MY_SECRET') }} — same length and content as the unresolved template, not the real secret.
  5. Run swamp model method run <name> <method> --skip-checks --json. The method's own [HOST-1] is the correctly resolved real secret value — confirmed by successfully authenticating against a real external service using it.

Expected

Either:

  • Checks receive the same vault-resolved globalArgs that method execute() receives, or
  • The checks documentation explicitly states that checks run before global argument expression resolution and cannot see vault-resolved values, so authors know not to write "live" checks that depend on secret values.

Actual

context.globalArgs inside a CheckDefinition.execute() contains the raw, unevaluated ${{ vault.get(...) }} expression text. The same field resolves correctly inside a method's execute().

Environment

  • swamp version: 20260722.210030.0-sha.7ff1e2f0 (update to 20260722.233513.0-sha.6e04abec is available; did not have swamp's own core source locally to compare against a specific diagnosed file, so this may already be fixed — filing since I can't confirm either way)
  • Vault type: local_encryption

Impact

Any custom extension model that wants a "live" pre-flight check verifying connectivity/auth against a vault-stored credential — a pattern the checks documentation itself encourages via the "live" label convention — can't do so today. Workaround: drop the live check and rely on the method's own execute() throwing before writing any data if the real credential turns out to be invalid.

02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 2 MOREREVIEW+ 4 MOREPR_MERGED+ 1 MORECONTRIBUTOR_NOTIFIED

Shipped

7/23/2026, 5:30:14 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack727/23/2026, 3:49:05 PM
Editable. Press Enter to edit.

shelson commented 7/23/2026, 3:45:25 AM

Correction: the auto-redactor mistook two code identifiers/tokens for a hostname and a secret and mangled the repro steps. For clarity, here are the exact strings it swapped out:

  • Every [HOST-1] in the body above is literally the identifier [HOST-1] (a TypeScript property access on the method/check context — not a hostname).
  • In step 3, "password=[REDACTED-SECRET-1] vault.get(...)" should read "password=[REDACTED-SECRET-2] vault.get('my-vault', 'MY_SECRET') }}" — the redactor ate the \${{ token, not an actual secret value.

Root cause and repro are otherwise unaffected — happy to paste the raw markdown via --no-redact if that's more useful for triage.

shelson commented 7/23/2026, 3:45:42 AM

Sorry for the noise — that first clarification got auto-redacted too. Using --no-redact now since the content is already sanitized (placeholders only, no real secrets).

The two [HOST-1] placeholders in the original body are literally the code identifier context.globalArgs.password (a property access, not a hostname) — the redactor's hostname heuristic apparently matches dotted.identifier.chains.

In step 3, the string "password=[REDACTED-SECRET-1] vault.get('my-vault', 'MY_SECRET') }}" should read "password=${{ vault.get('my-vault', 'MY_SECRET') }}" (with a backslash before the ${{ in the original shell command, to stop the shell from expanding it) — the redactor treated the ${{ token itself as a secret pattern.

Root cause/repro is unaffected either way, just flagging in case the raw text is useful for triage.

stack72 commented 7/23/2026, 5:30:22 PM

Thanks @shelson for reporting this! The fix has been merged and a release is on its way. We appreciate your contribution to swamp.

Sign in to post a ripple.