CodeGym /コース /ChatGPT Apps /本番運用とセキュリティ: 権限、サンドボックス、シークレット、モニタリング

本番運用とセキュリティ: 権限、サンドボックス、シークレット、モニタリング

ChatGPT Apps
レベル 12 , レッスン 4
使用可能

1. なぜエージェントに「本番思考」が必要か

通常のバックエンドを書くとき、「本番に出す」という発想だけで自動的にパラノイア・モードが入ります。すなわち、認可、ログ、エラー処理、リミット、シークレットはコードではなく .env に置く、といったことです。

エージェントでも同じモードを、むしろより厳格に適用する必要があります。理由は単純で、通常のバックエンドはあなたが書いたとおりに動きますが、エージェントは与えられたツールと指示の範囲で、モデルが自ら判断して実行します。クラシックなコード以上に制御できているように錯覚しがちですが、実際にあなたが制御できるのは環境と利用可能なアクションだけで、モデルの思考すべてではありません。

そこで本講義では、エージェントを段階的に「防御の層」で囲っていきます。

  • まず、できることを制限する(ツールの権限とエージェントの分離)、
  • 次に、実行環境を分離する(サンドボックスと各種リミット)、
  • シークレットと PII を適切に扱う、
  • 最後に、観測可能性を有効化する(ログ、メトリクス、基本的なトレーシング)。

具体的にするため、GiftGenius の例を継続します。これはギフト選びを支援し、少しだけコマースの世界(注文とチェックアウト)に足を踏み入れるエージェントです(ACP の詳細は後で扱います)。

2. 権限: エージェントに「世界中のすべてのボタン」は不要

最小権限の原則 (Least Privilege)

第一のルールは「エージェントは何でもできる必要はない」です。ツールが増えるほど、「不適切な関数を不適切なタイミングで」呼ぶ確率が高まります。何でも読み書きできる巨大な manageEverything() を作るのではなく、少なくとも読み取りと書き込みで分離された、小さく明確な関数群を設計します。

GiftGenius では特に明確です。ギフト一覧やユーザーの嗜好を読むことと、注文を作成・確定すること(=お金が関わる)は別物です。したがって一般的には次のように分けます。

  • 安全な「read-only」ツール群(ギフト検索、詳細表示)、
  • 別の「write」ツール群(注文ドラフトの作成、注文のキャンセル)、
  • 必要ならさらに危険度の高い操作のレイヤー(支払いの確定、大量更新)。

用途ごとにエージェントを分ける

強力な手法のひとつは、責務の領域ごとにエージェントを分離することです。ひとつは「ギフト選定」、もうひとつは「注文管理」。こうしておけば、ギフト用エージェントのモデルが多少暴走しても、支払い用ツールは構成に存在しないため物理的に呼び出せません。

エージェントとツールの最小構成タイプを仮定してみます。

// アイデアを説明するための簡略化した型
type ToolName = 'suggest_gifts' | 'get_gift_details' |
  'create_order_draft' | 'confirm_order';

type AgentConfig = {
  id: string;
  allowedTools: ToolName[];
  maxSteps: number;
};

では、GiftGenius の 2 つのエージェントを記述します。

export const giftPlannerAgent: AgentConfig = {
  id: 'gift-planner',
  allowedTools: ['suggest_gifts', 'get_gift_details'],
  maxSteps: 6,
};

export const orderAgent: AgentConfig = {
  id: 'order-manager',
  allowedTools: ['create_order_draft', 'confirm_order'],
  maxSteps: 4,
};

これは抽象的な例ですが、要点は簡単です。コードに 4 つのツールが存在しても、各エージェントには必要な部分集合だけを与えます。

権限をユーザーとロールに結びつける

ここでは 2 種類の主体を忘れないでください。

  • ユーザーとその権限(この user_id は購入・キャンセル・履歴閲覧が可能か)、
  • エージェントとその許可されたツール。

理想的には、各ツール呼び出しが 2 つのチェックを通過すべきです。「エージェントに許可されているか?」と「ユーザーにも許可されているか?」。

擬似コードで言えば:

type UserRole = 'guest' | 'customer' | 'admin';

function canUserCallTool(role: UserRole, tool: ToolName): boolean {
  if (tool === 'confirm_order') {
    return role === 'customer' || role === 'admin';
  }
  if (tool === 'create_order_draft') {
    return role !== 'guest';
  }
  return true; // 読み取りは全員に許可
}

MCP/バックエンド側での tool 呼び出し処理では、二重チェックを行えます。

function assertToolAllowed(
  agent: AgentConfig,
  userRole: UserRole,
  tool: ToolName,
) {
  if (!agent.allowedTools.includes(tool)) {
    throw new Error(`Tool ${tool} はエージェント ${agent.id} には許可されていません`);
  }
  if (!canUserCallTool(userRole, tool)) {
    throw new Error(`ロール ${userRole} のユーザーは ${tool} を呼び出すことはできません`);
  }
}

結果として、もしモデルが不適切なエージェントやゲスト権限で confirm_order を呼ぼうとしても、このチェックで止まり、計画外の支払いではなく、制御可能なエラーになります。

環境ごとの異なる構成

dev や staging の環境では、エージェントに自由度を持たせたいことが多いでしょう。テスト用ツール、フェイクの決済サービス、実験的機能など。逆に production では構成をできる限り厳格にします。いくつかのツールは無効化、エンドポイントは本番のみ、トークンも実運用のみ。

最も簡単なスキーム:

type Env = 'dev' | 'staging' | 'production';

const env = (process.env.APP_ENV as Env) ?? 'dev';

const orderAgentByEnv: Record<Env, AgentConfig> = {
  dev: {
    id: 'order-manager-dev',
    allowedTools: ['create_order_draft', 'confirm_order'],
    maxSteps: 8,
  },
  staging: {
    id: 'order-manager-staging',
    allowedTools: ['create_order_draft', 'confirm_order'],
    maxSteps: 6,
  },
  production: {
    id: 'order-manager-prod',
    allowedTools: ['create_order_draft'], // confirm は別経路のみ
    maxSteps: 4,
  },
};

export const currentOrderAgent = orderAgentByEnv[env];

本番では confirm_order を、ウィジェットでの明示的な「注文を確定」クリックと追加チェックの後にのみ呼び出す、独立した「危険」エージェントに切り出しておくのもありです。

3. サンドボックス: エージェントにあなたの世界への root 権限は不要

分離のレベル

エージェントとユーザーに権限を付与したら、次はサンドボックスと実行環境の分離という防御レイヤーに進みます。

エージェントやそのツール向けのサンドボックスは、大まかに次のレベルに分けられます。

  1. ツールコードのレベル。 ファイルシステム、ネットワーク、プロセス資源へのアクセスを制限します。どこにでも書けない、任意ドメインへ出られない、CPU を回し続けたり大量メモリを消費できないようにします。
  2. Agents SDK のレベル。 run サイクルのステップ数、tool 呼び出し回数、コンテキストサイズ(トークン上限)に制限を加えます。モデルは無限に「考えたり」tool-calls を量産できません。いずれかの時点で「ステップ上限」や「時間上限」に達して終了します。

これらはクラシックな「防御的アーキテクチャ」に集約され、次のような図で捉えると理解しやすいでしょう。

graph TD
    A[プロンプト / system 指示] --> B[ツールの JSON Schema]
    B --> C[エージェントとユーザーのパーミッション]
    C --> D[インフラのサンドボックス]
    D --> E[外部サービス / DB]

    subgraph エージェント
      A
      B
      C
    end

    subgraph インフラストラクチャ
      D
    end

プロンプトは最も弱い防御です。本当の強さは、コードが何をでき、どの API にアクセスできるかを物理的に制限するところから始まります。

run サイクルのリミット: ステップ、時間、tool-calls

サンドボックスの一部は、エージェントの構成で直接表現できます。最大ステップ数、総実行時間、tool 呼び出し回数の上限などです。これは暴走防止だけでなく、コスト管理にもなります。

run オプションの抽象的な設定例:

type RunLimits = {
  maxSteps: number;
  maxToolCalls: number;
  timeoutMs: number;
};

const defaultLimits: RunLimits = {
  maxSteps: 8,
  maxToolCalls: 10,
  timeoutMs: 30_000,
};

こうしたリミットを、エージェントを起動するラッパーに渡します。もしモデルが 11 回目のツール呼び出しを試みたら、エージェントの実行を中断し、タスクが複雑すぎるとユーザーに正直に伝えます。エージェントに無制限に予算を燃やさせるのではありません。

コードとネットワークの分離

コンテナ/プロセスレベルでの一般的なプラクティスは次のとおりです。

MCP サーバーやエージェントサービスのコードは、読み取り専用ファイルシステム(専用の作業ディレクトリを除く)と制限されたリソース(CPU、RAM)を持つコンテナで起動します。ネットワークは allow-list で構成し、必要な外部サービス(自社コマースのバックエンド、決済、一部の外部 API)にのみアクセスし、任意のインターネットへは出られないようにします。

エージェントのシナリオでは特に重要です。モデルが「場違いな」API に出ようとしたり、想定外のファイルを読もうとしても、余計なリソースに物理的に届かないようにしておくべきです。

コード上では TypeScript の「魔法の一行」というより、オーケストレーター(Docker Compose、Kubernetes、Vercel、Fly.io など)の設定として表れます。ただし、設計段階から次の点を意識しておくと良いでしょう。

  • 外部コードを実行するツール(例: シェルコマンドでレポート生成)は、厳格に分離された別環境で動かす。
  • ツールが他人のファイル、シークレット、設定を読めないようにする。
  • ネットワークアクセスはドメインや IP で明示的に制限する。

4. シークレットと機微情報: エージェントが知る必要のないこと

シークレットを置く場所/置かない場所

基本ルール: API キー、パスワード、アクセストークンといったシークレットは、モデルのプロンプト、ウィジェット、ログ、リポジトリに決して入れてはいけません。置き場所は次のとおりです。

  • 環境変数(process.env.SOMETHING)、
  • シークレットマネージャ(AWS Secrets Manager、GCP Secret Manager、Vault など)、
  • アクセスが厳格に管理された暗号化ストア。

たとえば GiftGenius には、店舗のコマース API のキーがあります。エージェントが MCP ツール経由で注文ドラフトを作成できるようにしたいが、モデル自身はキーを見られないようにします。

// mcp/tools/createOrderDraft.ts
const COMMERCE_API_KEY = process.env.COMMERCE_API_KEY!;

export async function createOrderDraft(args: {
  userId: string;
  giftId: string;
  quantity: number;
}) {
  // モデルが COMMERCE_API_KEY を目にすることはありません — ここサーバー側だけにあります
  const res = await fetch(`${process.env.COMMERCE_API_URL}/orders/draft`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${COMMERCE_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(args),
  });

  if (!res.ok) {
    throw new Error(`Commerce API returned ${res.status}`);
  }

  return res.json(); // エージェントには安全なオブジェクトだけを返します
}

重要: ツールのレスポンスでキーや他の機微な詳細を「通過」させてはいけません。エージェントに必要なのは draftOrderId、明細、必要ならステータス程度です。

PII とコンテキストの最小化

シークレット以外にも、PII(個人情報)というカテゴリがあります。氏名、電話番号、配送先住所、メールなど。エージェントには、こうした「生の」テキストが常に必要なわけではありません。十分なのは「ボードゲームが好き」「年齢 30–35」「予算は 50〜70 ドル」などの構造化されたプロフィールです。

ユーザーの注文履歴全文をプロンプトに入れる代わりに、集約・匿名化済みのプロフィールを返すツール get_user_profile_summary を用意できます。

type ProfileSummary = {
  ageRange: '18-25' | '26-35' | '36-50' | '50+';
  interests: string[];
  preferredBudget: { min: number; max: number };
};

export async function getUserProfileSummary(userId: string): Promise<ProfileSummary> {
  // DB にはアクセスするが、外部へは集約情報だけを返す
  return {
    ageRange: '26-35',
    interests: ['ボードゲーム', 'ガジェット'],
    preferredBudget: { min: 30, max: 80 },
  };
}

モデルは、ギフト選定に必要なだけの情報を見ますが、それ以上は見ません。

ログのスクラビング

ログは、シークレットや PII がうっかり露出しやすい場所です。特に「便利」だからと console.log(...) で何でもかんでも出力してしまう場合に起こります。

良いアプローチは、出力前にペイロードを走査して機微なフィールドをマスキングする中央ロガーを用意することです。

type LogPayload = Record<string, unknown>;

const SENSITIVE_KEYS = ['email', 'phone', 'cardNumber', 'token'];

function scrub(payload: LogPayload): LogPayload {
  const result: LogPayload = {};
  for (const [key, value] of Object.entries(payload)) {
    if (SENSITIVE_KEYS.includes(key)) {
      result[key] = '***redacted***';
    } else {
      result[key] = value;
    }
  }
  return result;
}

export function logEvent(event: string, payload: LogPayload) {
  const safe = scrub(payload);
  console.log(JSON.stringify({ event, ...safe }));
}

これで、「気づけば 6 か月間、電話番号やトークンを本番ログに書いていた」といった事故を防げます。これは単なる配慮の問題ではなく、将来的なコンプライアンス(GDPR や各国法)対応のためでもあります。PII がログに少なければ少ないほど、プロダクト運用は楽になります。

5. エージェントのモニタリングと観測可能性

何を可視化すべきか

エージェントができること、見えるデータ、ログに残すものを制限しました。次の問いは「本番でエージェントが設計どおりに振る舞っていると、どうやって判断するか」です。

「プロセスが生きているか/死んでいるか」という通常の監視は、エージェントにはほぼ役に立ちません。重要なのは、プロセスの稼働可否だけでなく、その振る舞いです。どんなステップを踏んだか、どのツールを何回呼んだか、どこで失敗したか、どこでループしたかを把握する必要があります。

各 run の最小データセット:

  • agent_run_id — 実行の一意な識別子
  • 匿名化された user_id またはセッション ID
  • エージェント名と環境
  • 呼び出したツール一覧:名前、回数、合計時間
  • ワークフローのステップと停止したステップ
  • 最終ステータス: success, partial_success, failed, canceled, timeout, limits_exceeded

これを構造として表すと次のようになります。

type RunStatus =
  | 'success'
  | 'partial_success'
  | 'failed'
  | 'canceled'
  | 'timeout'
  | 'limits_exceeded';

type ToolCallLog = {
  name: ToolName;
  durationMs: number;
  success: boolean;
};

type AgentRunLog = {
  runId: string;
  agentId: string;
  userId: string;
  env: Env;
  startedAt: string;
  finishedAt: string;
  status: RunStatus;
  toolCalls: ToolCallLog[];
  errorMessage?: string;
};

エージェント起動の「ラッパー」例

実際の Agents SDK 呼び出しをカプセル化した runAgent という関数があると仮定します。これをモニタリングでラップしましょう。

async function runAgentWithLogging(
  agent: AgentConfig,
  input: string,
  userId: string,
): Promise<string> {
  const runId = crypto.randomUUID();
  const startedAt = new Date();

  const toolCalls: ToolCallLog[] = [];

  try {
    const result = await runAgent(agent, input, {
      userId,
      limits: defaultLimits,
      onToolCall: (name, durationMs, success) => {
        toolCalls.push({ name, durationMs, success });
      },
    });

    const finishedAt = new Date();

    const log: AgentRunLog = {
      runId,
      agentId: agent.id,
      userId,
      env,
      startedAt: startedAt.toISOString(),
      finishedAt: finishedAt.toISOString(),
      status: 'success',
      toolCalls,
    };

    logEvent('agent_run', log);
    return result;
  } catch (err) {
    const finishedAt = new Date();
    const log: AgentRunLog = {
      runId,
      agentId: agent.id,
      userId,
      env,
      startedAt: startedAt.toISOString(),
      finishedAt: finishedAt.toISOString(),
      status: 'failed',
      toolCalls,
      errorMessage: (err as Error).message,
    };
    logEvent('agent_run', log);
    throw err;
  }
}

ここでの runAgent はブラックボックスで、実装は任意の Agents SDK で構いません。重要なのは、特定の API に依存せずに観測可能性を追加する方法を示している点です。

ログ vs メトリクス vs トレーシング

観測可能性は次の 3 レベルで捉えると便利です。

レベル 何か GiftGenius における例
ログ 特定の run に関する「ストーリー」 ステップやツールを含む詳細な AgentRunLog
メトリクス 集計された数値指標 run の p95 所要時間、平均 tool-calls 回数、エラー率
トレーシング リクエストとサブリクエストの木/グラフ Run → ステップ → tool-calls → 外部 API(コマース、DB など)呼び出し

メトリクスは「全体として問題ないか?」(例: 直近 1 時間のエラー率)を判断するために使います。ログとトレーシングは「なぜここで問題が起きたか?」を解明し、特定の問題 run を再現するために使います。

メトリクスの簡易版はログの上に構築できます。定期タスクで agent_run イベントを集計し、p95 所要時間やエラー件数などを算出します。

6. GiftGenius における全体像

抽象論の羅列に見えないように、学習用アプリの全体像を組み立てます。

エージェント gift-planner は本番環境で安全なツールのみを持ちます。ギフトの選定と詳細取得です。決済や注文管理は見えません。system 指示では、ユーザーに「こちらで全額決済します」などと約束せず、最大でもおすすめを用意し、必要ならギフトのドラフトリストを作るだけだと述べます。

エージェント order-manager は別個に存在し、注文のみを扱います。本番では注文ドラフトの作成(create_order_draft)のみ可能で、注文の確定(confirm_order)はウィジェットの明示的な UI トリガーを通じて人間が行うか、dev/staging のみで可能にします。ツールは店舗の API キーなどのシークレットをバックエンド側だけで使用し、レスポンスでは必要な項目だけをプロキシします。

両エージェントは runAgentWithLogging で起動され、リミットを適用し、agent_run_iduserId、環境、ツール一覧をログに記録します。ログにメールや電話番号は含まれません。これらのフィールドはスクラバーで事前にマスクされます。ユーザープロファイルは匿名化された形で利用します。年齢帯、興味、予算などであり、購買履歴の全文ではありません。

MCP サーバーとエージェントサービスが動作するインフラは隔離されています。読み取り専用ファイルシステムのコンテナ(/tmp や専用ディレクトリを除く)、CPU/RAM の制限、ドメインの allow-list によるネットワーク制御です。エージェントが「場違いな」先へアクセスしようとしても、物理的に到達できません。

もしある時点で、ステータス limits_exceeded の run の割合や「平均 tool-calls が 10 を超える」といったメトリクスのスパイクが見えたら、プロンプトが冗長になっているか、あるいはどこかのツールが不調でステップのやり直しを誘発している可能性を疑います。

これは「どうにか動けばいい」という実験的エージェントではなく、成熟したサービスのふるまいです。

7. エージェントを本番投入する際の典型的な落とし穴

ここまで述べたのは「正しい」本番エージェントの姿です。しかし実際には、よくある落とし穴にハマりがちです。以下にまとめます。少なくともこれらを避ければ、本番リリースはずっと穏やかになります。

落とし穴 1: エージェントに「何でも許可」してしまう。
よくあるのは、検索・変更・削除・決済などの MCP ツールを多数用意し、エージェント作成時にその全リストを丸ごと渡してしまうケースです。その結果、本来読み取りだけでよかった場面で、誤って削除や決済を呼ぶ可能性が生まれます。ロールごとにツールを分割し、複数のスコープの狭いエージェントを作成して、それぞれに固有の allowedTools を与えることで対処します。

落とし穴 2: 権限チェックをプロンプトだけに頼る。
system 指示に「ユーザーの確認なしに何も購入しないこと」と書いて安心してしまうことがあります。しかしプロンプトは弱い防御であり、jailbreak や単純ミスは起こり得ます。バックエンドレベルでの実チェックが必要です。「エージェントにそのツールが許可されているか」「ユーザーにも許可されているか」。これがないと、たった一回の不注意な生成で誰も想定しない行動につながり得ます。

落とし穴 3: シークレットをプロンプトやログに入れてしまう。
「統合作業を早く終えたい」という理由で、API キーを system プロンプトに埋め込んだり、ツール引数に渡してエージェント自身に外部 API へアクセスさせることがあります。結果として、キーはモデルのログや外部システムに残り得ます。これは漏えいやストアの BAN に直結します。シークレットはサーバー側だけに置く(環境変数やシークレットマネージャ)べきで、モデルのコンテキストに決して入れてはいけません。

落とし穴 4: スクラビングなしの「生ログ」。
デバッグ中は console.log(...) を書いてそのままにしがちです。数か月後、ログにユーザー住所、電話番号、PII を含む注文番号が並んでいることが発覚、というのはよくある話です。特に GDPR などの規制の世界では厄介です。最初から中央ロガーを用意し、機微フィールドの自動マスキングを導入しましょう。「dev でしかログしないから大丈夫」と思っていてもです。

落とし穴 5: エージェントのふるまいに上限がない。
ステップ、時間、tool 呼び出し回数の制限がなければ、エージェントはループします。同じツールを何度も呼ぶ、同じエラーを延々と直そうとする、大量のトークンを消費し、外部 API を負荷であふれさせます。良くてもモデル利用の請求が膨大になり、悪ければバックエンドが落ち、ユーザーが不満を募らせます。run サイクルのリミットと、タイムアウトの sane defaults は必須です。

落とし穴 6: read と write を 1 つのツールに混在させる。
便利さを理由に getOrCreateOrder のようなメソッドを作り、存在しなければ新規作成する、というパターンがあります。古典的なバックエンドでは許容されることもありますが、エージェントの世界では予期せぬ副作用につながります。モデルは状態を知りたかっただけなのに、ツールが何かを作ってしまう、といった具合です。get_order_detailscreate_order_draft を分けるほうがずっと安全で、リトライ時の影響も制御しやすくなります。

落とし穴 7: 観測可能性を無視する。
「ログやメトリクスは後で付ける。まず動けばいい」と始めるチームは多いものです。しかし観測機構のないエージェントはブラックボックスです。どのツールを呼んでいるか、ステップ数、失敗箇所が分かりません。ユーザーからの苦情が、真っ暗な部屋の捜査に変わります。最初からログ構造(agent_run_id、ツール、ステータス)と基本メトリクスを設計しておくほうが、後でカオスなコードの上に積み増すよりはるかに楽です。

1
アンケート/クイズ
エージェントのオーケストレーション、レベル 12、レッスン 4
使用不可
エージェントのオーケストレーション
Agents SDK を用いたエージェントのオーケストレーション
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION