CLAUDE LABJP
NEST — Subagents can now spawn nested subagents up to depth 3 by default, up from 1, making research/build/verify pipelines practical without extra setupAPISKILL — The bundled claude-api skill now defaults to Claude Opus 5, with a documented migration path from Opus 4.8ENVVAR — ${VAR} entries in managed MCP allowlists and denylists now resolve from the startup environment and managed-settings env, not the settings-file envA11Y — A screen reader mode lets you follow a session with assistive tech, including announcements of deleted textVOICE — Voice mode now runs on Opus, Sonnet, and Haiku alike, reaches connected tools like Gmail and Slack, and supports many more languagesTEACH — Claude for Teachers launched on July 14, alongside a $10M commitment to Canadian AI researchNEST — Subagents can now spawn nested subagents up to depth 3 by default, up from 1, making research/build/verify pipelines practical without extra setupAPISKILL — The bundled claude-api skill now defaults to Claude Opus 5, with a documented migration path from Opus 4.8ENVVAR — ${VAR} entries in managed MCP allowlists and denylists now resolve from the startup environment and managed-settings env, not the settings-file envA11Y — A screen reader mode lets you follow a session with assistive tech, including announcements of deleted textVOICE — Voice mode now runs on Opus, Sonnet, and Haiku alike, reaches connected tools like Gmail and Slack, and supports many more languagesTEACH — Claude for Teachers launched on July 14, alongside a $10M commitment to Canadian AI research
Articles/Claude Code
Claude Code/2026-07-27Advanced

Whose Environment Expands That Variable? Fingerprinting Your Effective Managed MCP Policy

Variable references in the Managed MCP allowlist and denylist now resolve from the startup environment and the managed-settings env block. I rebuilt both resolution orders locally to see where verdicts diverge, then wrote a preflight check that reduces the effective policy to a comparable fingerprint.

Claude Code204MCP48permissions4automation98

Premium Article

The change was a single line: variable references in the Managed MCP allowlist and denylist now resolve from the startup environment and the managed-settings env block, instead of the env block inside the settings file itself.

Reading it stopped me. As an indie developer I run my own scheduled automation, and it is written on the assumption that environment variables do not carry between invocations. The startup environment is, for me, the least stable input there is. If that becomes the resolution source, then the meaning of a policy can shift from one run to the next.

An allowlist only buys you confidence if what you wrote is what gets enforced. When that depends on the environment, reading the file confirms nothing. So I rebuilt both resolution orders locally and measured exactly where the verdicts diverge.

Resolution moved from inside the file to outside it

Under the previous behavior, a variable reference resolved from the settings file's own env block. The world was closed: read the distributed JSON and you could read the expanded values too.

Now the startup environment is consulted first, then the managed-settings env. The intent makes sense — you can swap values between CI and a workstation without editing the file. But the inputs that decide the policy have moved outside the administrator's reach.

The official documentation is explicit about the shape of this. Both serverCommand and serverUrl go through the same expansion on the policy side and on the server-config side before matching. And it warns directly: because expansion reads Claude Code's own process environment, a policy entry that references a variable expands to whatever value the user sets, so entries you rely on for enforcement should be written as literals.

Obvious once stated. What surprised me when I measured it was how far that one sentence reaches.

The harness

The actual expansion lives inside Claude Code, so I rebuilt it locally following the documented rules. The expander below takes resolution sources as an ordered array, so switching between the old and new behavior is just reordering that array.

// expand.mjs — reproduce ${VAR} / ${VAR:-default} with a swappable resolution order
const REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
 
// sources: array of [name, map]; earlier entries win
export function expand(value, sources, opts = {}) {
  const misses = [];
  const out = String(value).replace(REF, (whole, name, dflt) => {
    for (const [origin, map] of sources) {
      if (map && Object.prototype.hasOwnProperty.call(map, name) && map[name] !== undefined) {
        return String(map[name]);
      }
    }
    if (dflt !== undefined) { misses.push({ name, kind: "default", used: dflt }); return dflt; }
    misses.push({ name, kind: "unresolved", used: "" });
    return "";
  });
  return { out, misses };
}
 
export function expandEntry(entry, sources, opts) {
  const misses = [];
  const push = r => { misses.push(...r.misses); return r.out; };
  if (entry.serverUrl !== undefined)
    return { entry: { serverUrl: push(expand(entry.serverUrl, sources, opts)) }, misses };
  if (entry.serverCommand !== undefined)
    return { entry: { serverCommand: entry.serverCommand.map(a => push(expand(a, sources, opts))) }, misses };
  return { entry: { ...entry }, misses }; // serverName never expands
}

The matcher follows the documented rules as written: URLs accept * anywhere, hostnames are case-insensitive and ignore a trailing FQDN dot, paths stay case-sensitive, and a pattern with no path matches any path. Commands must match exactly, argument for argument.

// match.mjs — URL wildcard matching, exact command matching, and evaluation order
function normHost(h) { return h.replace(/\.$/, "").toLowerCase(); }
 
function splitUrl(u) {
  const m = /^([^:]*):\/\/([^/?#]*)(.*)$/.exec(u);
  return m ? { scheme: m[1], host: m[2], path: m[3] } : null;
}
 
function globToRe(s, { ci = false } = {}) {
  const body = s.split("*").map(p => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
  return new RegExp("^" + body + "$", ci ? "i" : "");
}
 
export function urlMatches(pattern, url) {
  const p = splitUrl(pattern), u = splitUrl(url);
  if (!p || !u) return globToRe(pattern, { ci: true }).test(url);
  if (!globToRe(p.scheme, { ci: true }).test(u.scheme)) return false;
  if (!globToRe(normHost(p.host), { ci: true }).test(normHost(u.host))) return false;
  if (p.path === "") return true;                 // a pattern with no path matches any path
  return globToRe(p.path).test(u.path === "" ? "/" : u.path);
}
 
export function commandMatches(pattern, cmd) {
  return Array.isArray(pattern) && Array.isArray(cmd)
    && pattern.length === cmd.length
    && pattern.every((a, i) => a === cmd[i]);
}
 
function hit(entry, server) {
  if (entry.serverUrl !== undefined)
    return server.type === "remote" && urlMatches(entry.serverUrl, server.url);
  if (entry.serverCommand !== undefined)
    return server.type === "stdio" && commandMatches(entry.serverCommand, server.command);
  if (entry.serverName !== undefined) return entry.serverName === server.name;
  return false;
}
 
// allow === undefined means unrestricted; [] means nothing allowed
export function evaluate(server, { allow, deny = [] }) {
  for (const e of deny) if (hit(e, server)) return { allowed: false, reason: "denylist" };
  if (allow === undefined) return { allowed: true, reason: "no-allowlist" };
  const hasUrl = allow.some(e => e.serverUrl !== undefined);
  const hasCmd = allow.some(e => e.serverCommand !== undefined);
  for (const e of allow) {
    if (!hit(e, server)) continue;
    if (e.serverName !== undefined) {
      if (server.type === "remote" && hasUrl) continue;   // name matches don't count once URL entries exist
      if (server.type === "stdio" && hasCmd) continue;
    }
    return { allowed: true, reason: Object.keys(e)[0] };
  }
  return { allowed: false, reason: "not-in-allowlist" };
}

Leaving serverName unexpanded is per spec, but implementing it made the reason concrete. A name is a label the user assigns, not the server itself, and the documentation says plainly that it is not a security control. If you want enforcement, write a command or a URL.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
A working harness (expander, matcher, differ) that reproduces how one managed-settings.json turns into different policies per environment, shown as a seven-server verdict table
Measured asymmetry of unresolved variables: the same collapsed pattern matches 4 of 4 URLs under one host-normalization rule and 0 of 4 under the other, so an allowlist over-tightens while a denylist silently opens
Full source of a preflight script that reduces the effective policy to a sha256 fingerprint, runs in 42ms, and exits non-zero on unresolved or empty expansions
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Claude Code2026-05-04
Claude Code MCP Servers in Practice — From Setup to Real-World Use
A practical guide to connecting MCP servers in Claude Code — covering configuration tips, common errors, and real project setups that actually work.
Claude Code2026-03-20
Implementation Patterns for Custom Claude Code Skills — SKILL.md Design, Testing, and Distribution
A hands-on tutorial for building three production custom Claude Code skills from scratch. Covers SKILL.md structure, agent types, context injection, testing, and team distribution.
Claude Code2026-07-19
A Committed Symlink That Points Outside the Worktree — Auditing Repos Before You Let AI Spin Up Parallel Trees
Claude Code 2.1.212 fixed a bug where a committed symlink under .claude/worktrees could be followed during worktree creation and write outside the repo. The patch closes the following side. Here is an audit script for the committed side, plus a quarantine workflow.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →