feat: author ${VAR} references, gated on whether the client expands them (#76)
CI / Lint (ruff) (pull_request) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 7s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled

The blocker on this issue was whether BCC or the client does the expanding.
Answer, from Anthropic's docs: Claude Code expands ${VAR} and
${VAR:-default} itself, in command, args, env, url and headers, for both
project .mcp.json and user-scope ~/.claude.json. Claude Desktop has no
documented support.

So this is a per-client capability, not a global one, and BCC does NOT
expand on write: resolving a reference into the file would put the secret
back on disk -- the whole thing the user is avoiding -- and would defeat a
feature the client already implements correctly. BCC authors, validates and
warns; expand_env_refs exists to preview what the client will do.

Semantics mirror the documented ones exactly, including the unusual bit:
an unset variable with no default is left as literal ${VAR} text rather
than blanked, because that is what Claude Code passes through.

Gating uses the existing profile_targets_claude_desktop(), so a config that
is correct under Claude Code and broken under Desktop is reported against
whichever profile is actually loaded. The two warnings are worded
differently on purpose -- 'this client will never expand these' is a
different problem from 'this variable looks unset here'.

Two existing behaviours were backwards for this feature and are fixed:

- Secret masking hid placeholders. is_secret_key('API_KEY') is true, so
  ${API_KEY} rendered as dots -- making a reference indistinguishable from
  a stored credential, which is the one distinction that makes the feature
  worth adopting. should_mask_value() now skips references, in the table
  delegate, _redact_server_data and redact_args alike.
- args_secret_warning fired on placeholders. Moving a token into ${VAR} is
  the recommended fix for that warning; continuing to warn punished the
  fix. It now skips references while still flagging a real secret that
  follows one.

Real secrets are still masked everywhere they were before -- asserted, not
assumed.

Refs #76
This commit is contained in:
2026-07-20 13:46:14 -04:00
parent 7ff4f6e5c0
commit a73f2e3883
3 changed files with 319 additions and 4 deletions
+177 -3
View File
@@ -893,13 +893,28 @@ 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 should_mask_value(key: str, value) -> bool:
"""Whether an env/header value should be masked for display.
A ${VAR} reference is NOT a secret -- it's a pointer to one, and it's the
thing we want users to adopt. Masking it to dots would make a reference
indistinguishable from a stored credential, hiding exactly the distinction
that makes the feature worth using (#76).
"""
if not is_secret_key(key):
return False
return not is_env_ref(value) if isinstance(value, str) else True
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()}
out["env"] = {
k: (MASK if should_mask_value(k, v) else v) for k, v in (out["env"] or {}).items()
}
return out
@@ -1450,6 +1465,155 @@ MASK = "••••••••"
_EMBEDDED_CRED_RE = re.compile(r"://[^:@/\s]+:[^:@/\s]+@")
# --------------------------------------------------------------------------- #
# Environment-variable references (issue #76)
#
# Claude Code expands ${VAR} and ${VAR:-default} itself, in command, args, env,
# url and headers. So BCC does NOT expand these on write -- resolving them into
# the file would put the secret back on disk, which is the whole thing the user
# is avoiding, and would defeat a feature the client already implements. BCC
# authors, validates and warns.
#
# Claude Desktop has no documented support, so the same text there is passed to
# the server literally. That makes this a per-client capability, not a global
# one -- see client_expands_env_refs().
# --------------------------------------------------------------------------- #
# ${NAME} or ${NAME:-default}. Names follow the shell convention (letter or
# underscore first) so a bare "${}" or "${1}" isn't mistaken for a reference.
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
# The five fields Claude Code documents as expansion sites.
ENV_REF_FIELDS = ("command", "args", "env", "url", "headers")
class EnvRef(NamedTuple):
"""One ${VAR} / ${VAR:-default} occurrence found in a server definition."""
name: str
default: str | None
field: str # which of ENV_REF_FIELDS it was found in
@property
def has_default(self) -> bool:
return self.default is not None
def find_env_refs(text: str, field: str = "") -> list[EnvRef]:
"""Every ${VAR} / ${VAR:-default} reference in a single string."""
if not isinstance(text, str):
return []
return [EnvRef(m.group(1), m.group(2), field) for m in _ENV_REF_RE.finditer(text)]
def is_env_ref(value: str) -> bool:
"""True when the value contains at least one ${VAR} reference.
Used to keep placeholders OUT of secret masking: `${API_KEY}` under a
secret-looking key is a reference, not a secret, and masking it to dots
would hide the one distinction the user needs to see.
"""
return bool(find_env_refs(value))
def server_env_refs(data: dict) -> list[EnvRef]:
"""Every env reference in a server definition, tagged with its field.
Only inspects the fields Claude Code actually expands; a ${VAR} written
into some other key is not a reference and shouldn't be reported as one.
"""
out: list[EnvRef] = []
if not isinstance(data, dict):
return out
for field in ENV_REF_FIELDS:
value = data.get(field)
if isinstance(value, str):
out.extend(find_env_refs(value, field))
elif isinstance(value, list):
for item in value:
out.extend(find_env_refs(item, field))
elif isinstance(value, dict):
for v in value.values():
out.extend(find_env_refs(v, field))
return out
def expand_env_refs(text: str, environ: dict | None = None) -> str:
"""Expand ${VAR} / ${VAR:-default} the way Claude Code documents it.
Provided for previewing what the client will do -- BCC never writes the
expanded form back to the config. Unset with no default is left as the
literal ${VAR} text, matching Claude Code: the config still loads and the
unexpanded text is passed through.
"""
if not isinstance(text, str):
return text
env = os.environ if environ is None else environ
def repl(m: re.Match) -> str:
name, default = m.group(1), m.group(2)
if name in env:
return env[name]
return default if default is not None else m.group(0)
return _ENV_REF_RE.sub(repl, text)
def unresolved_env_refs(data: dict, environ: dict | None = None) -> list[EnvRef]:
"""References that would not resolve: variable unset AND no default.
Best-effort by nature -- BCC's environment isn't necessarily the client's,
so this warns rather than blocks, and the warning text says so.
"""
env = os.environ if environ is None else environ
return [r for r in server_env_refs(data) if not r.has_default and r.name not in env]
def client_expands_env_refs(profile: Profile) -> bool:
"""Whether the client behind `profile` expands ${VAR} itself.
Claude Code does, in command/args/env/url/headers, for both project
`.mcp.json` and user-scope `~/.claude.json`. Claude Desktop has no
documented support, so a reference there reaches the server as literal
text -- which surfaces as a confusing auth failure rather than an obvious
config error, hence the warning.
"""
return not profile_targets_claude_desktop(profile)
def env_ref_warnings(
data: dict, profile: Profile | None = None, environ: dict | None = None
) -> list[str]:
"""Advisory warnings about env references in one server definition.
Two distinct problems, deliberately worded differently:
- the target client won't expand them at all (Claude Desktop)
- the client will expand them, but a variable looks unset here
"""
refs = server_env_refs(data)
if not refs:
return []
if profile is not None and not client_expands_env_refs(profile):
names = ", ".join(sorted({f"${{{r.name}}}" for r in refs}))
return [
f"{names} will NOT be expanded by Claude Desktop -- it has no "
f"documented support for variable references, so the server "
f"receives the literal text. Use a real value here, or move this "
f"server to a Claude Code config."
]
missing = unresolved_env_refs(data, environ)
if not missing:
return []
names = ", ".join(sorted({r.name for r in missing}))
return [
f"{names} is not set in this environment and has no ':-default'. "
f"Claude Code will pass the reference through unexpanded. "
f"(Checked against BCC's environment, which may differ from the "
f"client's.)"
]
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 ""))
@@ -1466,17 +1630,22 @@ def redact_args(args: list[str]) -> list[str]:
--api-key=abc123 -> --api-key=•••••••• (inline flag=value)
ghp_abc123 -> •••••••• (well-known token prefix)
Everything else passes through untouched.
${VAR} references are left visible: they name a secret rather than being
one, and hiding them would obscure the difference between "this config
leaks a token" and "this config points at one" (#76).
"""
out: list[str] = []
mask_next = False
for a in args:
s = str(a)
if mask_next:
out.append(MASK)
mask_next = False
out.append(s if is_env_ref(s) else MASK)
continue
if s.startswith("-") and "=" in s and is_secret_key(s.split("=", 1)[0]):
out.append(s.split("=", 1)[0] + "=" + MASK)
flag, value = s.split("=", 1)
out.append(f"{flag}={value}" if is_env_ref(value) else f"{flag}={MASK}")
continue
if s.startswith("-") and is_secret_key(s):
out.append(s)
@@ -1503,6 +1672,11 @@ def args_secret_warning(data: dict) -> str | None:
args = [str(a) for a in (data.get("args") or [])]
mask_next = False
for a in args:
# A ${VAR} reference is the recommended fix for this very warning --
# continuing to warn after the user adopts it punishes the fix (#76).
if is_env_ref(a):
mask_next = False
continue
if mask_next:
mask_next = False
if not a.startswith("-"):