deepseek.ac日本語で学ぶ、実践する

security

モデルに関数を実行させない — DeepSeek APIツール呼び出しの安全な実装

· 約14分

リード

ツール呼び出し(Function Calling)は「モデルが関数を実行する機能」ではありません。モデルが返すのは呼び出しの提案であり、実行するかどうかを決めるのはあなたのコードです。 この記事では、提案を検証してから実行する最小構成のゲート実装と、Thinking Mode 併用時の落とし穴を扱います。

前提

本文

モデルが返すのは「提案」であって「実行」ではない

API のレスポンスに含まれる tool_calls は、構造化されたテキストです。関数名と引数(JSON 文字列)が入っているだけで、何も実行されていません。危険なのはその後です。

# アンチパターン: モデルの出力をそのまま実行経路に流す
for call in resp.choices[0].message.tool_calls:
    fn = globals()[call.function.name]          # 名前で関数を解決
    fn(**json.loads(call.function.arguments))   # 引数を検証せず展開

この形が危険な理由は3つあります。

「モデルに実行させる」ではなく「モデルの提案を、ホストが審査して実行する」に反転させるのが出発点です。

モデル出力はテキスト。DSML を正規表現で拾って実行しない

DeepSeek のモデルは内部的に DSML(DeepSeek Markup Language)と呼ばれるマークアップでツール呼び出しを表現している、という報告があります(V3/R1 系と V4 系でタグ形状が異なるという第三者の解析があります。公式仕様は要確認)。

ここで押さえるべきは形状の細部ではなく、モデルの生テキストにはツール呼び出しっぽい文字列が現れうるという事実です。すると次の実装がアンチパターンになります。

# アンチパターン: 本文テキストからツール呼び出しを正規表現で抽出して実行
m = re.search(r"<\|.*?invoke name=\"(\w+)\".*?>(.*?)<\|", text, re.S)
if m:
    run_tool(m.group(1), m.group(2))

この方式だと、RAG で取り込んだ Web ページや Issue 本文、PDF の中に DSML 風の文字列が混ざっていた場合、モデルが意図していない呼び出しをホストが勝手に実行します。間接プロンプトインジェクションの典型的な入口です。

対策はシンプルです。

実行ゲートを1か所に集約する

ツール実行の入り口を1関数に閉じ込めます。ポリシー判断・検証・監査をここに集めると、レビューすべき場所が1点になります。

from dataclasses import dataclass
from typing import Any, Callable
import json
import jsonschema

@dataclass(frozen=True)
class Tool:
    name: str
    schema: dict
    handler: Callable[[dict], Any]
    risk: str  # "read" | "write" | "destructive"

TOOLS: dict[str, Tool] = {}  # 起動時に明示登録する(動的解決はしない)


def execute_tool_call(call, *, approve=None, audit=print):
    name = call.function.name
    tool = TOOLS.get(name)
    if tool is None:
        return {"ok": False, "error": "unknown_tool"}

    try:
        args = json.loads(call.function.arguments or "{}")
    except json.JSONDecodeError:
        return {"ok": False, "error": "invalid_json_arguments"}
    if not isinstance(args, dict):
        return {"ok": False, "error": "arguments_must_be_object"}

    try:
        jsonschema.validate(args, tool.schema)
    except jsonschema.ValidationError:
        return {"ok": False, "error": "schema_validation_failed"}

    # read 以外は人間かポリシーエンジンの承認を必須にする
    if tool.risk != "read":
        if approve is None or not approve(tool, args):
            return {"ok": False, "error": "denied_by_policy"}

    audit({"tool": name, "args": args, "risk": tool.risk})
    try:
        return {"ok": True, "result": tool.handler(args)}
    except TimeoutError:
        return {"ok": False, "error": "timeout"}
    except Exception as e:
        # 例外メッセージをそのまま返さない(内部パス・認証情報の漏洩防止)
        return {"ok": False, "error": type(e).__name__}

設計上のポイントは4つです。

  1. ホワイトリスト方式: TOOLS に登録したものだけ実行できる。globals() も getattr(module, name) も使わない
  2. risk による段階分け: 参照系は自動、書き込み・破壊系は承認必須
  3. 承認は単調(monotonic)に: 後段のフックで deny が allow に上書きされない構造にする。プラグイン的に拡張するなら「deny を返したら確定」を保証する
  4. エラーは抽象化: 例外文字列をモデルに返すと、それが次のプロンプトに入り内部情報が漏れる

引数はスキーマで縛り、危険な引数は型で潰す

JSON Schema は「形」しか守れません。値の危険性はハンドラ側で潰します。

import subprocess

def run_tests(args: dict) -> str:
    target = args["target"]  # schema で enum: ["unit", "integration"]
    if target not in {"unit", "integration"}:
        raise ValueError("unsupported target")
    proc = subprocess.run(
        ["pytest", "-q", f"tests/{target}"],
        shell=False,             # シェル解釈をさせない
        capture_output=True,
        text=True,
        timeout=120,
        check=False,
        env={"PATH": "/usr/local/bin:/usr/bin:/bin"},  # 環境変数を絞る
    )
    return proc.stdout[-4000:]   # 出力は切り詰める

外部通信を伴うツール(fetch_url など)は、SSRF 対策が必須です。

「モデルが生成した URL をそのまま requests に渡す」は、クラウドのメタデータエンドポイントに到達しうる経路です。

ツール結果も untrusted input として扱う

見落としやすいのが往復の後半です。ツールが返した文字列は、次のターンでモデルのコンテキストに入ります。つまりツール結果は新しい入力です。

実行ループには上限を必ず入れます。

MAX_TURNS = 8

for turn in range(MAX_TURNS):
    resp = client.chat.completions.create(...)
    msg = resp.choices[0].message
    if not msg.tool_calls:
        break
    for call in msg.tool_calls:
        result = execute_tool_call(call, approve=ask_human)
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(result, ensure_ascii=False)[:4000],
        })
else:
    # 上限到達は異常系として扱う(無限ループ=コスト爆発)
    raise RuntimeError("tool loop exceeded MAX_TURNS")

Thinking Mode と併用するときの落とし穴

推論(Thinking)を有効にしたままツールを使うと、通常モードとは挙動が変わります。第三者の実装報告では以下が挙げられています(いずれも公式ドキュメントで要確認)。

論点 報告されている挙動 実装側の対応
tool_choice Thinking 時は required / 名前指定を受け付けない パラメータを送らず自動選択に任せる
reasoning_content tools を含むリクエストでは全ターンで保持が必要。欠落すると HTTP 400 履歴に assistant メッセージを丸ごと積む
content ツール呼び出し時の assistant メッセージで null になりうる 空文字に正規化して送る

履歴の積み方の例です。

messages.append({
    "role": "assistant",
    "content": msg.content or "",                      # null → ""
    "tool_calls": msg.tool_calls,
    "reasoning_content": getattr(msg, "reasoning_content", "") or "",
})

reasoning_content は実行判断には使わないでください。推論過程はモデル内部の思考であり、ここに現れた文字列を根拠に権限を昇格させたり、承認をスキップしたりしてはいけません。あくまで API の整合性維持のために往復させるフィールドです。また、ユーザーにそのまま見せる設計も避けたほうが無難です(内部の前提やシステムプロンプトの断片が混ざりうるため)。

監査ログとキルスイッチを最初に作る

ツール呼び出しは「動くもの」を先に作りがちですが、事故対応のコストはログの有無で桁が変わります。

つまずきポイント

症状 対処
tools 付きリクエストで HTTP 400 になる(Thinking 時) reasoning_content を全ターン分保持しているか確認。assistant メッセージを自前で組み立て直していないか見る
Thinking 時に tool_choice でエラー 送らない。自動選択に任せる(仕様は公式で要確認)
モデルが同じツールを呼び続ける MAX_TURNS で打ち切る。結果にエラーを返し続けていないか確認
arguments の JSON パースに失敗する 空文字を {} に正規化。スキーマ違反はモデルに返して再試行させる(上限付き)
ツール結果を返したら挙動がおかしくなった 結果が大きすぎないか、外部テキストをそのまま入れていないか確認
本番で意図しない外部通信が出た URL 引数ツールの allowlist と SSRF 検査を最優先で入れる

まとめ

チェックリスト