From 1b124899138eaae4d5ac05abf8f2959cd489f1a0 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 4 Aug 2026 03:35:21 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20"Move=20to=20environment=20variable"=20?= =?UTF-8?q?=E2=80=94=20convert=20a=20plaintext=20secret=20to=20${VAR}=20(#?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #76/#82: BCC warns when a config holds a raw credential and points at ${VAR}, but gave no way to make the change. This adds the one-click conversion, right-click a secret row in the env or headers table. The value is about to leave the file, so the action's real job is handing the secret back before it does: - Core (pure, tested): sanitize_env_var_name (key -> legal upper-case shell name; 'api-key' -> API_KEY, '2fa' -> _2FA, non-ASCII/empty handled), shell_export_lines (the exact export/setx line, POSIX single-quoted safely), move_value_to_env_ref (data in -> new data out, replaces one env/header/args value with ${VAR}, returns the removed secret; never mutates the input; None if the target is missing, non-string, or already a reference), can_move_value_to_env_ref (offer only a real stored secret, not already a ref, AND only on a client that expands references -- offering it on Claude Desktop would author a config that reaches the server as literal ${VAR}, the exact failure #76 exists to prevent), and is_env_var_set (skip the ceremony when the variable already looks set). - GUI: KeyValueTable gains a context menu gated on can_move_value_to_env_ref (so it never appears on a non-secret row or a Claude Desktop profile). MoveToEnvDialog lets the user name the variable (defaulting to the sanitised key), shows the platform-appropriate shell line live, notes when the variable already looks set, and on accept copies the secret to the clipboard before the cell is replaced with the reference. Wired through ServerEditor.set_profile_provider so the tables know which client is loaded. Scope note: env and headers rows for now. The core already handles args by index; wiring the args editor (a free-text widget, not a table) is a small follow-up, deliberately not bundled here. Tests: +15 core (name sanitisation incl. non-ASCII/leading-digit/empty, POSIX quote safety, the gate across secret/non-secret/already-ref/ non-expanding-client, env+headers+args rewrite, input-not-mutated, missing/non-string/already-ref -> None, is_env_var_set). 478 passed, ruff clean. GUI is untestable in CI (no PySide6); the decision logic all lives in bcc_core and is tested there. Closes #83 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE --- bcc.py | 137 +++++++++++++++++++++++++++++++++++++++++++++ bcc_core.py | 123 ++++++++++++++++++++++++++++++++++++++++ tests/test_core.py | 89 +++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+) diff --git a/bcc.py b/bcc.py index dc224f7..fc53f11 100644 --- a/bcc.py +++ b/bcc.py @@ -272,6 +272,90 @@ class _SecretMaskDelegate(QStyledItemDelegate): option.text = core.MASK +class MoveToEnvDialog(QDialog): + """Confirm moving a plaintext secret out to a ${VAR} reference (#83). + + The secret is about to leave the config file, so this dialog's whole job is + to hand it back first: it lets the user name the variable, shows the exact + shell line to set it, and (on accept) the caller copies the secret to the + clipboard. If the variable already looks set in this environment, it says so + and drops the urgency. + """ + + def __init__(self, parent, key: str, secret: str): + super().__init__(parent) + self.setWindowTitle("Move to environment variable") + self.setMinimumWidth(460) + self._secret = secret + v = QVBoxLayout(self) + v.setSpacing(10) + + intro = QLabel( + "The value will be removed from the config and replaced with a " + "reference. Set the variable in your environment first, or the " + "server won't authenticate." + ) + intro.setWordWrap(True) + v.addWidget(intro) + + grid = QGridLayout() + grid.setSpacing(8) + lbl = QLabel("Variable:") + lbl.setObjectName("muted") + grid.addWidget(lbl, 0, 0) + self._name_edit = QLineEdit(core.sanitize_env_var_name(key)) + self._name_edit.textChanged.connect(self._refresh) + grid.addWidget(self._name_edit, 0, 1) + v.addLayout(grid) + + self._already = QLabel("") + self._already.setWordWrap(True) + self._already.setStyleSheet(f"color: {GOOD};") + v.addWidget(self._already) + + set_lbl = QLabel("Set it with:") + set_lbl.setObjectName("muted") + v.addWidget(set_lbl) + self._cmd = QLabel("") + self._cmd.setWordWrap(True) + self._cmd.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self._cmd.setStyleSheet("font-family: monospace;") + v.addWidget(self._cmd) + + note = QLabel("The secret will be copied to your clipboard when you continue.") + note.setObjectName("muted") + note.setWordWrap(True) + v.addWidget(note) + + btns = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + ok = btns.button(QDialogButtonBox.StandardButton.Ok) + ok.setText("Move && copy secret") + ok.setObjectName("primary") + btns.accepted.connect(self.accept) + btns.rejected.connect(self.reject) + v.addWidget(btns) + + self._refresh() + + def var_name(self) -> str: + return core.sanitize_env_var_name(self._name_edit.text()) + + def _refresh(self, *_): + name = self.var_name() + lines = core.shell_export_lines(name, self._secret) + if sys.platform == "win32": + self._cmd.setText(f"{lines['windows']}\n\n(macOS/Linux: {lines['posix']})") + else: + self._cmd.setText(f"{lines['posix']}\n\n(Windows: {lines['windows']})") + if core.is_env_var_set(name): + self._already.setText(f"{name} already looks set in this environment.") + self._already.show() + else: + self._already.hide() + + class KeyValueTable(QWidget): def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None): super().__init__() @@ -292,6 +376,11 @@ class KeyValueTable(QWidget): self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setMinimumHeight(90) self.table.itemChanged.connect(self._changed) + # Right-click a secret row to move it out to a ${VAR} reference (#83). + # Set by the owner (ServerEditor) so the action can gate on the client. + self.profile_provider = None + self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.table.customContextMenuRequested.connect(self._context_menu) # Secret-looking values (API_KEY, TOKEN, ...) render masked by default. self._mask_delegate = _SecretMaskDelegate(self.table) self.table.setItemDelegateForColumn(1, self._mask_delegate) @@ -315,6 +404,47 @@ class KeyValueTable(QWidget): self.reveal_btn.setText("Hide secrets" if on else "Show secrets") self.table.viewport().update() + def _row_key_value(self, row: int): + key_item = self.table.item(row, 0) + val_item = self.table.item(row, 1) + key = key_item.text().strip() if key_item else "" + # The mask is display-only (a delegate); the model text is the real value. + value = val_item.text() if val_item else "" + return key, value + + def _context_menu(self, pos): + item = self.table.itemAt(pos) + if item is None: + return + row = item.row() + key, value = self._row_key_value(row) + profile = self.profile_provider() if self.profile_provider else None + if not core.can_move_value_to_env_ref(key, value, profile): + return + menu = QMenu(self) + act = QAction("Move to environment variable…", self) + act.triggered.connect(lambda: self._move_row_to_env(row)) + menu.addAction(act) + menu.exec(self.table.viewport().mapToGlobal(pos)) + + def _move_row_to_env(self, row: int): + key, secret = self._row_key_value(row) + if not secret: + return + dlg = MoveToEnvDialog(self.window(), key, secret) + if not dlg.exec(): + return + var_name = dlg.var_name() + # Hand the secret back before it leaves the file: clipboard now holds it, + # and the dialog showed the exact shell line to set it. + QGuiApplication.clipboard().setText(secret) + if self._before_change: + self._before_change() + val_item = self.table.item(row, 1) + if val_item is not None: + # setText fires itemChanged -> _changed -> on_change (dirty + revalidate). + val_item.setText(f"${{{var_name}}}") + def _changed(self, item=None, *_): if item is not None and item.column() == 0: new_key = item.text().strip() @@ -631,6 +761,12 @@ class ServerEditor(QFrame): v.addWidget(self.headers, 1) return w + def set_profile_provider(self, provider): + """Let the env/headers tables gate "move to environment variable" on + which client the loaded profile targets (#83).""" + self.env.profile_provider = provider + self.headers.profile_provider = provider + # --- model <-> form -------------------------------------------------- # def load_entry(self, entry: core.ServerEntry | None): self._loading = True @@ -1649,6 +1785,7 @@ class MainWindow(QMainWindow): split.setHandleWidth(10) split.addWidget(self._build_left()) self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo) + self.editor.set_profile_provider(lambda: self.current_profile) split.addWidget(self.editor) split.setStretchFactor(0, 3) split.setStretchFactor(1, 4) diff --git a/bcc_core.py b/bcc_core.py index d709b28..8fec31d 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -1837,6 +1837,129 @@ def env_ref_warnings( ] +# --------------------------------------------------------------------------- # +# "Move to environment variable" (issue #83) +# +# Turn a plaintext secret in a config into a ${VAR} reference, so the secret +# stops living in the file. Two things make this more than a string swap: +# * the replaced value is the ONLY copy of the secret the user may have, so +# the caller must hand it back (clipboard + the exact shell line) before it +# leaves the config -- shell_export_lines builds that; +# * only clients that expand ${VAR} (Claude Code, not Desktop) should be +# offered this, or it walks the user straight into a broken config -- that +# gate is can_move_value_to_env_ref, reusing client_expands_env_refs. +# The rewrite itself is pure (data in -> data out) and lives here; the GUI does +# clipboard, the confirm dialog, and the already-set check. +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class EnvRefConversion: + """The result of moving one secret out to a ${VAR} reference.""" + + var_name: str # the sanitised shell variable name chosen + reference: str # "${VAR_NAME}" -- what now sits in the config + secret: str # the plaintext value that was removed (hand this back!) + data: dict # a NEW server-definition dict with the value replaced + + +def sanitize_env_var_name(name: str) -> str: + """Coerce an arbitrary key into a legal, conventional shell variable name. + + POSIX names are `[A-Za-z_][A-Za-z0-9_]*`; env vars are conventionally + upper-case. Non-alphanumerics (and any non-ASCII) become `_`, a leading + digit gets an `_` prefix, and an empty/degenerate result falls back to VAR. + 'api-key' -> 'API_KEY'; '2fa' -> '_2FA'; '' -> 'VAR'. + """ + cleaned = "".join( + ch if (ch.isascii() and (ch.isalnum() or ch == "_")) else "_" for ch in (name or "") + ) + if not cleaned.strip("_"): + return "VAR" + if cleaned[0].isdigit(): + cleaned = "_" + cleaned + return cleaned.upper() + + +def _posix_single_quote(s: str) -> str: + """Wrap s in single quotes, safely, for a POSIX shell (handles embedded ').""" + return "'" + s.replace("'", "'\\''") + "'" + + +def shell_export_lines(var_name: str, secret: str) -> dict[str, str]: + """The exact command to set `var_name`=`secret` in the user's shell. + + Returned per-platform so the UI can show the one that fits (or both). This + is what makes moving the secret out safe: the user gets the setter before + the value leaves the file. + """ + return { + "posix": f"export {var_name}={_posix_single_quote(secret)}", + "windows": f'setx {var_name} "{secret}"', + } + + +def can_move_value_to_env_ref(key: str, value, profile: Profile | None = None) -> bool: + """Whether to offer "move to environment variable" for one env/header row. + + True only for a real stored secret (`should_mask_value`) that isn't already + a reference, on a client that expands references. Offering it on Claude + Desktop would produce a config that reaches the server as literal `${VAR}` + text -- the exact failure #76 exists to prevent -- so a non-expanding + profile refuses outright. + """ + if profile is not None and not client_expands_env_refs(profile): + return False + if is_env_ref(value): + return False + return should_mask_value(key, value) + + +def move_value_to_env_ref( + data: dict, *, field: str, key: str | None = None, index: int | None = None, var_name=None +) -> EnvRefConversion | None: + """Replace one secret value in `data` with a `${VAR}` reference. + + `field` is "env" or "headers" (addressed by `key`) or "args" (addressed by + `index`). `var_name` defaults to the row's key (sanitised); args have no + key, so a var name should be supplied there. Returns an EnvRefConversion + carrying a NEW data dict (the input is never mutated) and the removed + secret, or None if the target isn't found or isn't a string to move. + """ + new = copy.deepcopy(data) + + if field in ("env", "headers"): + block = new.get(field) + if not isinstance(block, dict) or key not in block: + return None + current = block[key] + if not isinstance(current, str) or is_env_ref(current): + return None + vn = sanitize_env_var_name(var_name if var_name is not None else key) + block[key] = f"${{{vn}}}" + elif field == "args": + args = new.get("args") + if not isinstance(args, list) or index is None or not (0 <= index < len(args)): + return None + current = args[index] + if not isinstance(current, str) or is_env_ref(current): + return None + vn = sanitize_env_var_name(var_name if var_name is not None else f"ARG_{index}") + args[index] = f"${{{vn}}}" + else: + return None + + return EnvRefConversion(var_name=vn, reference=f"${{{vn}}}", secret=current, data=new) + + +def is_env_var_set(var_name: str, environ: dict | None = None) -> bool: + """Whether `var_name` is already present (non-empty) in the environment. + + Lets the UI skip the copy-the-secret ceremony when the variable is already + set. Best-effort: BCC's environment may differ from the client's. + """ + env = os.environ if environ is None else environ + return bool(env.get(var_name)) + + def is_secret_key(name: str) -> bool: """Does this env-var / header / flag name look like it holds a secret?""" return bool(_SECRET_KEY_RE.search(name or "")) diff --git a/tests/test_core.py b/tests/test_core.py index fd36e80..2f8a7e7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3059,3 +3059,92 @@ def test_discover_project_configs_skips_non_object_and_garbage(tmp_path): assert str(array / ".mcp.json") not in paths assert str(garbage / ".mcp.json") not in paths assert str(missing / ".mcp.json") not in paths + + +# --------------------------------------------------------------------------- # +# Move to environment variable (issue #83) +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "raw,expected", + [ + ("API_KEY", "API_KEY"), + ("api-key", "API_KEY"), + ("x.y z", "X_Y_Z"), + ("2fa", "_2FA"), + ("", "VAR"), + ("***", "VAR"), + ("clé", "CL_"), # non-ASCII becomes _ + ], +) +def test_sanitize_env_var_name(raw, expected): + assert c.sanitize_env_var_name(raw) == expected + + +def test_shell_export_lines_quote_safely(): + lines = c.shell_export_lines("TOKEN", "ab'cd") + assert lines["posix"] == "export TOKEN='ab'\\''cd'" + assert lines["windows"] == 'setx TOKEN "ab\'cd"' + + +def test_can_move_gate_requires_secret_and_expanding_client(): + desktop = c.Profile(label="d", path="/x/Claude/claude_desktop_config.json", config_exists=True) + code = c.Profile(label="c", path=Path.home() / ".claude.json", config_exists=True) + # real secret on an expanding client -> offer + assert c.can_move_value_to_env_ref("API_KEY", "ghp_abc", code) is True + assert c.can_move_value_to_env_ref("API_KEY", "ghp_abc", None) is True + # non-secret key -> no + assert c.can_move_value_to_env_ref("REGION", "us-east-1", code) is False + # already a reference -> no + assert c.can_move_value_to_env_ref("API_KEY", "${API_KEY}", code) is False + # non-expanding client (Claude Desktop) -> refuse even a real secret + assert c.can_move_value_to_env_ref("API_KEY", "ghp_abc", desktop) is False + + +def test_move_env_value_replaces_with_reference_and_returns_secret(): + data = {"command": "x", "env": {"API_KEY": "ghp_secret", "REGION": "us"}} + conv = c.move_value_to_env_ref(data, field="env", key="API_KEY") + assert conv is not None + assert conv.var_name == "API_KEY" + assert conv.reference == "${API_KEY}" + assert conv.secret == "ghp_secret" + assert conv.data["env"]["API_KEY"] == "${API_KEY}" + # non-secret row untouched + assert conv.data["env"]["REGION"] == "us" + # input never mutated + assert data["env"]["API_KEY"] == "ghp_secret" + + +def test_move_derives_and_sanitises_var_name_from_key(): + data = {"headers": {"x-api-key": "sekret"}} + conv = c.move_value_to_env_ref(data, field="headers", key="x-api-key") + assert conv.var_name == "X_API_KEY" + assert conv.data["headers"]["x-api-key"] == "${X_API_KEY}" + + +def test_move_honours_explicit_var_name(): + data = {"env": {"tok": "sekret"}} + conv = c.move_value_to_env_ref(data, field="env", key="tok", var_name="GITHUB_TOKEN") + assert conv.reference == "${GITHUB_TOKEN}" + assert conv.data["env"]["tok"] == "${GITHUB_TOKEN}" + + +def test_move_args_by_index(): + data = {"command": "x", "args": ["--token", "ghp_secret"]} + conv = c.move_value_to_env_ref(data, field="args", index=1, var_name="GH_TOKEN") + assert conv.secret == "ghp_secret" + assert conv.data["args"] == ["--token", "${GH_TOKEN}"] + + +def test_move_returns_none_on_missing_or_nonstring_or_already_ref(): + data = {"env": {"API_KEY": "${API_KEY}", "N": 5}} + assert c.move_value_to_env_ref(data, field="env", key="ABSENT") is None + assert c.move_value_to_env_ref(data, field="env", key="N") is None # not a string + assert c.move_value_to_env_ref(data, field="env", key="API_KEY") is None # already a ref + assert c.move_value_to_env_ref({}, field="bogus") is None + assert c.move_value_to_env_ref({"args": ["a"]}, field="args", index=9) is None + + +def test_is_env_var_set(): + assert c.is_env_var_set("FOO", {"FOO": "x"}) is True + assert c.is_env_var_set("FOO", {"FOO": ""}) is False + assert c.is_env_var_set("FOO", {}) is False