●NEST — Subagents can now spawn nested subagents up to depth 3 by default, up from 1, making research/build/verify pipelines practical without extra setup●APISKILL — The bundled claude-api skill now defaults to Claude Opus 5, with a documented migration path from Opus 4.8●ENVVAR — ${VAR} entries in managed MCP allowlists and denylists now resolve from the startup environment and managed-settings env, not the settings-file env●A11Y — A screen reader mode lets you follow a session with assistive tech, including announcements of deleted text●VOICE — Voice mode now runs on Opus, Sonnet, and Haiku alike, reaches connected tools like Gmail and Slack, and supports many more languages●TEACH — Claude for Teachers launched on July 14, alongside a $10M commitment to Canadian AI research●NEST — Subagents can now spawn nested subagents up to depth 3 by default, up from 1, making research/build/verify pipelines practical without extra setup●APISKILL — The bundled claude-api skill now defaults to Claude Opus 5, with a documented migration path from Opus 4.8●ENVVAR — ${VAR} entries in managed MCP allowlists and denylists now resolve from the startup environment and managed-settings env, not the settings-file env●A11Y — A screen reader mode lets you follow a session with assistive tech, including announcements of deleted text●VOICE — Voice mode now runs on Opus, Sonnet, and Haiku alike, reaches connected tools like Gmail and Slack, and supports many more languages●TEACH — Claude for Teachers launched on July 14, alongside a $10M commitment to Canadian AI research
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.
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.
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 orderfunction 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 allowedexport 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.
The effective policy is the file multiplied by the environment
I deliberately wrote the test configuration the way people actually write these: tenant name as a variable, internal tool directory as a variable, a development binary allowed relative to the home directory, and a sandbox domain denied through a variable. Four of the six entries contain a variable, four distinct variables are referenced, and zero references carry a default.
I resolved that one file against three environments and judged seven servers. CI has every variable set. The workstation is missing TENANT and SANDBOX_DOMAIN. The third case is a user who points HOME at their own working directory.
Server
CI (all vars set)
Workstation (two unset)
Redirected HOME
tenant-mcp (own remote)
allowed
blocked (denylist)
allowed
github (remote)
allowed
blocked (denylist)
allowed
sandbox-mcp (meant to be blocked)
blocked
blocked
blocked
evil-remote (unlisted remote)
blocked
blocked
blocked
company (approved stdio)
allowed
allowed
allowed
dev-tool (/home/dev/bin/dev-mcp)
blocked
allowed
blocked
shadow (/tmp/mine/bin/dev-mcp)
blocked
blocked
allowed
Three of seven verdicts — 42.9% — flip between CI and the workstation. One (14.3%) flips between CI and the redirected-HOME case. You can review that JSON, approve it, and still have a different policy in force depending on where it runs — which means a diff review is not, on its own, a safety check.
Looking back, what I had been relying on without noticing was the closed property: values written in the file were the values that took effect. That was never a design decision on my part. It happened to hold. Now that it doesn't, the guarantee has to be rebuilt deliberately.
An unresolved variable fails in opposite directions
This is the part that ran against my expectations. An undefined variable collapses to an empty string, but whether that tightens or loosens depends entirely on which list it sits in.
On the workstation, SANDBOX_DOMAIN is undefined, so the deny entry https://*.${SANDBOX_DOMAIN}/* becomes https://*./*. I measured that innocuous-looking pattern against two host-normalization implementations.
Host normalization
URLs matched by the collapsed pattern
Consequence
Trailing FQDN dot ignored
4 of 4 (100%)
The host part collapses to * and every remote server is blocked
Trailing dot not normalized
0 of 4 (0%)
That denylist entry becomes inert
The same unresolved variable produced total lockout and total pass-through. Which way it lands depends on details of the matching implementation, which means the person writing the entry cannot predict it. In the verdict table above, tenant-mcp and github were blocked on the workstation because my harness lands on the first row.
In an allowlist the same collapse produces a string that matches nothing, and the entry is quietly voided. The documentation notes that a server blocked by policy disappears from /mcp and claude mcp list with no warning. To the user it simply looks broken, and nothing surfaces the undefined variable as the cause.
Compressed into something worth remembering: an unresolved variable makes an allowlist too strict and a denylist too permissive, and both happen in silence.
You can move the rule, not just the target
The second reversal took some rewiring. When I imagined how an allowlist gets circumvented, I pictured tampering with the thing being matched — the server config. What actually works is tampering with the matcher.
In the third environment the user points HOME at /tmp/mine. The allowlist entry ${HOME}/bin/dev-mcp therefore expands to /tmp/mine/bin/dev-mcp. The target of the permission relocates to wherever the user says. That is why shadow is allowed only in that column — and why dev-tool, the binary the entry was written for, falls outside the allowance at the same time.
No rule has to be broken. The rule's address just gets rewritten. Under the old resolution source, closed inside the settings file, this path did not exist. With resolution moved to the startup environment, it does. The documentation's advice to keep enforcement entries literal was describing exactly this.
Defaults stop the silence but do not restore enforcement
The first fix that comes to mind is a default value. Write ${TENANT:-invalid.example} and an undefined variable no longer becomes an empty string, so the pattern never collapses into an accidental wildcard. Failures caused by unresolved references do disappear.
That turned out to be half a fix. Under the new order, the startup environment is consulted before the managed-settings env, so a user who sets TENANT overrides both the default and the value in the file. Here are the effective-policy fingerprints for the defaulted configuration across three environments.
Missing variables no longer change the fingerprint. Substituted values still do. When I rewrote every enforcement target as a literal, all four environments I tried — including one that set the variables to empty strings — produced the same fingerprint, 03d2bb7397763c07.
So my conclusion is this. A default is an observability tool, not an enforcement tool. Values you genuinely want to vary per environment, like a tenant name, are fine as variables. Values that define the boundary between allowed and denied belong in literals. The riskiest property of this configuration format is that both are written with the same syntax.
Check the effective policy by fingerprint before startup
If reading the file cannot tell you the effective policy, then print the resolved state in a form you can compare. That is what the preflight below does: expand, report findings, canonicalize the effective policy, and reduce it to a short sha256 fingerprint. Compare fingerprints between CI and a workstation and the drift is immediately visible.
#!/usr/bin/env node// mcp-policy-preflight.mjs — resolve the effective policy, fingerprint it, surface environment drift// usage: node mcp-policy-preflight.mjs <managed-settings.json> [--json]import { readFileSync } from "node:fs";import { createHash } from "node:crypto";const REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;const [file, ...flags] = process.argv.slice(2);if (!file) { console.error("usage: mcp-policy-preflight.mjs <managed-settings.json> [--json]"); process.exit(2); }const cfg = JSON.parse(readFileSync(file, "utf8"));const sources = [["startup", process.env], ["settings.env", cfg.env ?? {}]];const findings = [];function expandValue(raw, where) { return String(raw).replace(REF, (whole, name, dflt) => { const found = sources.find(([, m]) => m?.[name] !== undefined); if (found) { const value = String(found[1][name]); if (value === "") findings.push({ level: "error", code: "EMPTY", where, name, detail: `${found[0]} is an empty string` }); // present in both sources with different values: meaning depends on the environment const other = sources.find(([o, m]) => o !== found[0] && m?.[name] !== undefined); if (other && String(other[1][name]) !== value) findings.push({ level: "warn", code: "SHADOWED", where, name, detail: `${found[0]}="${value}" shadows ${other[0]}="${other[1][name]}"` }); return value; } if (dflt !== undefined) { findings.push({ level: "info", code: "DEFAULTED", where, name, detail: `using default "${dflt}"` }); return dflt; } findings.push({ level: "error", code: "UNRESOLVED", where, name, detail: "not present in any resolution source" }); return ""; });}function resolveEntry(entry, where) { if (entry.serverUrl !== undefined) return { serverUrl: expandValue(entry.serverUrl, where) }; if (entry.serverCommand !== undefined) return { serverCommand: entry.serverCommand.map(a => expandValue(a, where)) }; return { ...entry };}const effective = {};for (const key of ["allowedMcpServers", "deniedMcpServers"]) { if (!cfg[key]) continue; effective[key] = cfg[key].map((e, i) => { const where = `${key}[${i}]`; const resolved = resolveEntry(e, where); // an enforcement field containing a variable can be steered by the user's environment const raw = e.serverUrl ?? (e.serverCommand ? e.serverCommand.join(" ") : ""); if (REF.test(raw)) { REF.lastIndex = 0; findings.push({ level: "warn", code: "USER_STEERABLE", where, name: "-", detail: "enforcement field contains a variable" }); } return resolved; });}// fingerprint: canonicalize the effective policy so environments can be comparedconst canon = JSON.stringify(effective, (k, v) => Array.isArray(v) && v.every(x => x && typeof x === "object") ? [...v].map(x => JSON.stringify(x)).sort() : v);const fingerprint = createHash("sha256").update(canon).digest("hex").slice(0, 16);const errors = findings.filter(f => f.level === "error");if (flags.includes("--json")) { console.log(JSON.stringify({ fingerprint, findings, effective }, null, 2));} else { console.log(`effective policy fingerprint: ${fingerprint}`); for (const f of findings) console.log(` [${f.level}] ${f.code} ${f.where} ${f.name} — ${f.detail}`); if (!findings.length) console.log(" no findings");}process.exit(errors.length ? 1 : 0);
The REF.lastIndex = 0 right after REF.test(raw) matters because a global regular expression remembers where it stopped. Omit it and every entry after the first gets skipped. I lost a couple of detections that way before catching it.
Running the original configuration in a complete environment and an incomplete one:
$ HOME=/root TENANT=acme TOOLS_DIR=/opt/acme/bin SANDBOX_DOMAIN=sandbox.example.com \ node mcp-policy-preflight.mjs policy.jsoneffective policy fingerprint: 8e135e9e5af52776 [warn] USER_STEERABLE allowedMcpServers[0] - — enforcement field contains a variable [warn] USER_STEERABLE allowedMcpServers[2] - — enforcement field contains a variable [warn] USER_STEERABLE allowedMcpServers[3] - — enforcement field contains a variable [warn] USER_STEERABLE deniedMcpServers[0] - — enforcement field contains a variableexit=0$ env -u TENANT -u SANDBOX_DOMAIN HOME=/home/dev node mcp-policy-preflight.mjs policy.jsoneffective policy fingerprint: 5a72a0ce0f10f8bc ... [error] UNRESOLVED deniedMcpServers[0] SANDBOX_DOMAIN — not present in any resolution sourceexit=1
The fingerprints split into 8e135e9e5af52776 and 5a72a0ce0f10f8bc. That mismatch is the fact a file review could not surface. Across ten runs on Node.js v22.22.3 the script averaged 42ms (40ms minimum, 52ms maximum), short enough to sit in a pre-session hook without being felt.
The order I settled on for wiring it into operations:
Run it with --json in the distributing CI job and record the fingerprint as an artifact
Run the same script before a session starts on the workstation and compare against the recorded value
Treat UNRESOLVED and EMPTY as hard stops, since both exit non-zero already
Keep every USER_STEERABLE entry on a list of candidates to rewrite as literals
When a fingerprint changes, separate environment drift from an intentional edit before letting it through
Step 3 is deliberately unforgiving. An unresolved variable leads to a failure whose direction cannot be predicted, and stopping to look is cheaper than discovering it later.
How much belongs in a variable
Having measured it, here is the line I drew.
What you are writing
Recommendation
Why
URLs and commands that define the allow/deny boundary
Literals only
A variable lets the target move with the user's environment
Tenant name or region that varies per environment
Variable with a safe default
Never collapses to an empty string, so patterns stay intact
Paths relative to the home directory
Avoid
The easiest variable to substitute, and a ready-made development back door
Domains in the denylist
Literals required
An unresolved reference can void the deny entry entirely
Identifying a server
serverUrl or serverCommand
serverName is a user-assigned label and cannot enforce anything
For the default values themselves I chose things that are guaranteed not to match, like invalid.example, rather than a working value. A configuration that fails loudly is easier to operate than one that quietly passes while a variable is missing.
If your Managed MCP allowlist or denylist contains even one variable reference, resolve that configuration twice: once with every variable set, once with one of them missing. If a single server's verdict changes, that entry is a candidate for a literal. The preflight above is ready to run as-is.
Until I measured this, I assumed that reading the distributed JSON told me what was in force. The effective policy is not the settings file. It is the settings file multiplied by the environment. Being able to compare that product as a short fingerprint on every run turned out to be the most useful change I made here.
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.