01はじめに
業務エージェントの導入提案を進めると、情報システム部門やコンプライアンス担当者から必ずといってよいほど同じ質問が来ます。
「入力したデータはどこに保存されますか?」 「AIベンダーの学習に使われませんか?」 「ログに個人情報が残りませんか?」
これらは当然の懸念です。業務エージェントは、ユーザーの氏名・メールアドレス・契約情報・社内文書といった機密性の高いデータを日常的に扱います。設計段階でこれらの問いに答えられる構造を作っておかなければ、導入審査を通過できないか、仮に通過しても運用後に問題が顕在化します。
この記事では、筆者たちが業務エージェント開発で直面してきた情報管理の課題を整理し、「どう設計すれば審査を通り、運用でも安心できるか」を具体的なコードとともに解説します。
対象読者
- 業務エージェントの設計・開発を担当するエンジニア
- 社内システムへのAI導入を検討しているIT企画・情報システム担当者
- エージェント基盤のセキュリティレビューを行うコンプライアンス担当者
前提とする実行環境
- Node.js v20.18.1
- TypeScript 5.4 以上
- LLM API として OpenAI API(gpt-5.5)を想定。他社APIも設計思想は共通です。
おことわり
この記事はエンジニアリング観点での実装指針を扱うものです。個人情報保護法・GDPRなどの法的要件への適合判断については、必ず法務担当者または専門家に確認してください。本記事の内容をそのまま法的根拠として用いることはお控えください。
02TL;DR
- 個人情報・機密情報はエージェントへの入力前にマスキングかトークン化を施す
- ログ・トレースには生データを出力せず、マスク済みの参照キーのみを残す
- LLM APIへの送信前にフィルタリング層を挟み、学習利用オプトアウトを確認する
- 保存が必要なデータは保存期間・暗号化方式・アクセス権限を明文化する
- 導入前に顧客から聞かれる論点を先回りしてチェックリストで整理しておく
03データの流れを把握する
対策を設計する前に、業務エージェントの中でデータがどう流れるかを整理しておきます。
flowchart TD
A["ユーザー入力"]
B["入力フィルタ層\nマスキング・バリデーション"]
C["コンテキスト組み立て\nRAG・社内DBからの情報付与"]
D["LLM API呼び出し\n外部ベンダーへの送信"]
E["応答処理"]
F["ログ・トレース記録\n※個人情報の混入リスクが高い"]
G["ユーザーへの返答"]
A --> B --> C --> D --> E --> F --> G
機密情報の混入リスクが特に高いのは「LLM API呼び出し」と「ログ・トレース記録」の2か所です。 それぞれに対して異なる対策が必要になります。
04入力データの保存方針
何を保存し、何を保存しないかを決める
業務エージェントが扱うデータを保存する理由は主に3つです。
- 会話履歴をコンテキストとして再利用する
- 問題発生時のデバッグに使う
- 利用状況の分析・改善に使う
それぞれで「保存が必要なもの」「保存しなくてよいもの」は異なります。下表を参考に、用途ごとに保存方針を決めてください。
| 用途 | 保存が必要なデータ | 保存しなくてよいデータ |
|---|---|---|
| 会話履歴 | セッションID・ターン番号・役割(user/assistant)・本文 | 生の個人情報(本文はマスク済みで保存) |
| デバッグ | エラー種別・スタックトレース・入力パラメータ(匿名化済み) | 個人を特定できるユーザーID・氏名 |
| 利用分析 | クエリの意図分類・応答時間・エラー率 | 個人の質問内容そのもの |
保存期間の設計
保存期間は「業務上の必要性」と「リスク」のバランスで決まります。一般的な目安として以下を参考にしてください。ただし法的要件はシステムの性質や業種によって異なるため、自社の法務担当者への確認を推奨します。
| データ種別 | 目安の保存期間 | 理由 |
|---|---|---|
| 会話ログ(マスク済み) | 90日程度 | デバッグ・改善に使える期間 |
| エラーログ | 30〜90日 | 問題の再現・調査に必要な期間 |
| 利用統計(匿名化済み) | 1年程度 | 季節性・トレンド分析に必要 |
| 個人情報を含む生ログ | 原則保存しない | 保存しないことがリスク低減の最善策 |
保存期間の自動削除は実装で担保します。
// src/storage/session-store.ts
import { createClient } from 'redis';
interface SessionRecord {
sessionId: string;
messages: MaskedMessage[];
createdAt: number;
expiresAt: number;
}
interface MaskedMessage {
role: 'user' | 'assistant' | 'system';
maskedContent: string;
contentHash: string; // 元テキストのハッシュ(完全性確認用)
}
const RETENTION_DAYS = 90;
const TTL_SECONDS = RETENTION_DAYS * 24 * 60 * 60;
export async function saveSession(
client: ReturnType<typeof createClient>,
sessionId: string,
messages: MaskedMessage[]
): Promise<void> {
const record: SessionRecord = {
sessionId,
messages,
createdAt: Date.now(),
expiresAt: Date.now() + TTL_SECONDS * 1000,
};
// Redis の SETEX で TTL を設定し、期限切れを自動削除する
await client.setEx(
`session:${sessionId}`,
TTL_SECONDS,
JSON.stringify(record)
);
}
実行結果のイメージ(Redis の TTL 確認):
> TTL session:abc123
(integer) 7776000 # 90日 = 7,776,000秒
保存データの暗号化
保存するデータが機密性を持つ場合、保存時の暗号化(At Rest Encryption)を施します。 クラウドのマネージドストレージ(Amazon S3、Cloud Storage など)を使う場合は、サービス側の暗号化機能を有効にするのが最も手軽です。自前でデータを暗号化する場合のシンプルな実装例を示します。
// src/crypto/field-encryptor.ts
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32;
const IV_LENGTH = 16;
const TAG_LENGTH = 16;
const SALT_LENGTH = 32;
export class FieldEncryptor {
private readonly key: Buffer;
constructor(password: string, salt: string) {
// scrypt でパスワードからキーを導出する
this.key = scryptSync(password, salt, KEY_LENGTH);
}
encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
// iv + tag + encrypted を Base64 で連結して保存する
return Buffer.concat([iv, tag, encrypted]).toString('base64');
}
decrypt(ciphertext: string): string {
const buf = Buffer.from(ciphertext, 'base64');
const iv = buf.subarray(0, IV_LENGTH);
const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
}
使用例:
// 環境変数からパスワード・ソルトを取得する(ハードコードは避ける)
const encryptor = new FieldEncryptor(
process.env.FIELD_ENC_PASSWORD!,
process.env.FIELD_ENC_SALT!
);
const plainEmail = 'user@example.com';
const encrypted = encryptor.encrypt(plainEmail);
// => "dGVzdA==" のようなBase64文字列(実際の値は毎回異なる)
const decrypted = encryptor.decrypt(encrypted);
// => "user@example.com"
05ログ・トレースからのマスキング実装
なぜログが危険か
エンジニアが見落としがちな落とし穴が「ログへの個人情報の混入」です。 LangChain や LlamaIndex などのエージェントフレームワークは、デフォルトでプロンプトの全文をトレースに記録します。「とりあえずデバッグのために全部ログに出した」という実装のまま本番稼働すると、ログストレージに個人情報が大量に蓄積されます。
マスキングの基本設計
マスキングの基本方針は「入力を受け取った直後、LLMに渡す前にマスキングし、ログにはマスク済みテキストを流す」です。
入力: "田中太郎さん(tanaka@example.com)の契約を更新してください"
↓ マスキング処理
マスク済み: "[NAME_1]さん([EMAIL_1])の契約を更新してください"
マッピング: { NAME_1: "田中太郎", EMAIL_1: "tanaka@example.com" }
マッピングはリクエストスコープで保持し、LLMの応答中にプレースホルダーが返ってきた場合のみ復元します。 ログにはマスク済みテキストのみを記録します。
// src/masking/pii-masker.ts
export interface MaskResult {
maskedText: string;
// 元の値に戻すためのマッピング(リクエストスコープで管理する)
mapping: Map<string, string>;
}
// マスキング対象のパターン定義
const PII_PATTERNS: Array<{ name: string; pattern: RegExp }> = [
{
name: 'EMAIL',
pattern: /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g,
},
{
name: 'PHONE_JP',
// 日本の電話番号(ハイフンあり・なし両対応)
pattern: /0\d{1,4}[-\s]?\d{1,4}[-\s]?\d{4}/g,
},
{
name: 'POSTAL_CODE_JP',
pattern: /〒?\d{3}-\d{4}/g,
},
{
name: 'CREDIT_CARD',
// クレジットカード番号(4桁×4)
pattern: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g,
},
];
export class PiiMasker {
mask(text: string): MaskResult {
const mapping = new Map<string, string>();
let maskedText = text;
const counters: Record<string, number> = {};
for (const { name, pattern } of PII_PATTERNS) {
// グローバルフラグのあるRegExpはstatefulなのでリセットする
pattern.lastIndex = 0;
maskedText = maskedText.replace(pattern, (match) => {
counters[name] = (counters[name] ?? 0) + 1;
const placeholder = `[${name}_${counters[name]}]`;
mapping.set(placeholder, match);
return placeholder;
});
}
return { maskedText, mapping };
}
restore(maskedText: string, mapping: Map<string, string>): string {
let restored = maskedText;
for (const [placeholder, original] of mapping) {
// エスケープして正規表現の特殊文字を無効化する
const escaped = placeholder.replace(/[[\]]/g, '\\$&');
restored = restored.replace(new RegExp(escaped, 'g'), original);
}
return restored;
}
}
動作確認:
const masker = new PiiMasker();
const input = '田中さん(tanaka@example.com)に090-1234-5678で連絡してください';
const { maskedText, mapping } = masker.mask(input);
console.log(maskedText);
// => 田中さん([EMAIL_1])に[PHONE_JP_1]で連絡してください
console.log(mapping);
// => Map {
// '[EMAIL_1]' => 'tanaka@example.com',
// '[PHONE_JP_1]' => '090-1234-5678'
// }
const restored = masker.restore(maskedText, mapping);
console.log(restored);
// => 田中さん(tanaka@example.com)に090-1234-5678で連絡してください
構造化ログへの適用
ログライブラリ(pino など)を使う場合、シリアライザーフックでマスキングを一元化できます。
// src/logger/pino-logger.ts
import pino from 'pino';
// ログに出力するフィールドに対してマスキングを適用するシリアライザー
const sensitiveFieldSerializer = (value: unknown): unknown => {
if (typeof value !== 'string') return value;
// メールアドレスをマスクする(ローカルパートの後半3文字以降を * に置換)
return value
.replace(
/([a-zA-Z0-9._%+\-]{1,3})[a-zA-Z0-9._%+\-]*@([a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})/g,
'$1***@$2'
)
.replace(/(\d{3})[-\s]?(\d{4})[-\s]?(\d{4})/, '$1-****-$3');
};
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
serializers: {
// 'input' フィールドに自動適用する
input: sensitiveFieldSerializer,
// エラーオブジェクトの message フィールドにも適用する
err: pino.stdSerializers.err,
},
});
使用例と出力:
logger.info(
{ input: 'tanaka@example.com に送信', sessionId: 'sess_001' },
'LLM呼び出し開始'
);
// 出力ログ(JSONを整形して表示):
// {
// "level": 30,
// "time": 1749600000000,
// "input": "tan***@example.com に送信",
// "sessionId": "sess_001",
// "msg": "LLM呼び出し開始"
// }
LangChain / Vercel AI SDK 利用時の注意点
LangChain の CallbackHandler や Vercel AI SDK の onChunkReceived などは、プロンプト全文がコールバックに渡されます。
カスタムコールバックでログ出力する際は、プロンプトをそのまま記録するのではなく、マスキング済みテキストを参照するよう設計してください。
// src/agent/masked-callback-handler.ts
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
import { PiiMasker } from '../masking/pii-masker.js';
import { logger } from '../logger/pino-logger.js';
const masker = new PiiMasker();
export class MaskedCallbackHandler extends BaseCallbackHandler {
name = 'MaskedCallbackHandler';
handleLLMStart(
_llm: Record<string, unknown>,
prompts: string[]
): void {
for (const prompt of prompts) {
const { maskedText } = masker.mask(prompt);
logger.debug({ maskedPrompt: maskedText }, 'LLM呼び出し開始');
}
}
handleLLMEnd(output: { generations: Array<Array<{ text: string }>> }): void {
for (const generation of output.generations) {
for (const { text } of generation) {
const { maskedText } = masker.mask(text);
logger.debug({ maskedResponse: maskedText }, 'LLM応答受信');
}
}
}
}
06LLM APIへの外部送信の制御
学習利用オプトアウトの確認
業務データをLLM APIに送信するとき、最初に確認すべきは「送信したデータがモデルの学習に使われるか否か」です。 2026年6月時点の主要ベンダーの方針を以下に整理しますが、ポリシーは随時改定されるため、導入時は必ず公式ドキュメントを確認してください。
| ベンダー | エンタープライズ向けオプトアウト | 確認先 |
|---|---|---|
| OpenAI | API経由の利用はデフォルトで学習対象外(利用規約で明記) | OpenAI API利用規約 |
| Anthropic | API経由の利用はデフォルトで学習対象外 | Anthropic使用ポリシー |
| Google (Gemini API) | Vertex AI経由の利用は学習対象外 | Google Cloud利用規約 |
| Azure OpenAI Service | 学習利用なし(Microsoftのポリシーで担保) | Microsoft製品規約 |
重要なのは「エンタープライズ契約やAPIアクセスと、無料プランや一般ユーザー向けコンシューマサービスは扱いが異なる」点です。 業務利用では必ずAPI経由のエンタープライズ利用であることを確認してください。
リージョン制御
金融・医療・行政などのデータは、データが物理的に保存・処理されるリージョンが法的・規制的に制約されることがあります。
OpenAI API や Azure OpenAI Service はリクエスト先のエンドポイントでリージョンを制御できます。
// src/llm/openai-client.ts
import OpenAI from 'openai';
// 日本リージョンを指定する(Azure OpenAI Service の場合)
const createAzureClient = (): OpenAI => {
return new OpenAI({
apiKey: process.env.AZURE_OPENAI_API_KEY!,
baseURL: `https://${process.env.AZURE_OPENAI_RESOURCE_NAME}.openai.azure.com/openai/deployments/${process.env.AZURE_OPENAI_DEPLOYMENT_NAME}`,
defaultQuery: {
'api-version': '2024-02-01',
},
});
};
// 標準 OpenAI API を使う場合
// プランや契約によってはデータレジデンシー(処理リージョン指定)のオプションが提供されています。
// 最新の提供状況は公式情報を確認してください。
const createOpenAIClient = (): OpenAI => {
return new OpenAI({
apiKey: process.env.OPENAI_API_KEY!,
});
};
// 利用するクライアントを環境変数で切り替える
export const getLlmClient = (): OpenAI => {
if (process.env.LLM_PROVIDER === 'azure') {
return createAzureClient();
}
return createOpenAIClient();
};
送信前フィルタリング層
LLM APIに送る直前に「送ってよいか」をチェックする層を挟みます。 誤って機密フラグのついたフィールドが含まれていたら送信をブロックし、代わりにマスク済みテキストを送るようにします。
// src/llm/send-filter.ts
import { PiiMasker } from '../masking/pii-masker.js';
import { logger } from '../logger/pino-logger.js';
interface SendFilterResult {
allowed: boolean;
sanitizedMessages: Array<{ role: string; content: string }>;
blockedReasons: string[];
}
const masker = new PiiMasker();
// 送信禁止キーワードの例(社内ルールに合わせて調整する)
const BLOCK_KEYWORDS: string[] = [
'パスワード',
'password',
'秘密鍵',
'private key',
'内部限定',
'CONFIDENTIAL',
];
export function applyPreSendFilter(
messages: Array<{ role: string; content: string }>
): SendFilterResult {
const blockedReasons: string[] = [];
const sanitizedMessages: Array<{ role: string; content: string }> = [];
for (const message of messages) {
const upperContent = message.content.toUpperCase();
// ブロックキーワードの検査
for (const keyword of BLOCK_KEYWORDS) {
if (upperContent.includes(keyword.toUpperCase())) {
blockedReasons.push(`禁止キーワードを検出しました: "${keyword}"`);
}
}
// マスキングを施してから送信用メッセージを構築する
const { maskedText } = masker.mask(message.content);
sanitizedMessages.push({ role: message.role, content: maskedText });
}
if (blockedReasons.length > 0) {
logger.warn({ blockedReasons }, '送信前フィルタでブロックしました');
return { allowed: false, sanitizedMessages, blockedReasons };
}
return { allowed: true, sanitizedMessages, blockedReasons: [] };
}
使用例:
const messages = [
{
role: 'user',
content: 'tanaka@example.com の契約書を要約してください',
},
];
const { allowed, sanitizedMessages, blockedReasons } = applyPreSendFilter(messages);
if (!allowed) {
console.error('送信がブロックされました:', blockedReasons);
// => 禁止キーワードがない場合は allowed: true になる
} else {
console.log(sanitizedMessages[0].content);
// => "[EMAIL_1] の契約書を要約してください"
}
07アクセス制御の設計
「誰が何にアクセスできるか」を最小権限で設計する
エージェントが扱う情報へのアクセス権限は「最小権限の原則」で設計します。 つまり、エージェントが業務を遂行するのに必要な最低限のアクセス権のみを付与し、余分な権限を持たせません。
実装パターンとして、ユーザーのロールに応じてエージェントが参照できるツール・データソースを制限するアプローチが有効です。
// src/access-control/agent-acl.ts
export type UserRole = 'viewer' | 'editor' | 'admin';
interface ToolPermission {
toolName: string;
allowedRoles: UserRole[];
}
// ツールごとに許可ロールを定義する
const TOOL_PERMISSIONS: ToolPermission[] = [
{
toolName: 'search_contracts',
allowedRoles: ['viewer', 'editor', 'admin'],
},
{
toolName: 'update_contract',
allowedRoles: ['editor', 'admin'],
},
{
toolName: 'delete_record',
allowedRoles: ['admin'],
},
{
toolName: 'export_bulk_data',
allowedRoles: ['admin'],
},
];
export class AgentAcl {
getAllowedTools(userRole: UserRole): string[] {
return TOOL_PERMISSIONS
.filter((perm) => perm.allowedRoles.includes(userRole))
.map((perm) => perm.toolName);
}
canUseTool(userRole: UserRole, toolName: string): boolean {
const perm = TOOL_PERMISSIONS.find((p) => p.toolName === toolName);
if (!perm) return false;
return perm.allowedRoles.includes(userRole);
}
}
使用例:
const acl = new AgentAcl();
console.log(acl.getAllowedTools('viewer'));
// => ['search_contracts']
console.log(acl.getAllowedTools('editor'));
// => ['search_contracts', 'update_contract']
console.log(acl.canUseTool('viewer', 'delete_record'));
// => false
console.log(acl.canUseTool('admin', 'delete_record'));
// => true
セッションスコープでのコンテキスト分離
複数ユーザーが同じエージェント基盤を使う場合、ユーザーAのデータがユーザーBのコンテキストに混入しないことを保証する必要があります。 セッションIDをコンテキストキーとして扱い、他のセッションから参照できない設計にします。
// src/context/session-context.ts
import crypto from 'crypto';
import { UserRole } from '../access-control/agent-acl.js';
export interface AgentContext {
sessionId: string;
userId: string;
userRole: UserRole;
maskedMessages: Array<{ role: string; content: string }>;
// 元の値の復元マップはセッションスコープでのみ保持する
piiMappings: Map<string, string>;
createdAt: Date;
}
// セッションコンテキストはプロセスメモリに保持し、永続化しない
// (永続化が必要な場合は暗号化してRedisに保存する)
const sessionStore = new Map<string, AgentContext>();
export function createSession(userId: string, userRole: UserRole): AgentContext {
const sessionId = crypto.randomUUID();
const context: AgentContext = {
sessionId,
userId,
userRole,
maskedMessages: [],
piiMappings: new Map(),
createdAt: new Date(),
};
sessionStore.set(sessionId, context);
return context;
}
export function getSession(sessionId: string): AgentContext | undefined {
return sessionStore.get(sessionId);
}
export function destroySession(sessionId: string): void {
const context = sessionStore.get(sessionId);
if (context) {
// PII マッピングをメモリから確実に削除する
context.piiMappings.clear();
sessionStore.delete(sessionId);
}
}
08エンドツーエンドの実装例
ここまでに紹介した各コンポーネントを組み合わせて、マスキング付きエージェント呼び出しの全体像を示します。
// src/agent/masked-agent.ts
import OpenAI from 'openai';
import { PiiMasker } from '../masking/pii-masker.js';
import { applyPreSendFilter } from '../llm/send-filter.js';
import { AgentAcl, UserRole } from '../access-control/agent-acl.js';
import { createSession, destroySession } from '../context/session-context.js';
import { logger } from '../logger/pino-logger.js';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const masker = new PiiMasker();
const acl = new AgentAcl();
interface AgentCallOptions {
userId: string;
userRole: UserRole;
userInput: string;
}
interface AgentCallResult {
response: string;
sessionId: string;
}
export async function callMaskedAgent(
options: AgentCallOptions
): Promise<AgentCallResult> {
const { userId, userRole, userInput } = options;
// セッション生成(このセッション内でのみPIIマッピングを保持する)
const session = createSession(userId, userRole);
try {
// 1. 入力をマスキングする
const { maskedText, mapping } = masker.mask(userInput);
// PIIマッピングをセッションスコープに追加する
for (const [k, v] of mapping) {
session.piiMappings.set(k, v);
}
// 2. 送信前フィルタを通す
const messages = [{ role: 'user', content: maskedText }];
const { allowed, sanitizedMessages, blockedReasons } = applyPreSendFilter(messages);
if (!allowed) {
throw new Error(`送信がブロックされました: ${blockedReasons.join(', ')}`);
}
// 3. 利用可能なツールをロールに基づいて制限する
const allowedTools = acl.getAllowedTools(userRole);
logger.info(
{ sessionId: session.sessionId, userRole, allowedTools },
'エージェント呼び出し開始'
);
// 4. LLM API に送信する(マスク済みメッセージのみ)
const completion = await openai.chat.completions.create({
model: 'gpt-5.5',
messages: sanitizedMessages as OpenAI.ChatCompletionMessageParam[],
});
const rawResponse = completion.choices[0]?.message?.content ?? '';
// 5. 応答中のプレースホルダーを元の値に復元する
const restoredResponse = masker.restore(rawResponse, session.piiMappings);
logger.info(
{ sessionId: session.sessionId },
'エージェント呼び出し完了'
);
return { response: restoredResponse, sessionId: session.sessionId };
} finally {
// セッション終了時にPIIマッピングをメモリから削除する
destroySession(session.sessionId);
}
}
呼び出し例:
const result = await callMaskedAgent({
userId: 'user-001',
userRole: 'viewer',
userInput: 'tanaka@example.com の最新の契約内容を教えてください',
});
console.log(result.response);
// => LLM応答(応答中に "[EMAIL_1]" が含まれていた場合は "tanaka@example.com" に復元済み)
09業務導入時に顧客から聞かれる論点チェックリスト
業務エージェントを企業に導入する際、情報システム部門・コンプライアンス部門・経営層から繰り返し聞かれる論点をチェックリストにまとめます。 導入提案の段階でこれらを先回りして回答できると、審査がスムーズに進みやすくなります。
データの保存・管理
- ユーザーの入力データはどこに保存されますか?(国・リージョン・サービス名)
- 保存されるデータの暗号化方式は何ですか?(AES-256 等)
- データの保存期間はどのくらいですか?
- 保存期間後の削除は自動ですか、手動ですか?
- バックアップデータにも同等の暗号化が施されていますか?
- 個人情報を含むログは保存していますか?保存している場合は誰がアクセスできますか?
外部送信・AIベンダー
- どのAIベンダーのAPIを利用していますか?
- 送信したデータはAIモデルの学習に使用されますか?
- APIプロバイダーとの間でデータ処理契約(DPA)を締結していますか?
- データが処理されるリージョンはどこですか?(日本国内か、海外か)
- サブプロセッサー(AIベンダー以外にデータが渡る第三者)はいますか?
- AIベンダーのポリシー改定に気づく仕組みはありますか?
アクセス制御・監査
- エージェントが参照できるデータの範囲をロール別に制限していますか?
- 誰がどのデータにアクセスしたかの監査ログは取得できますか?
- 監査ログ自体へのアクセス権限は適切に制限されていますか?
- 不正アクセスや異常な利用パターンのアラートはありますか?
- 従業員がエージェントを使って不正に情報を引き出そうとした場合の検知はできますか?
インシデント対応
- データ漏えいが発生した場合の通知フロー・連絡先はありますか?
- インシデント発生時にエージェントへのアクセスを即時停止できますか?
- 影響範囲の調査に必要なログは保持されていますか?
- 過去に同種のセキュリティインシデントがありましたか?
法的・コンプライアンス
- 個人情報保護法の個人情報取扱事業者として適切な対応をしていますか?
- プライバシーポリシーにAI利用についての記載はありますか?
- EU圏のユーザーのデータを扱う場合、GDPRへの対応は整っていますか?
- データ主体からの開示・削除請求に応えられる仕組みはありますか?
- 社内規程(情報セキュリティポリシー・個人情報管理規程等)に沿った設計ですか?
上記の問いに対する自社の回答をドキュメント化しておくと、導入審査の場での説明が格段に楽になります。 筆者たちも BizPlan(事業計画エージェント)の設計において、このチェックリストを元にセキュリティ設計を整理した経験があります。
10よくある設計ミスとその対策
実際の開発・導入支援の中で遭遇しやすいパターンを整理します。
ミス1: デバッグ用に全プロンプトをログに出した
状況: 開発時のデバッグ用途で console.log(prompt) を追加し、そのまま本番に残った。
問題: プロンプトに個人情報が含まれており、ログストレージに大量蓄積。
対策: ログ出力には必ずマスキングを通す。本番環境では LOG_LEVEL=info とし、debug レベルの詳細出力を抑制する。
ミス2: エージェントツールが全テーブルへの読み取り権限を持っていた
状況: 「とりあえずフルアクセスで動かす」設計が本番にそのまま残った。 問題: エージェントがプロンプトインジェクションの影響を受けた際、想定外のデータを参照・返答した。 対策: ツールが叩くDBユーザーには必要最低限のテーブル・カラムへのアクセス権のみを付与する。
ミス3: 応答をそのままフロントエンドに返した
状況: LLMの応答テキストに、もとのプレースホルダーが復元されず [EMAIL_1] のまま表示された。
問題: 想定外の文字列がユーザーに表示され、バグとして報告された。
対策: レスポンス処理の最後に必ず masker.restore() を呼ぶ。ユニットテストで復元を検証する。
ミス4: 会話履歴を暗号化せずDBに保存した
状況: セッション履歴をテキストのままRDBに保存。DBの権限管理は適切だったが、バックアップファイルの扱いが不明確だった。
問題: バックアップファイルへのアクセス権限が広く、平文データが参照できた。
対策: 保存前に FieldEncryptor 等でフィールド単位の暗号化を施す。バックアップも暗号化対象に含める。
11まとめ
業務エージェントにおける個人情報・機密情報の管理は、「後から追加する」のではなく「最初から設計に組み込む」ことが重要です。
設計の要点を4点に整理します。
- 入力フィルタ層でマスキング: LLMにデータを渡す前にPIIを取り除く。マッピングはセッションスコープで管理し、応答時に復元する。
- ログには生データを書かない: フレームワークのコールバックやロガーのシリアライザーで一元的にマスキングを施す。
- 外部送信は最小限・明文化: 学習オプトアウト・リージョン・サブプロセッサーを確認し、ドキュメントに残す。
- 最小権限でアクセス制御: ツール・データ・ロールの対応表を設計段階で定め、コードで強制する。
顧客から問われる論点チェックリストに事前に答えられる状態を作っておくと、導入審査のコストが大きく下がります。今回紹介したコードはあくまで設計思想を示すサンプルです。実際の運用に合わせてパターン定義・暗号化アルゴリズム・保存先を調整してください。
また、繰り返しになりますが、法的要件への適合については必ず専門家に確認することをお勧めします。技術的に「漏れないようにした」としても、法令上の手続き(プライバシーポリシーの記載・委託先への監督義務など)が別途必要になる場合があります。
(関連記事: ログとトレーシング:エージェントの挙動を観測可能にする)
12参考文献
- OpenAI. "API data privacy". OpenAI Platform Documentation. 2026年6月時点.
- Anthropic. "Privacy Policy and Usage Policies". Anthropic Documentation. 2026年6月時点.
- Microsoft. "Data, privacy, and security for Azure OpenAI Service". Microsoft Learn. 2026年6月時点.
- Google Cloud. "Data governance and compliance in Vertex AI". Google Cloud Documentation. 2026年6月時点.
- 個人情報保護委員会. "個人情報の保護に関する法律についてのガイドライン". 2023年改正版. 2026年6月時点.
- OWASP. "OWASP Top 10 for Large Language Model Applications". OWASP Foundation. 2025年版. 2026年6月時点.

