0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claudeに「直しました」と言われてファイルが壊れていた — MCPサーバーの読み取り専用モードと絶対パスの完全実装

0
Posted at

「確認して修正しておきました」——Claudeのこの報告を信用して数日後にファイルを開いたら、直したはずの設定ファイルが上書きされていました。原因は、自作MCPサーバーの書き込み系ツールをLLMが別用途で呼べてしまう素通しだったことです。

この記事では、自作MCPサーバーに最低限の保護(読み取り専用モード / 書き込み先の絶対パス指定 / sudo禁止)を入れる完全なコードと、実装中に引っかかった5つの落とし穴を書きます。すべて実際に起きた事故です。

結論: 保護の置き場はプロンプトでなく「ツール定義」

LLMは渡されたツールを都合よく使います。「書き込みしないで」とプロンプトで頼んでも、書き込み系ツールがツール一覧に載っている状態では実行を止められません。保護は3層に分けて入れます。

レイヤー 実装 役目
ツール定義 read_onlyの時は書き込み系ツールを定義しない LLMは存在しない道具を呼べない(第1防御)
絶対パス 書き込み系ツールに許可パス以外を拒否するガードを入れる 許可域外の書き込みは必ず失敗(第2防御)
OS層 サーバー起動はsudoでなく一般ユーザーで 万一突破されても被害が当該ユーザー権限で止まる(最後の城)

「書かないでね」はお願いであって制御ではない、というのが今回の教訓でした。

落とし穴1: startswithのままだと親ディレクトリ逃げで抜けられる

最初に書いたガードは、path.startswith("/home/user/projects/app") の一択だった。これに /home/user/projects/app/../secrets を渡すと、先頭一致で通ってしまいます。判定の直前に os.path.realpathで正規化し、「許可ルートと同一か、その直下であること」を見るようにする必要があります。

落とし穴2: 許可リストの末尾スラッシュで別ディレクトリが一致する

許可リストに /tmp/app/ と書き、入力に /tmp/app_backup/x.txt が来ると、素のstartswithでは /tmp/app が先頭一致して通ってしまいます。比較の際にルート側の末尾に os.sep を足して「ディレクトリ境界での一致」に統一する必要があります(以下のコードに入れてあります)。

完全実装: ガード付きツール層(このまま動く)

# safe_mcp_tools.py -- MCPサーバーのツール層に入れる最小ガード
# 単体でも検証できるようにmain付き(python3 safe_mcp_tools.py)
import os

class ToolDenied(Exception):
    pass

def _allow_roots() -> list:
    raw = os.environ.get("MCP_ALLOW_ROOTS", "")
    return [r for r in raw.split(":") if r]

def assert_writable(path: str) -> str:
    """書き込みの直前に必ず通す。許可域外ならToolDenied。"""
    real = os.path.realpath(path)
    for root in _allow_roots():
        root = os.path.realpath(root)
        # ルート側は末尾にos.sepを足して「境界一致」に統一する
        if real == root or real.startswith(root + os.sep):
            return real
    raise ToolDenied("path outside allow-roots: " + real)

def read_file(path: str) -> str:
    """読み取り用ツール(READ_ONLYでも提示してよい)。"""
    with open(path, "r", encoding="utf-8") as f:
        return f.read()

def write_file(path: str, text: str) -> str:
    """書き込み用ツール。READ_ONLYの際はツールごと存在しない。"""
    real = assert_writable(path)
    with open(real, "w", encoding="utf-8") as f:
        f.write(text)
    return "wrote %d chars to %s" % (len(text), real)

def build_tools() -> dict:
    """LLMに提示するツール一覧。READ_ONLYの場合は書き込み系を除外。"""
    tools = {"read_file": read_file}
    if os.environ.get("MCP_READ_ONLY", "1") != "1":
        tools["write_file"] = write_file
    return tools

if __name__ == "__main__":
    import tempfile
    demo_root = tempfile.mkdtemp(prefix="mcp_demo_")
    os.environ["MCP_ALLOW_ROOTS"] = demo_root
    os.environ["MCP_READ_ONLY"] = "0"
    print("1) normal write:", write_file(demo_root + "/a.txt", "hello"))
    try:
        write_file(demo_root + "/../evil.txt", "boom")
    except ToolDenied as e:
        print("2) traversal blocked OK:", e)
    os.environ["MCP_READ_ONLY"] = "1"
    print("3) tools in READ_ONLY:", list(build_tools().keys()))

デモの期待出力は次の3行です。

1) normal write: wrote 5 chars to /tmp/mcp_demo_xxxx/a.txt
2) traversal blocked OK: path outside allow-roots: /tmp/evil.txt
3) tools in READ_ONLY: ['read_file']

3)が本質です。READ_ONLYフラグが立っていると、write_file の実体がLLMから見えなくなります。許可された書き込み自体は行えてしまうが、LLMへの提示自体がないため、LLMはその経路を選べません。

落とし穴3~5: 実装後に気づいて直したこと

残り3つは、事故が起きる前には気づけなかった地雷です。

起きた問題 直したこと 役目
ガードの拒否まででサーバー全体が落ちる 拒否はMCPの応答にisError=Trueで返す AIが失敗を認識して別方法を採る。セッションごと死なない
ツールの説明が素っ気なうちでAIが無駄な書き込みを試す 説明に「許可以外は必ず拒否」を明記 空振り呼び出し(トークン無駄)が減る
書き込み事故後に気づかない 書き込み前のpathとバックアップ先をstderrに出力 上書きされても切り戻せる。出力しておかないと切り戻し性がない
sudo(権限昇格)を要求するツールが存在する 昇格を削除し、必要な作業は人間が実行 1回昇格されるとガード全体の意味が消える

まとめ

  • 保護はプロンプトでなくツール定義に置く。READ_ONLYで書き込み系を除外するのが第1防御
  • 書き込みツールを提供する場合は、realpath正規化+絶対パス指定を入れる(startswith単独は抜けられる)
  • 拒否は isError=True で返す(サーバーを落とさない)
  • sudoでの起動は禁止。MCPだけでなく、LLMに実行権限を渡す全ての場面で同じことが言える

次のアクションは、自分のMCPサーバーで build_tools() 相当を呼んで「READ_ONLYで書き込み系が除外されているか目視」することです。書き込み系が残っていた場合は、今回の assert_writable を必ず入れてください(上書き事故は、書き込むつもりのない呼び出しから起きました)。

読者特典(無料)

本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。

🎁 読者特典を受け取る(無料・メール登録)

参考書籍: MCP実践入門 — AIエージェントを拡張するModel Context Protocol(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?