The accepted residual from the #33 security reviews (four rounds, shipped as #439): when dashboard.control is on, the dashboard container bind-mounts the live config.json read-only to prefill the editor form. The API masks every secret leaf before serving the browser, but the masking protects the browser, not the container — a compromised container reads the raw file: node RPC password, stratum password, Telegram bot token, XvB donor id, Healthchecks ping URL. SECURITY.md documents this mount as the real secret trust boundary today.
Close the gap by moving the merge host-side: the control runner (which already re-validates every intent) writes a pre-masked copy of the config into a host-owned leg of the spool (alongside results/, read-only in the container), and the container serves the form from that copy instead of the raw mount. The "blank secret keeps the live value" merge then happens host-side at staging time — the container never holds a secret it didn't already have.
Sketch:
control-run-pending (or apply) renders data/control/masked/config.json — same masking rules control_service.py applies today, moved behind the trust boundary.
- The dashboard's
GET /api/config reads the masked copy; drop the config.json:ro bind mount from the compose service.
- Staleness: re-render the masked copy on every apply/commit; the view already tolerates preview-time drift (staged copy has a 10-minute expiry).
- The sentinel-swap on commit already happens host-side at staging — verify nothing else reads the raw mount (grep the dashboard for CONFIG paths) before removing it.
Payoff: a fully compromised dashboard container can ask to change an allowlisted key and read masked config + results — nothing else. The remaining secret exposure through this feature drops to zero.
Refs #33 / PR #439 (the residual is called out in the final security-review comment there and in SECURITY.md → "Secret trust boundary for dashboard config editing").
The accepted residual from the #33 security reviews (four rounds, shipped as #439): when
dashboard.controlis on, the dashboard container bind-mounts the liveconfig.jsonread-only to prefill the editor form. The API masks every secret leaf before serving the browser, but the masking protects the browser, not the container — a compromised container reads the raw file: node RPC password, stratum password, Telegram bot token, XvB donor id, Healthchecks ping URL.SECURITY.mddocuments this mount as the real secret trust boundary today.Close the gap by moving the merge host-side: the control runner (which already re-validates every intent) writes a pre-masked copy of the config into a host-owned leg of the spool (alongside
results/, read-only in the container), and the container serves the form from that copy instead of the raw mount. The "blank secret keeps the live value" merge then happens host-side at staging time — the container never holds a secret it didn't already have.Sketch:
control-run-pending(or apply) rendersdata/control/masked/config.json— same masking rulescontrol_service.pyapplies today, moved behind the trust boundary.GET /api/configreads the masked copy; drop theconfig.json:robind mount from the compose service.Payoff: a fully compromised dashboard container can ask to change an allowlisted key and read masked config + results — nothing else. The remaining secret exposure through this feature drops to zero.
Refs #33 / PR #439 (the residual is called out in the final security-review comment there and in
SECURITY.md→ "Secret trust boundary for dashboard config editing").