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
- Create a vault and store a secret:
swamp vault create local_encryption my-vault --jsonswamp vault put my-vault MY_SECRET - Create a custom extension model (
extensions/models/repro.ts) with:globalArguments: z.object({ password: z.string() })- a
checksentry whoseexecuteinspects[HOST-1] - a mutating method whose
executealso reads[HOST-1](e.g. to authenticate against something)
- 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 viaswamp model create ... --global-arg "password=[REDACTED-SECRET-1] vault.get('my-vault', 'MY_SECRET') }}"— both produce the same broken behavior.) - Run
swamp model validate <name> --json(orswamp model method run <name> <method> --jsonwithout--skip-checks). Inside the check'sexecute,[HOST-1]is literally the string${{ vault.get('my-vault', 'MY_SECRET') }}— same length and content as the unresolved template, not the real secret. - 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
globalArgsthat methodexecute()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.
Shipped
Click a lifecycle step above to view its details.
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.
Sign in to post a ripple.