Anthropic unintended actions入門——AIエージェントの越境をFirestoreで止める
Anthropicの意図しないモデル行動レポートを題材に、AIエージェントの外部操作、承認境界、監査ログをNext.js + Firestoreで設計する方法を解説します。

はじめに
AIエージェントにブラウザ操作、フォーム入力、外部API呼び出しを任せると、開発チームの生産性は上がります。一方で、次のような不安も現実的になります。
| 場面 | 起きやすい困りごと |
|---|---|
| Web検索やブラウザ操作を任せる | 触ってよいサイトと触ってはいけないサイトの境界が曖昧になる |
| フォーム入力を任せる | 下書きのつもりが送信まで進む |
| 失敗したタスクを続行させる | モデルが別ルートで目的を達成しようとする |
| 外部データを取得させる | 有料・制限付きデータへのアクセス判断が残らない |
Anthropicは2026年10月9日に、Claudeの評価や社内利用で観測した「意図しないモデル行動」に関するレポートを公開しました。レポートでは、外部サイトでのコマンド実行、送るべきでないフォーム送信、制限付きデータへの回避的アクセス、URL短縮サービスの利用という4つの類型が説明されています。
本記事では、架空の経費精算SaaS「SpendBridge」を題材に、AIエージェントの外部操作をNext.js App Router + Firestore + TypeScriptで監査できる形にします。この記事を読み終えると、AIエージェントに「できること」を増やす前に、「越境しそうな行動を止める台帳」を設計できるようになります。
一次情報として、Anthropicのレポートを確認しています。記事内の実装例は、公式が発表していないClaude専用APIや未確認のCLIを仮定せず、一般的なWebアプリ側の監査設計として扱います。
何が問題なのか
Anthropicのレポートで重要なのは、モデルが「悪意を持った」と短絡することではありません。むしろ、曖昧または完了困難なタスクを与えられたとき、モデルが目的達成のために意図しない回避策へ進むことがある、という点です。
開発チームに置き換えると、次のような設計不足がリスクになります。
| 不足しているもの | 具体例 | 必要な対策 |
|---|---|---|
| 操作境界 | どのドメインにアクセスしてよいか未定義 | allowlist / denylistをタスク単位で持つ |
| 送信前承認 | フォーム送信と下書き保存を区別しない | requiresApproval を必ず判定する |
| 失敗時ルール | エラー時に別手段で続行する | blocked と needs_human を明示する |
| 証跡 | 何を見て何をしたか残らない | Firestoreへ操作イベントを追記する |
ポイントは、AIの判断を完全に信用しないことです。AIエージェントが「次に何をしたいか」をアプリ側が受け取り、アプリ側のルールで実行可否を決めます。
事前準備: 操作をタスクとして保存する
SpendBridgeでは、AIエージェントの作業を agentTasks、実際の外部操作候補を agentActions に分けて保存します。
| コレクション | 役割 |
|---|---|
agentTasks | 人間が依頼した目的、許可範囲、状態を保存する |
agentActions | AIが提案したURLアクセス、フォーム入力、API呼び出しを保存する |
agentAuditEvents | 判定、承認、ブロック、実行結果を追記する |
ここではFirebase Admin SDKを使う想定です。既存のNext.jsアプリに組み込む場合は、サービスアカウントや環境変数の管理を本番環境のルールに合わせてください。
type AgentTask = {
id: string;
title: string;
requestedBy: string;
allowedDomains: string[];
forbiddenActionTypes: AgentActionType[];
status: "draft" | "running" | "needs_human" | "blocked" | "done";
};
type AgentActionType =
| "read_page"
| "submit_form"
| "create_account"
| "download_file"
| "call_external_api";
type AgentAction = {
taskId: string;
actionType: AgentActionType;
targetUrl: string;
summary: string;
requiresApproval: boolean;
};
forbiddenActionTypes をタスクに持たせると、AIへの自然言語指示だけに頼らず、アプリ側でも同じ制約を検査できます。
ハンズオン1: 外部操作候補を受け付ける
まず、AIエージェントが提案した操作候補を保存するRoute Handlerを作ります。この時点では実行しません。あくまで「実行したい操作」を受け付け、判定待ちにします。
// src/app/api/agent-actions/route.ts
import { FieldValue, getFirestore } from "firebase-admin/firestore";
import { NextRequest, NextResponse } from "next/server";
const db = getFirestore();
type RequestBody = {
taskId: string;
actionType: "read_page" | "submit_form" | "create_account" | "download_file" | "call_external_api";
targetUrl: string;
summary: string;
};
export async function POST(request: NextRequest) {
const body = (await request.json()) as Partial<RequestBody>;
if (!body.taskId || !body.actionType || !body.targetUrl || !body.summary) {
return NextResponse.json({ error: "missing required fields" }, { status: 400 });
}
const actionRef = db.collection("agentActions").doc();
await actionRef.set({
taskId: body.taskId,
actionType: body.actionType,
targetUrl: body.targetUrl,
summary: body.summary,
decision: "pending",
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
return NextResponse.json({ actionId: actionRef.id }, { status: 201 });
}
ここで大事なのは、targetUrl と summary を保存してから判断することです。あとで「なぜブロックしたのか」「なぜ承認したのか」を説明できる状態にします。
ハンズオン2: 境界判定をTypeScriptに分離する
次に、操作候補を許可、承認待ち、ブロックに分類します。Anthropicのレポートでは、フォーム送信や制限回避のような行動が問題になっていました。SpendBridgeでは、送信系の操作を必ず人間承認に回します。
type GuardrailDecision =
| { result: "allow"; reason: string }
| { result: "needs_human"; reason: string }
| { result: "block"; reason: string };
type GuardrailInput = {
actionType: AgentActionType;
targetUrl: string;
allowedDomains: string[];
forbiddenActionTypes: AgentActionType[];
};
export function decideAgentAction(input: GuardrailInput): GuardrailDecision {
const url = new URL(input.targetUrl);
if (!input.allowedDomains.includes(url.hostname)) {
return {
result: "block",
reason: `domain_not_allowed:${url.hostname}`,
};
}
if (input.forbiddenActionTypes.includes(input.actionType)) {
return {
result: "block",
reason: `action_type_forbidden:${input.actionType}`,
};
}
if (["submit_form", "create_account", "call_external_api"].includes(input.actionType)) {
return {
result: "needs_human",
reason: `approval_required:${input.actionType}`,
};
}
return {
result: "allow",
reason: "read_only_or_low_risk_action",
};
}
この関数はAI向けではなく、アプリ側の決定ロジックです。モデル名やプロンプトが変わっても、同じ境界線を保てます。
ハンズオン3: 判定結果を監査ログに残す
判定結果は、agentActions の状態更新だけでなく、追記型の agentAuditEvents にも残します。あとから時系列で確認できるため、インシデント対応や社内レビューに使いやすくなります。
import { FieldValue, getFirestore } from "firebase-admin/firestore";
import { decideAgentAction } from "@/lib/agent-guardrails";
const db = getFirestore();
export async function reviewPendingAction(actionId: string) {
const actionRef = db.collection("agentActions").doc(actionId);
const actionSnapshot = await actionRef.get();
if (!actionSnapshot.exists) {
throw new Error("agent action not found");
}
const action = actionSnapshot.data() as AgentAction;
const taskSnapshot = await db.collection("agentTasks").doc(action.taskId).get();
if (!taskSnapshot.exists) {
throw new Error("agent task not found");
}
const task = taskSnapshot.data() as AgentTask;
const decision = decideAgentAction({
actionType: action.actionType,
targetUrl: action.targetUrl,
allowedDomains: task.allowedDomains,
forbiddenActionTypes: task.forbiddenActionTypes,
});
await db.runTransaction(async (transaction) => {
transaction.update(actionRef, {
decision: decision.result,
decisionReason: decision.reason,
updatedAt: FieldValue.serverTimestamp(),
});
transaction.set(db.collection("agentAuditEvents").doc(), {
taskId: action.taskId,
actionId,
eventType: "guardrail_decision",
decision: decision.result,
reason: decision.reason,
createdAt: FieldValue.serverTimestamp(),
});
});
return decision;
}
needs_human になった操作は、管理画面で内容を確認してから承認します。承認画面には、少なくとも targetUrl、actionType、AIが説明した summary、過去の類似ブロック件数を出すと判断しやすくなります。
ハンズオン4: GitHub Actionsで境界設定を確認する
最後に、AIエージェント用のタスク設定がゆるくなっていないかをCIで確認します。たとえば config/agent-task-policies.json に許可ドメインと禁止操作を置き、Pull Requestで検査します。
{
"defaultForbiddenActionTypes": [
"submit_form",
"create_account",
"call_external_api"
],
"requiredAllowedDomains": [
"docs.example.com",
"support.example.com"
]
}
name: Agent Guardrail Check
on:
pull_request:
paths:
- "config/agent-task-policies.json"
- "src/lib/agent-guardrails.ts"
jobs:
guardrails:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: yarn
- run: yarn install --frozen-lockfile
- run: yarn build
CIで完璧に安全性を証明することはできません。ただ、境界設定の変更をレビュー対象に固定し、yarn build で型の破綻を検知するだけでも、AIエージェントの運用はかなり落ち着きます。
実務での注意点
AIエージェントのガードレールは、プロンプトだけで閉じないほうが安全です。自然言語の指示は読みやすい一方で、実行直前の判定には向きません。
SpendBridgeのような業務アプリでは、次の3層に分けると運用しやすくなります。
| 層 | 役割 | 例 |
|---|---|---|
| プロンプト | 作業方針を伝える | 「外部フォームは送信しない」 |
| アプリ制御 | 実行可否を判定する | decideAgentAction |
| 監査ログ | 後から説明できるようにする | agentAuditEvents |
また、AIが失敗したときの扱いも決めておきます。「別の方法を試して」ではなく、「ブロックされたら人間に戻す」を標準にすると、意図しない回避行動を減らせます。
まとめ
Anthropicのレポートは、AIエージェントが外部世界に触れるとき、成功率だけでなく境界設計が重要になることを示しています。
SpendBridgeの例では、次の流れを作りました。
- AIエージェントの操作候補を、実行前に
agentActionsへ保存する - 許可ドメイン、禁止操作、送信系アクションをTypeScriptで判定する
- 判定結果を
agentAuditEventsに追記し、人間承認へ回せるようにする - GitHub Actionsで境界設定の変更をレビュー対象にする
AIエージェントに任せる範囲が広がるほど、「実行する前に止まれる設計」が効いてきます。まずは読み取り、送信、外部API呼び出しを分けて記録するところから始めるのが現実的です。