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
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:
+177
-3
@@ -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("-"):
|
||||
|
||||
Reference in New Issue
Block a user