変更点はたった1行でした。Managed MCP の allowlist・denylist に書いた ${VAR} の解決元が、設定ファイル内の env から、起動時の環境と managed-settings の env に変わった、という記述です。
それを読んで手が止まりました。私が個人開発の運用で組んでいる自動化は、呼び出しごとに環境変数が引き継がれない前提で書いてあります。つまり「起動時の環境」は、私にとって最も安定しない変数なのです。そこが解決元になったということは、ポリシーの意味が呼び出しごとに変わりうる、ということでもあります。
許可リストは、書いた内容が守られている前提でしか安心を提供しません。前提が環境に依存するなら、ファイルを眺めるだけでは何も確認できていない。そう思って、両方の解決順序を手元で再現し、判定がどこでずれるかを測りました。
解決元が「ファイルの内側」から「外側」へ出た
以前の挙動では、${VAR} は設定ファイル自身の env ブロックから解決されていました。ファイルを閉じた世界で完結していたわけです。配布した JSON を読めば、展開後の値も読み取れました。
新しい挙動では、まず起動時の環境が参照され、次に managed-settings の env が参照されます。組織で配布する設定を CI と手元の端末で切り替えやすくする、という意図は理解できます。ただし同時に、ポリシーの内容を決める入力が、管理者の手を離れた場所に移りました。
公式ドキュメントは、この構造をはっきり書いています。serverCommand と serverUrl は、ポリシー側の記述とサーバー側の設定値の両方が同じ展開を通ってから照合される。そして「${VAR} の展開は Claude Code 自身のプロセス環境を読むため、変数を参照する serverCommand や serverUrl のエントリは、利用者が設定した値に展開される。強制のために頼るエントリはリテラルで書くこと」と注意されています。
読めば当然の話です。ただ、私が実際に測ってみて驚いたのは、この一文の帰結が想像よりずっと広かったことでした。
検証に使ったハーネス
実際の展開処理は Claude Code 側の実装なので、ここでは文書化されている規則に沿って手元で再現しました。以下は展開器です。解決元を配列で渡し、先に来たものを優先します。旧挙動と新挙動を、この配列の並べ替えだけで切り替えられるようにしてあります。
// expand.mjs — ${VAR} / ${VAR:-default} の展開を、解決元の順序を差し替えられる形で再現する
const REF = / \$\{ ( [A-Za-z_][A-Za-z0-9_] * )(?::-( [ ^ }] * )) ? \} / g ;
// sources: 先に来た方を優先する [name, map] の配列
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 は展開しない
}
照合側は、ドキュメントに書かれた規則をそのまま写しました。URL は * をどこにでも置ける、ホスト名は大文字小文字を区別せず末尾の FQDN ドットを無視する、パスは区別する、パスを持たないパターンは全パスに一致する。コマンドは引数の順序まで含めた完全一致です。
// match.mjs — URL ワイルドカード照合とコマンド完全一致、および評価順序
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 ; // パスなしのパターンは全パスに一致
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 なら無制限、[] なら全拒否
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 ; // URL 項目があると名前一致は数えない
if (server.type === "stdio" && hasCmd) continue ;
}
return { allowed: true , reason: Object. keys (e)[ 0 ] };
}
return { allowed: false , reason: "not-in-allowlist" };
}
serverName を展開しないのは仕様どおりですが、実装しながら落とし穴の位置を改めて意識しました。名前は利用者が付けるラベルであって、サーバーの実体ではありません。ドキュメントも serverName はセキュリティ制御ではないと明記しています。強制したいなら serverCommand か serverUrl を書く、という前提はここでも変わりません。
実効ポリシーは、ファイルではなく「ファイル×環境」で決まる
検証に使った設定は、いかにも書きそうな形にしました。テナント名を変数にし、社内ツールの置き場所を変数にし、開発用のバイナリをホームディレクトリ基準で許可し、サンドボックス用のドメインを変数で拒否する。エントリ6件のうち4件が変数を含み、参照される変数は4種類、既定値つきの参照は0件です。
{
"allowManagedMcpServersOnly" : true ,
"env" : { "TENANT" : "acme" , "TOOLS_DIR" : "/opt/acme/bin" },
"allowedMcpServers" : [
{ "serverUrl" : "https://mcp.${TENANT}.example.com/*" },
{ "serverUrl" : "https://api.githubcopilot.com/*" },
{ "serverCommand" : [ "${TOOLS_DIR}/company-mcp" , "--config" , "/etc/company/mcp.json" ] },
{ "serverCommand" : [ "${HOME}/bin/dev-mcp" ] }
],
"deniedMcpServers" : [
{ "serverUrl" : "https://*.${SANDBOX_DOMAIN}/*" },
{ "serverName" : "dangerous-server" }
]
}
この1ファイルを、3つの環境で解決させて7つのサーバーを判定させました。CI は必要な変数が全部揃った状態、端末は TENANT と SANDBOX_DOMAIN が未定義の状態、差替は利用者が HOME を自分の作業ディレクトリに向けた状態です。
サーバー CI(変数が揃う) 端末(2変数が未定義) 差替(HOME を変更)
tenant-mcp(自社の remote) 許可 拒否 (denylist)許可
github(remote) 許可 拒否 (denylist)許可
sandbox-mcp(遮断したい remote) 拒否 拒否 拒否
evil-remote(未登録の remote) 拒否 拒否 拒否
company(承認済み stdio) 許可 許可 許可
dev-tool(/home/dev/bin/dev-mcp) 拒否 許可 拒否
shadow(/tmp/mine/bin/dev-mcp) 拒否 拒否 許可
CI と端末では、7件のうち3件、割合にして 42.9% で判定が逆になりました。CI と差替では1件(14.3%)です。同じ JSON をレビューして承認しても、走る環境が違えば別のポリシーが効いている。この一点だけでも、設定ファイルの差分レビューが安全確認として不十分だと分かります。
私が個人開発でこの種の設定を扱うとき、これまで無自覚に頼っていたのは「ファイルの中に書いた値がそのまま効く」という閉じた性質でした。それは意図した設計というより、たまたま成立していた安全弁でした。安全弁が外れたのなら、自分で作り直す必要があります。
未解決変数の向きは、置いた場所で逆になる
最も予想を外れたのはここです。未定義の変数は空文字に落ちますが、それが allowlist にあるか denylist にあるかで、事故の方向が反対になります。
端末の環境では SANDBOX_DOMAIN が未定義なので、拒否リストの https://*.${SANDBOX_DOMAIN}/* は https://*./* になります。この一見無害なパターンを、ホスト正規化の実装を2通り用意して測りました。
const urls = [
"https://a.sandbox.example.com/mcp" , "https://api.githubcopilot.com/mcp/" ,
"https://mcp.acme.example.com/mcp" , "https://mcp.attacker.test/mcp"
];
// 実装A: ドキュメントどおり末尾の FQDN ドットを無視する
// 実装B: 末尾ドットを正規化しない
ホスト正規化の実装 崩れたパターンに一致した URL 結果として起きること
末尾ドットを無視する 4/4 件(100%) ホスト部が * に潰れ、remote が全て拒否される
末尾ドットを無視しない 0/4 件(0%) 拒否リストが1件分まるごと無効になる
同じ未解決変数から、全遮断と全通過という正反対の結果が出ました。どちらに転ぶかは照合実装の細部に依存するので、書いた側が予測できません。上の判定表で tenant-mcp と github が端末で拒否されたのは、実装Aの側に倒れた結果です。
一方、許可リスト側で同じことが起きると、そのエントリは何にも一致しない文字列になり、静かに無効化されます。ドキュメントには、ポリシーで弾かれたサーバーは /mcp と claude mcp list から警告なしに消えると書かれています。つまり利用者には「壊れた」ようにしか見えず、原因が変数の未定義であることは表に出ません。
覚えておく形にすると、こうなります。allowlist の未解決は締めすぎ、denylist の未解決は緩みすぎに倒れる。 どちらも沈黙して起きます。
規則そのものを動かせる、という反転
もう一つ、頭の切り替えが必要だった点があります。許可リストを回避する経路として私が想像していたのは、照合される側、つまりサーバーの設定を細工することでした。実際に効くのは、照合する側でした。
差替の環境では、利用者が HOME を /tmp/mine に向けています。すると許可リストのエントリ ${HOME}/bin/dev-mcp 自身が /tmp/mine/bin/dev-mcp に展開されます。許可の対象が、利用者の指定した場所へ移動するのです。判定表の shadow が差替でだけ許可されたのはこれが理由です。同時に、本来通したかった dev-tool は許可の枠から外れます。
規則を破る必要はありません。規則の宛先を書き換えれば済む。以前は解決元が設定ファイルの内側に閉じていたため、この経路は成立しませんでした。解決元が起動時環境に移ったことで、成立します。ドキュメントの「強制に頼るエントリはリテラルで」という一文は、この構造をひとことで言い切っていたわけです。
${VAR:-default} は沈黙を止めるが、強制は取り戻せない
対処としてまず思いつくのは既定値です。${TENANT:-invalid.example} と書けば、未定義でも空文字にはならず、意図しないワイルドカードにも潰れません。実際、未解決由来の事故はこれで消えます。
ただ、これは半分の対処でした。新しい解決順序では起動時環境が managed-settings の env より先に見られるので、利用者が TENANT を設定すれば既定値もファイル内の値も上書きされます。既定値つきに直した設定で、実効ポリシーの指紋を3環境で比べたときの結果です。
起動時環境 実効ポリシーの指紋
HOME=/root TENANT=acme SANDBOX_DOMAIN=sandbox.example.com 03d2bb7397763c07
HOME=/home/dev(TENANT 未定義) 03d2bb7397763c07
HOME=/tmp/mine TENANT=evil.attacker.test 9b5d7c2095923bde
未定義でも指紋は揃うようになりました。しかし値を差し替えられると別のポリシーになります。強制の対象を完全にリテラルへ書き換えた版では、値を差し替えても空にしても、4通りの環境すべてで指紋 03d2bb7397763c07 が動きませんでした。
私の結論はこうです。既定値は可観測性のための道具であって、強制のための道具ではありません。境界を決める値については、変数を使わずリテラルで書くことを推奨します。テナント名や配布先の切り替えのように「環境ごとに変えたい」ものは変数でよい。許可・拒否の境界そのものを決める値は、リテラルで書く。両者を同じ構文で書けてしまうことが、この設定でいちばん危ういところだと感じました。
起動前に、実効ポリシーを指紋で突き合わせる
設定ファイルを読んでも実効ポリシーは分からない。ならば、解決後の状態を出力させて比較できる形にすれば良い。そう考えて起動前チェックを書きました。展開して、所見を挙げ、正規化した実効ポリシーを sha256 で短い指紋にします。指紋を CI と端末で突き合わせれば、環境差はその場で見えます。
#!/usr/bin/env node
// mcp-policy-preflight.mjs — 実効ポリシーを解決し、指紋を出して環境差を検出する
// 使い方: 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 ] } が空文字` });
// 起動時環境と managed env の両方にあり値が違う場合は、環境しだいで意味が変わる
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 }" が ${ other [ 0 ] }="${ other [ 1 ][ name ] }" を隠している` });
return value;
}
if (dflt !== undefined ) { findings. push ({ level: "info" , code: "DEFAULTED" , where, name, detail: `既定値 "${ dflt }" を使用` }); return dflt; }
findings. push ({ level: "error" , code: "UNRESOLVED" , where, name, detail: "どの解決元にも存在しない" });
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);
// 強制に使う項目が変数を含んでいたら、利用者が値を差し替えられる
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: "強制に使う項目が変数を含む(利用者の環境で意味が変わる)" }); }
return resolved;
});
}
// 指紋: 実効ポリシーを正規化してハッシュ化する。環境間で一致するかを比較できる
const 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 ( `実効ポリシー指紋: ${ fingerprint }` );
for ( const f of findings) console. log ( ` [${ f . level }] ${ f . code } ${ f . where } ${ f . name } — ${ f . detail }` );
if ( ! findings. length ) console. log ( " 所見なし" );
}
process. exit (errors. length ? 1 : 0 );
REF.test(raw) の直後に REF.lastIndex = 0 を入れているのは、g フラグ付き正規表現が前回の位置を覚えているためです。忘れると2件目以降の判定が飛びます。手元でも一度これで検出漏れを出しました。
最初の設定を、変数の揃った環境と足りない環境で走らせた出力です。
$ HOME=/root TENANT=acme TOOLS_DIR=/opt/acme/bin SANDBOX_DOMAIN=sandbox.example.com \
node mcp-policy-preflight.mjs policy.json
実効ポリシー指紋: 8e135e9e5af52776
[warn] USER_STEERABLE allowedMcpServers[0] - — 強制に使う項目が変数を含む(利用者の環境で意味が変わる)
[warn] USER_STEERABLE allowedMcpServers[2] - — 強制に使う項目が変数を含む(利用者の環境で意味が変わる)
[warn] USER_STEERABLE allowedMcpServers[3] - — 強制に使う項目が変数を含む(利用者の環境で意味が変わる)
[warn] USER_STEERABLE deniedMcpServers[0] - — 強制に使う項目が変数を含む(利用者の環境で意味が変わる)
exit=0
$ env -u TENANT -u SANDBOX_DOMAIN HOME=/home/dev node mcp-policy-preflight.mjs policy.json
実効ポリシー指紋: 5a72a0ce0f10f8bc
...
[error] UNRESOLVED deniedMcpServers[0] SANDBOX_DOMAIN — どの解決元にも存在しない
exit=1
指紋が 8e135e9e5af52776 と 5a72a0ce0f10f8bc に分かれています。この不一致こそが、レビューでは見えなかった事実です。Node.js v22.22.3 で10回計測した実行時間は平均 42ms(最小 40ms・最大 52ms)でした。セッション起動前のフックに置いても体感に響く長さではありません。
運用に組み込むときの順序は次のようにしました。
配布側の CI で --json 付きで走らせ、指紋を成果物として記録する
端末のセッション起動前に同じスクリプトを走らせ、指紋を記録済みの値と比較する
UNRESOLVED と EMPTY は非ゼロ終了なので、そのまま起動を止める
USER_STEERABLE が付いたエントリは、次の棚卸しでリテラル化する候補として残す
指紋が変わったら、環境差か設定変更かを切り分けてから通す
3の扱いは意図的に厳しくしています。未解決変数は、方向が予測できない事故に直結するからです。止めて調べるほうが安いという判断です。
どこまでを変数にしてよいか
測ったうえでの線引きを、私はこう決めました。
書きたい対象 推奨 理由
許可・拒否の境界を決める URL とコマンド リテラルのみ 変数だと利用者の環境で宛先が動く
環境ごとに変えたいテナント名・リージョン ${VAR:-安全側の既定}未定義でも空文字にならず、パターンが潰れない
ホームディレクトリ基準のパス 避ける 最も差し替えやすい変数で、開発用の抜け道になりやすい
拒否リストのドメイン リテラル必須 未解決時に拒否が丸ごと無効化されうる
サーバーの識別 serverUrl / serverCommandserverName は利用者が付けるラベルで強制に使えない
既定値の中身も、動く値ではなく invalid.example のような確実に一致しない値を選びました。未定義に気づかないまま通ってしまう事故より、確実に落ちるほうが扱いやすいと考えたからです。
外向き通信を締める話とは近い問題で、あちらはstrictAllowlist で Claude Code サンドボックスの外向き通信を締める にまとめています。どちらも「書いた内容が本当に効いているか」を起動時に確かめる手段があるかどうかで、運用の安心感が変わりました。
今日できる一手
もし Managed MCP の allowlist・denylist に ${...} を1つでも書いているなら、まずその設定を、変数が揃った環境と1つ欠けた環境の2通りで解決させてみてください。判定が変わるサーバーが1件でも出たら、そのエントリはリテラルへ寄せる候補です。上の preflight をそのまま使えるようにしてあります。
私自身、この検証をするまでは「配布した JSON を読めば効いている内容が分かる」と思い込んでいました。実効ポリシーは設定ファイルではなく、設定ファイルと環境の積で決まる。その事実を短い指紋として毎回突き合わせられるようにしたことが、今回いちばん役に立った変更でした。お読みいただきありがとうございました。