Summary
update_service will silently write the literal string "[REDACTED]" as a real environment-variable value if the caller round-trips a spec obtained from get_service/list_services without modification — corrupting every secret-looking var on that service. This is a natural, easy-to-fall-into workflow: fetch the current spec, tweak one field (e.g. add a healthcheck), write it back.
Root cause
sanitizeService/sanitizeEnvVars (src/sanitize.ts) redact matching env vars to "[REDACTED]" on every read (get_service, list_services).
restoreRedactedValues() exists to undo this — but only operates on compose YAML strings, used exclusively by the stack-update path.
update_service's handler (src/tools/services.ts) passes the caller-supplied spec.variables (a structured {name, value}[] array, not YAML) straight to client.updateService(id, spec) with no restoration step at all.
update_service_env is safe by a different mechanism — it fetches the live service server-side and only mutates the specific named vars from set/remove, so a caller never needs to round-trip a redacted value through it.
Net effect: update_service is the one write path with zero protection against this, despite being the most natural pairing with get_service for "fetch, modify one field, write back" workflows (e.g. adding a healthcheck, changing deploy.replicas, etc.).
Reproduction
get_service({id}) → returns a spec with variables: [{name: "RPC_URL", value: "[REDACTED]"}, ...].
- Add/change an unrelated field (e.g.
healthcheck), leave variables as returned.
update_service({id, spec}).
- Every redacted var is now literally the string
"[REDACTED]" in the running container's environment. In our case this took down a production service (Error: Endpoint should start with 'ws://', received '[REDACTED]').
Suggested fix
Give update_service the same protection as the compose path: before forwarding to client.updateService, diff the incoming spec.variables against a fresh client.getService(id) fetch and restore any var whose value is exactly "[REDACTED]" from the live value (mirroring restoreRedactedValues, but for the structured {name, value}[] shape instead of YAML). Doing this unconditionally (not just when redact !== "none") is safest, since a caller may have gotten the spec from an earlier get_service call in a different redact mode.
Recovery in our case was via rollback_service, which worked — but only because Swarm had a previous good revision to roll back to.
Summary
update_servicewill silently write the literal string"[REDACTED]"as a real environment-variable value if the caller round-trips a spec obtained fromget_service/list_serviceswithout modification — corrupting every secret-looking var on that service. This is a natural, easy-to-fall-into workflow: fetch the current spec, tweak one field (e.g. add ahealthcheck), write it back.Root cause
sanitizeService/sanitizeEnvVars(src/sanitize.ts) redact matching env vars to"[REDACTED]"on every read (get_service,list_services).restoreRedactedValues()exists to undo this — but only operates on compose YAML strings, used exclusively by the stack-update path.update_service's handler (src/tools/services.ts) passes the caller-suppliedspec.variables(a structured{name, value}[]array, not YAML) straight toclient.updateService(id, spec)with no restoration step at all.update_service_envis safe by a different mechanism — it fetches the live service server-side and only mutates the specific named vars fromset/remove, so a caller never needs to round-trip a redacted value through it.Net effect:
update_serviceis the one write path with zero protection against this, despite being the most natural pairing withget_servicefor "fetch, modify one field, write back" workflows (e.g. adding a healthcheck, changingdeploy.replicas, etc.).Reproduction
get_service({id})→ returns a spec withvariables: [{name: "RPC_URL", value: "[REDACTED]"}, ...].healthcheck), leavevariablesas returned.update_service({id, spec})."[REDACTED]"in the running container's environment. In our case this took down a production service (Error: Endpoint should start with 'ws://', received '[REDACTED]').Suggested fix
Give
update_servicethe same protection as the compose path: before forwarding toclient.updateService, diff the incomingspec.variablesagainst a freshclient.getService(id)fetch and restore any var whose value is exactly"[REDACTED]"from the live value (mirroringrestoreRedactedValues, but for the structured{name, value}[]shape instead of YAML). Doing this unconditionally (not just whenredact !== "none") is safest, since a caller may have gotten the spec from an earlierget_servicecall in a different redact mode.Recovery in our case was via
rollback_service, which worked — but only because Swarm had a previous good revision to roll back to.