fix: scope backup_diff to server sections and mask secrets
CI / Lint (ruff) (pull_request) Successful in 16s
CI / Tests (py3.10) (pull_request) Successful in 17s
CI / Tests (py3.12) (pull_request) Successful in 14s

The diff preview in RestoreDialog was serializing the full config dict,
exposing secret env values and token args in cleartext on a pasteable surface.

- Add _redact_server_data / _redact_servers_block helpers that apply
  redact_args to args and mask env values for is_secret_key() keys
- Rewrite backup_diff to compare only {mcpServers, _disabledMcpServers}
  sections (sanitized), not the whole file — also avoids double-serializing
  multi-MB ~/.claude.json for a servers-only diff
- Add clarifying comment in _restore_from_backup about why full_config
  is the right base after confirm-discard
- Add test: backup_diff with secret args/env → MASK in output, raw values absent
- Add test: restore_backup restores _disabledMcpServers correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AJ
2026-07-02 01:40:24 -04:00
parent df332a06ba
commit 6ab4789a70
3 changed files with 104 additions and 10 deletions
+35 -10
View File
@@ -273,18 +273,37 @@ def backup_label(backup_path: Path | str) -> str:
return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:]}"
def _redact_server_data(data: dict) -> dict:
"""Return a copy of a server definition with secrets masked for display."""
out = dict(data)
if "args" in out:
out["args"] = redact_args(list(out["args"] or []))
if "env" in out:
out["env"] = {k: (MASK if is_secret_key(k) else v) for k, v in (out["env"] or {}).items()}
return out
def _redact_servers_block(block: dict | None) -> dict:
"""Return a sanitized copy of a mcpServers / _disabledMcpServers block."""
if not block:
return {}
return {name: _redact_server_data(data) for name, data in block.items()}
def backup_diff(
config_path: Path | str,
backup_path: Path | str,
current_cfg: dict | None = None,
) -> str:
"""
Unified diff showing what the current config would look like after restoring
the backup (servers only — non-server keys are preserved).
Unified diff showing what the server sections would look like after restoring
the backup. Only mcpServers and _disabledMcpServers are compared; non-server
keys are excluded entirely (they are preserved by restore, not changed).
Secret values in args and env are masked so the diff is safe to share.
fromfile = current state, tofile = what will be written after restore.
Pass current_cfg to use an already-loaded (possibly repaired) in-memory config
as the baseline instead of re-reading from disk.
Pass current_cfg to diff against an already-loaded in-memory config instead
of re-reading from disk.
"""
p = Path(config_path)
bp = Path(backup_path)
@@ -299,12 +318,18 @@ def backup_diff(
after_cfg = dict(current_cfg)
apply_servers(after_cfg, backup_servers)
before_lines = (json.dumps(current_cfg, indent=2, ensure_ascii=False) + "\n").splitlines(
keepends=True
)
after_lines = (json.dumps(after_cfg, indent=2, ensure_ascii=False) + "\n").splitlines(
keepends=True
)
def _server_sections(cfg: dict) -> dict:
out: dict = {"mcpServers": _redact_servers_block(cfg.get("mcpServers"))}
if DISABLED_KEY in cfg:
out[DISABLED_KEY] = _redact_servers_block(cfg.get(DISABLED_KEY))
return out
before_lines = (
json.dumps(_server_sections(current_cfg), indent=2, ensure_ascii=False) + "\n"
).splitlines(keepends=True)
after_lines = (
json.dumps(_server_sections(after_cfg), indent=2, ensure_ascii=False) + "\n"
).splitlines(keepends=True)
result = "".join(
difflib.unified_diff(