fix: scope backup_diff to server sections and mask secrets
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:
+35
-10
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user