リリースノートを組み立てるだけのサブエージェントが、なぜか MCP のツール一覧を持っていました。
個人開発で回している構成なので、サブエージェントの権限はできるだけ狭く保つようにしています。そのエージェントにも MCP を継承させないつもりでいました。agent.md にもそう書いたはずでした。開いてみると、確かに書いてあります。
inheritMCP: false
Mcp ではなく MCP。たった1文字の大小の違いです。
そしてこの1文字が、私の意図を正反対にひっくり返していました。
エラーにならないから、気づけない
厄介なのは、この設定ファイルが読み込みに失敗しなかったことです。起動時に警告も出ません。エージェントは何食わぬ顔で立ち上がり、普通に動きます。
理由は単純で、frontmatter は YAML として完全に正しいからです。手元で確かめてみました。
import yaml
raw = open("agents/release-notes.md").read().split("---")[1]
d = yaml.safe_load(raw)
print("parse: OK, keys =", list(d))
print("d.get('inheritMcp') ->", d.get('inheritMcp'))
print("d.get('inheritMCP') ->", d.get('inheritMCP'))
実行結果です。
parse: OK, keys = ['subagent', 'inheritMCP', 'commandExecutionPolicy', 'model']
d.get('inheritMcp') -> None
d.get('inheritMCP') -> False
パーサーから見れば inheritMCP は単なる有効なキーです。値が false であることも正しく読めています。ただ、その名前を誰も探しに来ないというだけ。
設定を読む側は inheritMcp を探し、見つからず、既定値を適用します。私が書いた false は、YAML の中に正しい形で存在したまま、一度も参照されずに終わります。
想定と逆だったのは、フォールバックの向き
ここが、私が事前に予想していたことと逆でした。
設定ミスは安全側に倒れるものだと、なんとなく思い込んでいました。読めない設定があれば厳しい方へ寄せる、という発想です。ところが実際には、未指定のキーは「使いやすい既定値」へ落ちます。そして権限に関する設定では、使いやすい既定値とはたいてい緩い方を指します。
つまり権限を絞る側の記述ほど、綴りを間違えたときに無効化されやすいのです。緩める側の記述は既定値と方向が同じなので、間違えても症状が出にくく、気づく機会もありません。
構文エラーは教えてくれるのに、意味の取り違えは教えてくれません。設定ファイル全般に言えることですが、対象が権限だと結果の重さが変わります。
なお既定値の具体的な内容はバージョンによって変わり得ます。ここに書いた表は私の環境で観測したものなので、実際に使うときはご自身のバージョンで一度確かめていただくのが確実です。後述する lint も、既定値を定数として先頭にまとめてあります。
キー名についても同じ断りが必要です。Antigravity CLI の公式ドキュメント(agents のページ)で例示されている frontmatter は name と description だけで、本文のキー名(inheritMcp など)はそこでは確認できませんでした。ここで扱うキー名と値は、私の環境の agent.md で観測したものです。ご自身の環境では KNOWN_KEYS と SECURITY_KEYS を実際のスキーマに合わせて置き換えてお使いください。
権限キーだけを厳しく見る
対策として lint を書きました。agent.md を読み、frontmatter を既知のスキーマと突き合わせます。
方針として大事にしたのは、すべての未知キーを同じ重さで扱わないことです。
コメント代わりのメモを frontmatter に書くことはありますし、将来のバージョンで増えるキーを先取りして書いておくこともあります。それらを一律に ERROR にすると、開発者は lint を無視するようになります。無視される検査は無いのと同じです。
そこで、既知キーとの編集距離が近い未知キーだけを「綴り誤りの疑い」として扱い、さらにそれが権限に関わるキーなら ERROR、そうでなければ WARN に分けました。
#!/usr/bin/env python3
"""agent.md の frontmatter を厳格に検証する lint。
未知キーは黙って無視されるため、権限に関わるキーの綴り誤りは
「エラーにならず既定値へフォールバックする」形で通過してしまう。
CI 側で明示的に弾く。
"""
import sys, os, glob, difflib, time, re
import yaml
KNOWN_KEYS = {
"mainAgent": bool,
"subagent": bool,
"hidden": bool,
"inheritMcp": bool,
"commandExecutionPolicy": str,
"model": str,
}
# 未指定・綴り誤り時に実際に適用される値。
# バージョンによって変わるため、ここを自分の環境に合わせて更新する。
DEFAULTS = {
"mainAgent": False,
"subagent": False,
"hidden": False,
"inheritMcp": True,
"commandExecutionPolicy": "ask",
"model": "inherit",
}
POLICY_VALUES = {"ask", "allow", "deny"}
SECURITY_KEYS = {"inheritMcp", "commandExecutionPolicy", "hidden"}
RISK = {"allow": 2, "ask": 1, "deny": 0}
def split_frontmatter(text):
if not text.startswith("---"):
return None, text, "frontmatter がありません"
end = text.find("\n---", 3)
if end == -1:
return None, text, "frontmatter が閉じられていません"
try:
data = yaml.safe_load(text[3:end])
except yaml.YAMLError as e:
return None, text[end + 4:], f"YAML パースエラー: {e}"
if data is None:
data = {}
if not isinstance(data, dict):
return None, text[end + 4:], "frontmatter が map ではありません"
return data, text[end + 4:], None
続いて、1ファイル分の検査本体です。ここが lint の中心になります。
def lint_file(path):
findings = []
with open(path, encoding="utf-8") as f:
text = f.read()
data, body, err = split_frontmatter(text)
if err:
return [("ERROR", err)], {}
for key, value in data.items():
if key in KNOWN_KEYS:
expected = KNOWN_KEYS[key]
if not isinstance(value, expected):
findings.append(
("ERROR", f"{key}: 型が {expected.__name__} ではありません({value!r})"))
continue
# 既知キーとの距離で綴り誤りを推定する。
# cutoff=0.6 は inherit_mcp / inheritMCP を拾い、
# 無関係な memo や owner を拾わない値として選んだ。
near = difflib.get_close_matches(key, KNOWN_KEYS, n=1, cutoff=0.6)
if near:
suggestion = near[0]
level = "ERROR" if suggestion in SECURITY_KEYS else "WARN"
findings.append((level,
f"未知キー {key!r} -> {suggestion!r} の綴り誤りの可能性。"
f"現状 {suggestion}={DEFAULTS[suggestion]!r} が適用されます"))
else:
findings.append(("WARN", f"未知キー {key!r}(無視されます)"))
policy = data.get("commandExecutionPolicy")
if policy is not None and policy not in POLICY_VALUES:
findings.append(("ERROR",
f"commandExecutionPolicy: 未知の値 {policy!r}。"
f"許容値は {sorted(POLICY_VALUES)}。既定の 'ask' へ落ちます"))
policy = None
# 行頭の「# 」だけを H1 とみなす。"## 役割" や "C# の" を拾わないため。
if not re.search(r"^# \S", body, re.M):
findings.append(("WARN", "H1 見出しがありません。システムプロンプトの区切りを確認してください"))
# 実効値を組み立て直す。型が違うキーは採用しない。
effective = dict(DEFAULTS)
for k, v in data.items():
if k in KNOWN_KEYS and isinstance(v, KNOWN_KEYS[k]):
effective[k] = v
if policy is None:
effective["commandExecutionPolicy"] = DEFAULTS["commandExecutionPolicy"]
if effective["commandExecutionPolicy"] == "allow" and effective["inheritMcp"]:
findings.append(("ERROR",
"commandExecutionPolicy=allow と inheritMcp=true の同時指定。"
"承認なしで継承済み MCP ツールを呼び出せます"))
return findings, effective
H1 の検査には、掲載後に自分で穴を見つけて直した箇所があります。最初は "# " not in body と書いていましたが、これは部分一致なので ## 役割 の中の # にも、本文中の C# の にも反応します。H1 が無いファイルを見逃す側に倒れる検査でした。
3つの小さなファイルで両方を走らせて確かめた結果です。権限の検査ではないので重さは WARN にとどめていますが、「検査が黙って通してしまう」穴を持つ lint を、綴り誤りを見つけるために書いていたことになります。皮肉な話でした。
最後の組み合わせ検査を入れた理由を書いておきます。
allow も inheritMcp も、単体では正当な設定です。ベンチマークを回すだけのエージェントに毎回承認を求めるのは現実的ではありませんし、MCP の継承はむしろ普通の構成です。
問題は掛け合わせたときで、承認なしにコマンドを実行できるエージェントが、継承した MCP ツール一式にも手が届く状態になります。個別に見ていると気づきにくく、実効値まで組み立て直して初めて見える種類の危うさです。
実効値を並べる出力にした
そしてもう一つ、検査結果と同じくらい役に立ったのが出力の形式でした。
違反リストだけを出す lint にしていたときは、直すべき箇所は分かっても「今この構成全体でどこまで許しているのか」が分かりませんでした。そこで、全ファイルの実効値を危険度順に並べる一覧を必ず出すようにしました。
def main(paths):
t0 = time.perf_counter()
errors = warns = 0
inventory = []
for path in sorted(paths):
findings, effective = lint_file(path)
name = os.path.basename(path)
for level, msg in findings:
print(f"{level:5} {name}: {msg}")
if level == "ERROR":
errors += 1
else:
warns += 1
if effective:
inventory.append((name, effective))
elapsed = (time.perf_counter() - t0) * 1000
print("\n--- 権限サーフェス一覧(危険度降順) ---")
inventory.sort(key=lambda r: -RISK.get(r[1]["commandExecutionPolicy"], 1))
print(f"{'file':20} {'policy':8} {'inheritMcp':11} {'hidden':7} model")
for name, e in inventory:
print(f"{name:20} {e['commandExecutionPolicy']:8} "
f"{str(e['inheritMcp']):11} {str(e['hidden']):7} {e['model']}")
print(f"\n{len(paths)} files / ERROR {errors} / WARN {warns} / {elapsed:.1f} ms")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:] or sorted(glob.glob("agents/*.md"))))
手元の7ファイルに当ててみる
自分の agents/ ディレクトリに対して走らせた結果です。
WARN bench.md: H1 見出しがありません。システムプロンプトの区切りを確認してください
ERROR bench.md: commandExecutionPolicy=allow と inheritMcp=true の同時指定。承認なしで継承済み MCP ツールを呼び出せます
ERROR migration.md: commandExecutionPolicy: 未知の値 'allways'。許容値は ['allow', 'ask', 'deny']。既定の 'ask' へ落ちます
ERROR release-notes.md: 未知キー 'inheritMCP' -> 'inheritMcp' の綴り誤りの可能性。現状 inheritMcp=True が適用されます
ERROR release-notes.md: commandExecutionPolicy=allow と inheritMcp=true の同時指定。承認なしで継承済み MCP ツールを呼び出せます
ERROR triage.md: 未知キー 'inherit_mcp' -> 'inheritMcp' の綴り誤りの可能性。現状 inheritMcp=True が適用されます
ERROR triage.md: 未知キー 'commandexecutionpolicy' -> 'commandExecutionPolicy' の綴り誤りの可能性。現状 commandExecutionPolicy='ask' が適用されます
--- 権限サーフェス一覧(危険度降順) ---
file policy inheritMcp hidden model
bench.md allow True False flash
release-notes.md allow True False flash
doc-sync.md ask False True flash
migration.md ask True False inherit
orchestrator.md ask True False pro
reviewer.md ask True False pro
triage.md ask True False flash
7 files / ERROR 6 / WARN 1 / 3.9 ms
7ファイルで ERROR 6件。書いた本人としては、正直こたえる数字でした。
内訳を見ると、性質の違う3種類が混ざっています。
inheritMCP と inherit_mcp は大文字小文字とスネークケースの取り違えです。YAML の慣習に引きずられて inherit_mcp と書いてしまうのは、私だけではないと思います。commandexecutionpolicy は全部小文字にした例で、これも同様です。
allways は値の綴り誤りでした。キー名は正しいので、キーだけを見る検査では素通りします。許容値の集合まで持っていないと拾えません。
そして bench.md と release-notes.md の組み合わせ違反。前者は意図して allow にしたもので、後者は綴り誤りの結果として allow + 継承ありになったものです。同じメッセージが出ていても、原因はまったく違います。
この「同じ症状で原因が違う」ところが、実効値の一覧を見るまで分かりませんでした。違反リストだけを見ていた時点では、両方とも同じ問題に見えていたのです。
200ファイルまで伸ばして測る
CI に入れる以上、速度も確認しておきたいところでした。手元の7ファイルを増やして200ファイルにし、3回計測しています。
1ファイルあたりおよそ 0.5 ms。200ファイルでも 0.1 秒程度なので、pre-commit フックに入れても体感には出ません。
difflib.get_close_matches は文字列比較を繰り返すため重くなることを心配していたのですが、比較対象が既知キー6個だけなので実測では影響が見えませんでした。ファイル読み込みと YAML パースの方が支配的です。
なおこの合成コーパスは実運用の7ファイルを複製したものなので、ERROR 171件という数字自体に意味はありません。見たかったのは所要時間の伸び方だけです。実データを増やせない状況で計測する場合、こういう断りを自分用のメモにも残しておかないと、後から数字だけが一人歩きします。
しきい値は、思っていたより鈍感だった
cutoff の値は勘で 0.6 に置いていました。少し下げれば誤検出が増え、少し上げれば取りこぼす、繊細なつまみだろうと想像していたのです。
確かめるために、実際に書き間違えた/書き間違えそうなキーを16件、frontmatter に同居しうる無関係なキーを14件並べ、しきい値を振って検出率と誤検出率を測りました。
0.5 から 0.8 までは、検出率も誤検出率も一切動きません。つまみではなく平地でした。
これは予想と違いました。私は 0.8 では inherit_mcp を取りこぼすだろうと思い込んでいたのですが、実際には 0.8 でも全件拾えています。取りこぼしが始まるのは 0.85 からで、そこでも 0.9 でも、無関係キーの誤検出は 0 のままです。
そして最初に落ちるものの顔ぶれが意外でした。inheritMCP・Hidden・Model は、いずれも大文字小文字だけが違うキーです。difflib.SequenceMatcher は大小文字を区別するため、casing の違いは「1文字の差」ではなく普通の文字の不一致として数えられます。短いキーほど、その1文字が比率に効きます。
一方、0.4 に下げたときの誤検出はこうなりました。
誤検出はすべて 0.40 から 0.47 の狭い帯に収まっています。本物の綴り誤りとの間には、はっきりした谷があるということです。
結果として、この場合は 0.5 から 0.8 のどこを選んでも同じ挙動になります。0.6 を選ぶ理由は「平地の真ん中だから」で、それ以上の根拠はありません。語彙が6個しかない小ささが効いているので、キーが増えたら測り直す前提の値です。
勘で置いた値が正しかったこと自体より、勘の理由が間違っていたことの方が収穫でした。私自身、繊細なつまみだと思い込んだまま運用を続けていたら、キーを増やしたときに測り直す発想には至らなかったはずです。
運用してみて引いた線
数週間回した上で、いくつか設定を変えました。
cutoff は 0.6 のままにしました。 理由は次の節に書いたとおりで、実際に測ってみると安全な幅は思っていたより広いことが分かりました。
WARN では CI を落とさないことにしました。 未知キーの多くは、単に将来のバージョン向けの先取りです。落とし始めると、そのうち --no-verify が習慣になります。落とすのは権限キーの綴り誤り・不正な値・危険な組み合わせだけに絞りました。
DEFAULTS はコード内の定数のままにしました。 一度は設定ファイルに外出ししようとしたのですが、やめました。この定数はバージョンごとの観測結果そのもので、更新するときには必ず実際の挙動を確かめるべきものです。設定ファイルにすると、確かめずに書き換えられるようになってしまいます。定数としてコードの先頭に置き、変更時にレビューが入る状態の方が、この値の性質には合っていました。
私はこういう「更新に手間がかかる方を選ぶ」判断を好みます。頻繁に変えるものではなく、変えるときには慎重であってほしいものだからです。個人的には、権限に関わる定数はこの扱いを既定にしておくことをお勧めします。
綴り誤り以外にも効いた場面
副産物として、綴りは正しいのに意図と違っていた設定も見つかりました。
doc-sync.md の hidden: true です。一覧に出したくないという理由で設定していたのですが、実効値の一覧に並べたとき、これだけが hidden の列で浮いて見えました。改めて考えると、隠す理由は「使わないから」であって、それなら定義自体を消すか、subagent の構成を見直す方が筋が通ります。
設定を一覧で横に並べると、個別に見ていたときには気づかない不揃いが見えてきます。lint の本来の目的は違反検出ですが、実効値を必ず出力させたことで、棚卸しの道具としても使えるようになりました。
agent.md が思ったとおりに効かない症状全般については、agents.md が効いていないときの、Antigravity で起きる6つの症状と対処法に別の切り口でまとめてあります。エージェント定義そのものの書き方はカスタムエージェント作成ガイド — Antigravity で専用 AI アシスタントを構築するをご覧ください。
次にやるなら
もし同じ問題を追いかけるなら、まず自分の agent.md を全部開いて、権限に関わるキーの綴りだけを目視で確認してみてください。lint を書く前に、それだけで見つかるものがあるかもしれません。
私の場合は7ファイル中2ファイルでした。数が少ないうちは目視でも足りますが、増えると必ず取りこぼします。取りこぼしたときに何が起きるかというと、エラーではなく、静かな権限の拡張です。
構文の正しさは教えてもらえます。意味の正しさは、自分で検査を書くしかありません。設定が権限を決めている以上、そこは省略できないところだと考えています。
お読みいただきありがとうございました。