Skip to main content
← Back to list
01Issue
BugShippedSwamp CLIPublicTeam
Assigneeszack

Relationships

#3585 Persisted resource redaction masks the vault key inside a sensitive field ref (data name containing a vault-resolved value), so readResource fails with Secret not found

Opened by randybias · 10/17/2026· Shipped 10/29/2026

Summary

When a model writes a resource that has a sensitive field (.meta({ sensitive: true })), swamp vaults the field under a key built from the data name, e.g. <type>-<modelId>-<method>-<spec>-<dataName>-<field>. If any part of that data name equals a value that was resolved from a vault during the run (here a host address supplied through ${{ vault.get(...) }} in globalArguments), the run's secret redaction rewrites that part to *** in the persisted record, including inside the ${{ vault.get(...) }} reference string itself. The vault entry is still written under the real key, so the stored reference points at a key that does not exist. Any later readResource of that record fails:

Error: Secret 'test-maskprobe-13f1c472-...-put-r-root~***~22-token' not found in local vault 'default'

The record is unreadable even when the caller passes the correct, unmasked data name. Plain identity fields in the same record (host) are also stored as ***, so a model cannot rebuild its own data name or key from what it stored either.

Seen live in an SSH-driven model whose data name is user~host~port and whose host came from a vault-backed workflow input. A reset method failed with Secret '...install-state-root~***~22-config_file_sha256' not found in local vault 'default'. The workaround was to stop marking the field sensitive.

Minimal reproduction (swamp 20261013.025835.0-sha.1b6f0c59, local_encryption vault)

extensions/models/maskprobe.ts:

import { z } from "npm:zod@4.3.6";
const R = z.strictObject({
  host: z.string(), port: z.number(), user: z.string(),
  token: z.string().meta({ sensitive: true }),
});
const nameOf = (u: string, h: string, p: number) => `${u}~${h}~${p}`;
export const model = {
  type: "@test/maskprobe",
  version: "2026.10.17.1",
  description: "repro",
  globalArguments: z.strictObject({ host: z.string().optional() }),
  resources: { r: { description: "r", schema: R, lifetime: "infinite", garbageCollection: 5 } },
  methods: {
    put: {
      kind: "action", description: "write",
      arguments: z.strictObject({ host: z.string().optional() }),
      execute: async (a: any, ctx: any) => {
        const host = a.host ?? ctx.globalArgs.host;
        const h = await ctx.writeResource("r", nameOf("root", host, 22),
          { host, port: 22, user: "root", token: "dummy-not-a-secret" });
        return { dataHandles: [h] };
      },
    },
    reread: {
      kind: "action", description: "read back",
      arguments: z.strictObject({ host: z.string().optional() }),
      execute: async (a: any, ctx: any) => {
        const rec = await ctx.readResource(nameOf("root", a.host ?? ctx.globalArgs.host, 22));
        ctx.logger.info("host={h}", { h: rec?.host });
        return { dataHandles: [] };
      },
    },
  },
};

Control: host passed as a plain input. Works.

swamp model create @test/maskprobe m1
swamp behavior run @test/maskprobe put m1 --input host=192.0.2.10
swamp behavior run @test/maskprobe reread m1 --input host=192.0.2.10
#   host=192.0.2.10, token resolves   (succeeds)

Failing case: the same host comes from the vault.

swamp vault put default addr=192.0.2.10
swamp model create @test/maskprobe m2
# models/@test/maskprobe/m2.yaml:
#   globalArguments:
#     host: ${{ vault.get('default', 'addr') }}
swamp behavior run @test/maskprobe put m2
#   Data produced: root~192.0.2.10~22  host ***  token ${{ vault.get('default', '...') }}
swamp behavior run @test/maskprobe reread m2
#   Error: Secret 'test-maskprobe-13f1c472-a2a5-4243-a812-013c00d03e50-put-r-root~***~22-token' not found in local vault 'default'

What was stored, via swamp data query 'modelName == "m2" && specName == "r"' --select content --json:

{
  "host": "***",
  "port": 22,
  "user": "root",
  "token": "${{ vault.get('default', 'test-maskprobe-13f1c472-a2a5-4243-a812-013c00d03e50-put-r-root~***~22-token') }}"
}

The vault key that was actually written, via swamp vault list-keys default:

test-maskprobe-13f1c472-a2a5-4243-a812-013c00d03e50-put-r-root~192.0.2.10~22-token

The data name is stored unmasked (root~192.0.2.10~22). The vault key embedded in the reference is masked, and so the reference does not match the key.

Expected

Any one of these would fix it:

  1. Never run value redaction over swamp's own vault reference strings (the key inside ${{ vault.get(...) }}) when it persists a record. Those strings are not secret values. Better still, derive the vault key from something that cannot contain user values, such as the data id/version or a hash of the data name.
  2. Do not redact persisted resource attributes at rest. Mask them on display. A model that needs a field hidden at rest should mark it sensitive. As things stand, a non-sensitive field is silently lossy (host: "***"), and the stored record no longer matches what the method wrote.
  3. At minimum, give the model a stable, unmasked way to address its own vaulted fields, and give readResource a clear error naming the masked key, not a raw vault lookup failure.
  • #1169: cross-run readResource of vaulted fields and key collisions (shipped).
  • #2759: docs on sensitive values read through expressions; data-name suffixes there get a sensitive-<vault>.<key> placeholder. This case is different: the name is built inside the method from a resolved global argument.
02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 5 MOREREVIEW+ 17 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

10/29/2026, 7:57:46 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
zack assigned zack10/19/2026, 1:56:42 PM
Editable. Press Enter to edit.

zack commented 10/29/2026, 8:02:04 PM

Thanks @randybias for reporting this! We shipped: Option (a) from the issue. Persisted resource redaction stops touching the vault reference strings swamp writes for sensitive fields. createResourceWriter replaces its text-level redactor.redact(JSON.stringify(data)) with a structural redaction over a JSON-normalized copy that masks every other string value and object key but leaves the vaulted paths returned by processSensitiveResourceData unchanged. The stored reference then always names the key the vault actually holds, so readResource works when the data name contains a vault-resolved value. The vault key format is unchanged, so existing records, vaultKey overrides, grant key conditions and docs stay valid, and non-sensitive fields remain masked at rest as #1314 intended.. The fix has been merged and a release is on its way. We appreciate your contribution to swamp.

Sign in to post a ripple.