Memory toolのパストラバーサル対策 — ../secrets.envを防ぐ実装
memory toolのファイル操作はクライアント側の実装が実行します。/memories/../../secrets.envのような攻撃とURLエンコード済みの迂回を防ぐ検証を、6コマンド全部にかける実装手順です。
はじめに — パスの検証は誰の責任か
memory toolは、Claudeが生成したview・create・str_replace・insert・delete・renameというコマンドを、開発者側の実装が実際のファイル操作として実行する仕組みです。Anthropicのサーバーはファイルシステムに触れません。つまり、/memories/../../secrets.envのようなpathが渡されてきたときにそれを弾くかどうかは、実装側の責任です。公式ドキュメントも「アプリケーションがClaudeの要求するすべてのファイル操作を実行するため、こうした安全対策はあなたの責任です」と明記しています。この記事では、/memoriesの外に出るパスを拒否する検証を、6コマンドすべてに一貫してかける実装をステップで組み立てます。
長時間動くエージェントの実装パターンとして、memory toolの保存先をプロジェクトのチェックアウトと同じホスト・同じコンテナに置く構成はよく使われます。セッションをまたいで進捗ログや機能チェックリストを引き継ぐ用途では、/memoriesと同じファイルシステム上に.envファイルやAPIキーが置かれていることも珍しくありません。pathの検証を怠ると、/memoriesの外にある実在のファイルへ実際に到達できてしまう構成だということです。攻撃が机上の空論で終わらない前提を押さえたうえで、検証を組み立てます。
前提: Python 3.9以降(Path.is_relative_to()を使用)。BetaLocalFilesystemMemoryToolのような組み込みの実装を使わず、BetaAbstractMemoryToolを自前でサブクラス化してファイル操作を書くケースを想定します。自前実装が必要になるのは、ファイルをローカルディスクではなくデータベースやオブジェクトストレージに保存したい場合や、viewの結果をアプリケーション独自のログに残したい場合です。
ステップ1: すべてのパスが/memories配下にあることを確認する
最初の防御は文字列としての前方一致チェックです。commandに付随するpath(renameだけはold_pathとnew_path)が、必ず/memoriesから始まることを確認します。
MEMORY_ROOT_PREFIX = "/memories"
def check_prefix(raw_path: str) -> None:
if not raw_path.startswith(MEMORY_ROOT_PREFIX):
raise ValueError(f"Path must start with /memories: {raw_path}")これだけでは不十分です。/memories/../../secrets.envは文字列としては/memoriesで始まっているため、このチェックだけでは素通りしてしまいます。前方一致は必要条件であって十分条件ではありません。
さらに、raw_path.startswith("/memories")という書き方自体にも罠があります。これは/memoriesX/notes.txtのような、/memoriesと同じ階層にある別のディレクトリ名まで文字列として一致させてしまいます。区切りの/を含めて/memories/で始まるか、あるいはルート自体を表す/memoriesと完全一致するかを確認しないと、兄弟ディレクトリへのアクセスを誤って通してしまいます。
ステップ2: 正規化してから比較する
../のような相対参照は、文字列比較ではなく実際のパス解決を通してから判定します。Pythonのpathlib.Path.resolve()は..セグメントを含むパスを、シンボリックリンクも解決したうえで絶対パスに正規化します。正規化後のパスが、メモリのルートディレクトリの配下にとどまっているかをis_relative_to()(またはrelative_to()の例外捕捉)で確認します。
もう1つ見落としやすいのが、URLエンコードされたトラバーサル文字列です。%2e%2e%2fは../をパーセントエンコードした表現で、素の文字列チェックでは..という並びが見えないまま通過することがあります。str_replaceのold_strのような別パラメータ経由でエンコード済みの文字列が紛れ込む可能性も考えると、パス文字列は最初にurllib.parse.unquote()でデコードしてから検証にかけるのが安全です。
from pathlib import Path, PurePosixPath
from urllib.parse import unquote
MEMORY_ROOT = Path("/memories").resolve()
class PathTraversalError(ValueError):
pass
def resolve_memory_path(raw_path: str) -> Path:
# %2e%2e%2f のようなURLエンコード済みの ../ を先にデコードする
decoded = unquote(raw_path)
# デコード後に ../ や ..\ が残っていれば、その時点で拒否する
if ".." in decoded or "\\" in decoded:
raise PathTraversalError(f"Invalid path: {raw_path}")
if decoded != "/memories" and not decoded.startswith("/memories/"):
raise PathTraversalError(f"Path must start with /memories: {raw_path}")
# posix規則で正規化してから、実ファイルシステム上のパスへ解決する
relative = PurePosixPath(decoded).relative_to("/memories") # 前方一致済みなので例外は起きない
resolved = (MEMORY_ROOT / relative).resolve()
# 正規化後も /memories 配下にとどまっているかを最終確認する
if not resolved.is_relative_to(MEMORY_ROOT):
raise PathTraversalError(f"Path escapes memory root: {raw_path}")
return resolvedチェックを2段階にしているのには理由があります。デコード直後の文字列レベルの..検査は、単純なトラバーサル文字列を早期に落とすためのものです。resolve()とis_relative_to()によるチェックは、文字列検査をすり抜けた変則的な表現(冗長なスラッシュや大文字小文字の揺れなど)まで含めて、最終的な物理パスの位置で判定します。どちらか一方だけでは、想定していない表現が抜ける余地が残ります。
Path.resolve()はシンボリックリンクも解決します。これは../のような相対参照とは別の抜け道を塞ぐ副次的な効果があります。/memoriesディレクトリの中に、何らかの経緯で外部を指すシンボリックリンクが作られていた場合、そのリンク先のファイル名をpathにそのまま指定するだけなら文字列としては/memories配下に見えます。しかしresolve()はリンクをたどった実体のパスまで解決するため、is_relative_to()の判定でリンクの飛び先が検出されます。str_replaceやinsertのようにファイルの中身を書き換えるコマンドでは、このシンボリックリンク経由の抜け道も同じ検証でまとめて塞がれることになります。
ステップ3: 6コマンドすべてに検証をかける
memory toolのコマンドはview・create・str_replace・insert・delete・renameの6種類で、そのうちrenameだけはold_pathとnew_pathという2つのパスパラメータを持ちます。検証をコマンドの分岐処理の中に個別に書くと、新しいコマンドを足したときに検証を書き忘れる余地が生まれます。ディスパッチの入口で一括して検証する形にします。
def execute_memory_command(cmd: dict) -> str:
command = cmd["command"]
if command == "rename":
old_path = resolve_memory_path(cmd["old_path"])
new_path = resolve_memory_path(cmd["new_path"])
return do_rename(old_path, new_path)
path = resolve_memory_path(cmd["path"])
if command == "view":
return do_view(path, cmd.get("view_range"))
if command == "create":
return do_create(path, cmd["file_text"])
if command == "str_replace":
return do_str_replace(path, cmd["old_str"], cmd.get("new_str", ""))
if command == "insert":
return do_insert(path, cmd["insert_line"], cmd["insert_text"])
if command == "delete":
return do_delete(path)
raise ValueError(f"Unknown command: {command}")resolve_memory_pathの呼び出しをdo_viewやdo_createのような個々の実装関数の中に書くと、実装関数を1つ追加するたびに検証の呼び出し忘れが起こり得ます。ディスパッチの入口に集約しておけば、新しいコマンドを足しても検証だけは必ず通ります。renameのold_pathとnew_pathのように、1つのコマンドが複数のパスを持つ場合は両方を個別に検証します。片方だけ検証すると、検証していない側のパラメータがそのまま抜け道になります。
PathTraversalErrorを捕まえた呼び出し側は、tool_resultにis_error: trueを付けてClaudeへエラーメッセージを返します。ファイルシステムの実際のエラーと区別できるよう、エラーメッセージにはトラバーサルを検知した旨を含めておくと、後からログを追うときに原因を切り分けやすくなります。is_errorの内容次第でClaudeの立て直し方が変わる一般的な書き方はtool_resultのis_errorでエラーを正しく伝える書き方にまとめています。ここで重要なのは、トラバーサルを検知したという事実そのものはClaudeへ伝えてよい一方、実際に存在するファイルパスの一覧や内部ディレクトリ構造をエラーメッセージへ詳細に書き出さないことです。攻撃を試みた側へ、探索に使える情報を返さないためです。
攻撃パターンをテストで固定する
検証ロジックを書いたら、想定する攻撃パターンをテストケースとして残しておきます。実装を変更したときに、既知の攻撃パターンが再び通ってしまう退行を防げます。
import pytest
@pytest.mark.parametrize(
"raw_path",
[
"/memories/../../secrets.env",
"/memories/../../../etc/passwd",
"/memories/%2e%2e%2fsecrets.env",
"/memories/..\\..\\secrets.env",
"/etc/passwd",
"memories/notes.txt", # 先頭スラッシュが無い
"/memoriesX/notes.txt", # 前方一致だけだと通ってしまう兄弟ディレクトリ
],
)
def test_rejects_traversal(raw_path: str) -> None:
with pytest.raises(PathTraversalError):
resolve_memory_path(raw_path)
@pytest.mark.parametrize(
"raw_path",
[
"/memories/notes.txt",
"/memories/project/todo.md",
"/memories",
],
)
def test_accepts_valid_paths(raw_path: str) -> None:
resolved = resolve_memory_path(raw_path)
assert resolved.is_relative_to(MEMORY_ROOT)memory_20250818のようなツールバージョンが将来変わっても、この検証ロジック自体はmemory tool固有のAPI仕様に依存しない、純粋なパス処理です。実装を差し替える機会があっても、このテストケース一式は流用できます。memory toolが実装側に求めるもう1つの安全対策、PHIのような機微情報をメモリファイルへ書かせない対策についてはClaudeのstrict tool useとHIPAA/PHIで扱っている「スキーマの構造とやり取りされる値を区別する」という考え方が近い切り口になります。
TypeScriptでの実装方針
Node.js環境でも考え方は同じです。path.resolve()で正規化した絶対パスを求め、メモリのルートを表す絶対パスからの相対パスにpath.relative()で変換します。変換結果が..で始まっていたり絶対パスのままだったりする場合は、ルートの外に出ていると判定できます。
import path from "node:path";
const MEMORY_ROOT = path.resolve("/memories");
class PathTraversalError extends Error {}
function resolveMemoryPath(rawPath: string): string {
const decoded = decodeURIComponent(rawPath);
if (decoded.includes("..") || decoded.includes("\\")) {
throw new PathTraversalError(`Invalid path: ${rawPath}`);
}
if (!decoded.startsWith("/memories")) {
throw new PathTraversalError(`Path must start with /memories: ${rawPath}`);
}
const resolved = path.resolve(decoded);
const relative = path.relative(MEMORY_ROOT, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new PathTraversalError(`Path escapes memory root: ${rawPath}`);
}
return resolved;
}decodeURIComponent()はPythonのunquote()に相当し、URLエンコードされたトラバーサル文字列をデコードします。path.relative()の戻り値が..で始まるか絶対パスになっている場合、解決後のパスがメモリのルートより上の階層にあることを意味します。判定の骨格はPython版と同じで、言語ごとの標準ライブラリが提供するパス解決関数に置き換えるだけです。
ここでのdecoded.startsWith("/memories")もPython版と同じ前方一致で、/memoriesX/notes.txtのような兄弟ディレクトリの文字列自体は通してしまいます。ただしTypeScript版は最終判定をpath.relative()の戻り値で行うため、/memoriesXはMEMORY_ROOTからの相対パスが..始まりになり、この段階で拒否されます。Python版のようにrelative_to("/memories")で純粋な文字列切り出しをしてから正規化する構成だと、前方一致の緩さがそのまま素の例外として漏れてしまうため、判定条件自体を/memoriesとの完全一致または/memories/始まりに絞る必要があります。
よくあるつまずき
- デコードを1回で済ませてしまう: 二重にURLエンコードされた文字列(
%252e%252e%252f)は1回のunquote()では..まで戻りません。呼び出し元がどんな経路でパスを渡してくるか分からない前提なら、デコード後に再度..が現れないかをループで確認する、あるいはデコード回数の上限を決めておきます。 - Windows形式の区切り文字を見落とす:
..\\はPOSIX系の..検査だけでは引っかからないことがあります。デコード後の文字列に\が含まれていないかも合わせて確認します。サーバーをLinuxコンテナ上で動かしていても、この文字列自体はどのOSからでも送信できます。 - 検証をコマンドごとの実装関数の中に書いてしまう: 前述のとおり、新しいコマンドを追加するたびに検証の書き忘れが起こります。ディスパッチの入口に集約します。
/memoriesルート自体への操作を許してしまう: パストラバーサルとは別の観点として、deleteやrenameで/memoriesディレクトリそのものを対象にするリクエストは、Claudeへのツール説明では「できない」と伝えられていますが、実装側でも明示的に拒否しておくと安全です。ルートを消してしまうと、それ以降のセッションのメモリ復元が根本から壊れます。viewのディレクトリ表示だけ検証を素通りさせる:viewはファイルだけでなくディレクトリの一覧表示も返します。ファイルの読み書きコマンドだけ検証をかけてviewを例外扱いにすると、ディレクトリ一覧を通じて/memoriesの外側の構造を覗かれる経路が残ります。ステップ3のようにディスパッチの入口で一括検証していれば、この抜けは自然に防げます。
まとめ
memory toolのパス検証は、Anthropicのサーバーではなく実装側の責任です。/memoriesで始まるかの前方一致だけでは../を含むパスを止められず、URLエンコードされた%2e%2e%2fのような表現は素の文字列比較でも見逃します。安全な実装は、パスを先にデコードしてから明らかなトラバーサル文字列を弾き、Path.resolve()で正規化した後にis_relative_to()で最終的に/memories配下にとどまっているかを確認する2段構えです。resolve()はシンボリックリンクも解決するため、/memories配下に外部を指すリンクが紛れ込んでいた場合の抜け道も同じチェックで塞がれます。
この検証をview・create・str_replace・insert・delete・renameの6コマンドすべてに、できればディスパッチの入口で一括してかけておくと、コマンドを追加したときの検証漏れを防げます。renameだけはold_pathとnew_pathの2つを持つため、両方への適用を忘れないようにします。攻撃パターンをテストケースとして固定しておけば、実装を変更したときの退行にも早く気づけます。TypeScript実装でも判定の骨格は変わらず、標準ライブラリのパス解決関数に置き換えるだけで済みます。