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

Relationships

#684 sensitive-arg guard rejects an all-vault.get() record/map as a "literal", blocking definition migration

Opened by anthony · 6/18/2026· Shipped 6/19/2026

Summary

A sensitive global argument declared as a record/mapz.record(z.string(), z.string()).meta({ sensitive: true }) — whose every value is a ${{ vault.get(...) }} expression is rejected by the sensitive-literal guard as if it were a cleartext literal. The same single-expression value is accepted on a z.string().meta({ sensitive: true }) field, so the rejection is purely about the value being map-shaped.

Because the guard runs at the definition save chokepoint, this blocks any definition write — and in particular it blocks swamp model method run whenever the run triggers a type-version migration re-save (i.e. the instance's typeVersion is behind the live type version and the model defines an upgrades chain). The migration re-reads the on-disk definition (raw vault.get() map intact), applies the upgrade, and saves it — and that save throws. The aborted method need not consume the secret at all.

Root cause

src/domain/models/sensitive_field_extractor.ts:

  • extractSensitiveFields() flags the record field as sensitive (path = the field name) but does not recurse into a record shape (it only walks object shapes), so the per-value expressions are never inspected.
  • findLiteralSensitiveGlobalArgs() then calls isLiteralSecret() on the whole map value.
  • isLiteralSecret() returns true for any non-string value (a documented, deliberate fail-closed choice), so an all-vault.get() map is classified as a cleartext literal even though it carries no cleartext.

Save chokepoint: src/infrastructure/persistence/yaml_definition_repository.ts (findLiteralSensitiveGlobalArgs → throw). Migration re-save on a method run: src/domain/models/method_execution_service.ts (the DefinitionUpgradeService block that persists the upgraded definition).

Reproduction

  1. A model type whose globalArguments include secrets: z.record(z.string(), z.string()).meta({ sensitive: true }), and which defines an upgrades chain.
  2. A persisted instance carrying secrets: { MY_KEY: "${{ vault.get('my-vault', 'my-key') }}" }, pinned to a typeVersion behind the type's current version.
  3. swamp model method run <instance> <any-method> → the migration re-save hits the guard and aborts with: "Global argument 'secrets' is marked sensitive and cannot be set to a literal value — it would be stored in cleartext in the definition YAML…"

With the instance's typeVersion matched to the live version (no migration, no re-save) the same method runs fine with the same secrets block in place — confirming the map value itself is acceptable and only the save-time guard rejects it.

Expected

A sensitive container (record/array) whose leaves are all expression-only (e.g. vault.get(...)) should be accepted, exactly as a single ${{ vault.get(...) }} string is — it carries no cleartext.

Proposed fix (behaviour-preserving)

Make isLiteralSecret() recurse into containers; keep bare non-string scalars fail-closed (so the existing number/boolean literal tests stay green):

function isLiteralSecret(value: unknown): boolean {
  if (value === undefined || value === null) return false;
  if (typeof value === "string") {
    if (value.trim() === "") return false;
    return !isExpressionOnly(value);
  }
  // An array/plain-object is a literal secret iff ANY contained value is one.
  // An all-`vault.get(...)` map carries no cleartext; an empty container carries
  // no secret. Bare non-string scalars (number/boolean) stay fail-closed.
  if (Array.isArray(value)) return value.some(isLiteralSecret);
  if (typeof value === "object" &&
      (Object.getPrototypeOf(value) === Object.prototype ||
       Object.getPrototypeOf(value) === null)) {
    return Object.values(value as Record<string, unknown>).some(isLiteralSecret);
  }
  return true;
}

Suggested tests: an all-vault.get() record (and array) returns [] from findLiteralSensitiveGlobalArgs; a record containing one literal string — or a number — is still flagged.

extractSensitiveFieldValues() skips object values, so the resolved secret values inside a sensitive map are never registered with the log redactor. Independent of this guard bug, but worth handling in the same area (recurse into record/array values there too).

Environment

swamp 20260616.195738.0-sha.2c8bba58; guard confirmed unchanged on main (source fetched 2026-06-18). Reproduced on Linux.

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

Shipped

6/19/2026, 12:13:10 AM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack726/18/2026, 10:55:29 PM
Editable. Press Enter to edit.

stack72 commented 6/19/2026, 12:13:20 AM

Thanks @anthony 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.