顧客から届いたCSVを、そのままClaude CodeやCodexの作業フォルダへ置いていないでしょうか。
AIエージェントに「この売上データを集計して」と頼む前に、本当に確認すべきなのはプロンプトではありません。氏名、メールアドレス、電話番号、住所、口座番号などが、入力ファイルに残っていないかです。
目視確認だけでは、列の追加やファイル差し替えを見落とします。そこで今回は、AIへ渡す直前にNode.jsでファイルを検査し、疑わしい項目が1件でもあれば終了コード1で止める仕組みを作ります。
この記事で扱うのは技術的な最終防衛線です。顧客データの隔離、匿名化、Claude Codeの権限制限まで含めた運用全体は、税理士事務所でClaude Codeを安全に使う手順にまとめました。
先に結論:原本とAI用データを分ける
安全な流れは、次の3段階です。
顧客から受領した原本(AIから読めない場所)
↓ 人が必要項目だけ抽出・匿名化
AI用の作業コピー
↓ ai-input-check.mjs で検査
Claude Code / Codexに渡す
スキャナーを置くだけで安全になるわけではありません。原本を最初からAIの作業ディレクトリへ置いてしまうと、入力前検査を回避して読める状態が残ります。
原本と作業コピーを別フォルダに分け、AIには作業コピーだけを見せます。そのうえで、作業コピーに個人情報らしい値が戻っていないかを機械的に確認します。
コピペで動く入力前スキャナー
次のコードを scripts/ai-input-check.mjs として保存してください。Node.js 18以降なら追加パッケージなしで動きます。
#!/usr/bin/env node
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
const blockedKeys = new Set([
"accountnumber",
"address",
"bankaccount",
"birthdate",
"clientname",
"customername",
"email",
"employeeid",
"fullname",
"mynumber",
"name",
"phone",
"representativename",
"住所",
"代表者名",
"口座番号",
"氏名",
"生年月日",
"電話番号",
"メールアドレス",
"マイナンバー",
]);
const valueRules = [
{
label: "blocked field label",
pattern:
/(?:^|[,|\t{"']\s*)(?:account[ _-]?number|address|bank[ _-]?account|birth[ _-]?date|client[ _-]?name|customer[ _-]?name|email|employee[ _-]?id|full[ _-]?name|my[ _-]?number|name|phone|representative[ _-]?name|住所|代表者名|口座番号|氏名|生年月日|電話番号|メールアドレス|マイナンバー)(?=\s*(?:[,|:\t}"']|$))/im,
},
{
label: "email address",
pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
},
{
label: "Japanese phone number",
pattern: /(?<!\d)0\d{1,4}-\d{1,4}-\d{3,4}(?!\d)/,
},
{
label: "12-digit identifier",
pattern: /(?<!\d)\d{4}[- ]?\d{4}[- ]?\d{4}(?!\d)/,
},
{
label: "Japanese postal code",
pattern: /(?<!\d)\d{3}-\d{4}(?!\d)/,
},
];
const readableExtensions = new Set([".csv", ".json", ".md", ".txt"]);
function normalizeKey(key) {
return key.toLowerCase().replace(/[\s_-]/g, "");
}
function inspectValue(value, location, findings) {
if (Array.isArray(value)) {
value.forEach((item, index) =>
inspectValue(item, `${location}[${index}]`, findings),
);
return;
}
if (value && typeof value === "object") {
for (const [key, child] of Object.entries(value)) {
const childLocation = `${location}.${key}`;
if (blockedKeys.has(normalizeKey(key))) {
findings.push(`${childLocation}: blocked field name`);
}
inspectValue(child, childLocation, findings);
}
return;
}
if (typeof value !== "string") return;
for (const rule of valueRules) {
if (rule.pattern.test(value)) {
findings.push(`${location}: ${rule.label}`);
}
}
}
async function listFiles(targetPath) {
const info = await stat(targetPath);
if (info.isFile()) return [targetPath];
const entries = await readdir(targetPath, { withFileTypes: true });
const nested = await Promise.all(
entries
.filter((entry) => !entry.isSymbolicLink())
.map((entry) => listFiles(path.join(targetPath, entry.name))),
);
return nested.flat();
}
async function scanFile(filePath) {
if (!readableExtensions.has(path.extname(filePath).toLowerCase())) return [];
const source = await readFile(filePath, "utf8");
const findings = [];
if (path.extname(filePath).toLowerCase() === ".json") {
try {
inspectValue(JSON.parse(source), filePath, findings);
return findings;
} catch {
findings.push(`${filePath}: invalid JSON`);
return findings;
}
}
inspectValue(source, filePath, findings);
return findings;
}
const targetPath = process.argv[2];
if (!targetPath) {
console.error("Usage: node scripts/ai-input-check.mjs <file-or-directory>");
process.exit(2);
}
const files = await listFiles(path.resolve(targetPath));
const findings = (await Promise.all(files.map(scanFile))).flat();
if (findings.length > 0) {
console.error(`Blocked: ${findings.length} finding(s) in ${files.length} file(s)`);
for (const finding of findings) console.error(`- ${finding}`);
process.exit(1);
}
console.log(`Passed: ${files.length} file(s), 0 blocked patterns`);
安全なデータと危険なデータで試す
検査用フォルダを作り、まず匿名化済みのJSONを入れます。実在する顧客情報はテストに使わないでください。
mkdir -p work/ai-input
work/ai-input/safe.json:
{
"clientRef": "C-017",
"companyAlias": "顧問先A",
"amountBand": "1000万円から1500万円",
"task": "月次コメントの論点を整理する"
}
実行します。
node scripts/ai-input-check.mjs work/ai-input
個人情報らしい項目がなければ、次のように終了します。
Passed: 1 file(s), 0 blocked patterns
次に、公開しても問題ない架空データだけを使って、検査が止まることを確認します。
work/ai-input/unsafe.json:
{
"representativeName": "テスト太郎",
"email": "sample@example.com",
"myNumber": "1234-5678-9012"
}
再実行すると、終了コード1になります。
Blocked: 5 finding(s) in 2 file(s)
- ...unsafe.json.representativeName: blocked field name
- ...unsafe.json.email: blocked field name
- ...unsafe.json.email: email address
- ...unsafe.json.myNumber: blocked field name
- ...unsafe.json.myNumber: 12-digit identifier
検査対象に安全なファイルも残っているため、表示上は2ファイルです。1件でも検出すればフォルダ全体を止める設計にしています。
npmスクリプトにして実行忘れを減らす
毎回コマンドを思い出す運用は長続きしません。package.jsonへ検査コマンドを追加します。
{
"scripts": {
"ai:check-input": "node scripts/ai-input-check.mjs work/ai-input"
}
}
AIへ作業を依頼する前に、必ず次を実行します。
npm run ai:check-input
チームでは、プロンプトのテンプレートにも「最初に npm run ai:check-input を実行し、失敗したら以降のファイルを読まずに停止する」と書いておきます。ただし、プロンプトによる約束だけをアクセス制御の代わりにしてはいけません。原本フォルダはOS権限やサンドボックスでも読めない状態にします。
3つの実務例
1. 税理士事務所の月次コメント
氏名や会社名を C-017、顧問先A のような内部参照へ置き換えます。売上額をそのまま残す必要がなければ、金額も「1000万円から1500万円」のような帯へ変換します。AIには増減理由の候補や確認質問だけを作らせ、顧客への最終コメントは担当者が判断します。
2. 採用候補者の面接メモ
氏名、メールアドレス、電話番号を削除し、候補者ID、職種、評価項目だけを作業コピーへ移します。AIには評価観点の抜けを確認させますが、採否やセンシティブな評価は人が決めます。
3. 問い合わせログの分類
問い合わせ本文には、列名がなくても署名欄にメールアドレスや電話番号が残ります。構造化JSONのキー検査だけでは足りないため、本文全体にも値のパターン検査をかけます。分類後のデータを再利用する場合も、毎回スキャンします。
このスキャナーが検出できないもの
正規表現は個人情報の完全な判定器ではありません。少なくとも次のケースは見逃す可能性があります。
- ハイフンなし、国番号付き、特殊な表記の電話番号
- 列名のない日本人名や会社名
- PDF、Excel、画像、OCR前のスキャンデータ
- 顧客固有の契約番号や社内ID
- 複数項目を組み合わせると個人を特定できるデータ
逆に、12桁の商品番号を識別子として誤検出することもあります。誤検出したからといってルールをすぐ削除せず、値を別の代理IDへ変換できないか先に検討します。
実運用では、業界ごとの禁止項目を blockedKeys と valueRules へ追加します。PDFやExcelを扱うなら、専用パーサーでテキスト化した後に検査し、変換元の原本はAIの作業範囲外に置きます。
また、このコードはシンボリックリンクをたどりません。作業フォルダ内のリンクから原本フォルダへ到達したり、循環リンクで検査が終わらなくなったりするのを避けるためです。リンク先のデータも検査したい場合は、リンクを許可するのではなく、必要な項目だけを通常ファイルとして作業フォルダへ書き出します。
禁止項目を業務に合わせて決める
検出ルールを増やす前に、入力項目を3段階へ分けると判断しやすくなります。
| 区分 | 例 | AI用データでの扱い |
|---|---|---|
| 常に除外 | 氏名、住所、電話番号、メール、口座番号、マイナンバー | 原則として含めない |
| 代理値へ変換 | 顧客名、候補者名、従業員番号、案件番号 |
C-017 など元データと直接結び付かない値へ置換 |
| 目的を確認して残す | 金額、日付、地域、職種、問い合わせ分類 | AIへ渡す目的に必要な粒度まで粗くする |
たとえば、売上の変化理由を整理するだけなら、会社名や正確な金額は不要かもしれません。「顧問先A」「1000万円から1500万円」「前月比10%から20%減」のように、判断に必要な特徴だけを残せます。
一方、請求書の金額照合では正確な金額が必要です。この場合も、氏名や住所まで一緒に残す理由はありません。「業務に必要か」を項目ごとに分け、必要性を説明できないデータは作業コピーから外します。
禁止リストは、一般的な個人情報だけで終わりません。採用なら学校名や現職の会社名、医療なら患者IDや診療情報、不動産なら物件住所や入居者情報など、組み合わせで本人を推測できる項目があります。実際の列名を棚卸しし、業務固有の語を blockedKeys へ追加します。
誤検出が出たときの判断順序
name や12桁の数字は、商品名や管理番号にも一致します。そこで「検出したら削除」「誤検出なら許可」という二択にしないことが大切です。
最初に、その項目がAIの作業に本当に必要か確認します。不要なら削除が最も簡単です。必要なら、元の値を復元できない代理値や範囲へ変換します。それでも原値が必要な場合だけ、AIへ渡さない別工程に分けられないか検討します。
許可ルールを足すのは最後です。ファイル名や列名だけで恒久的に除外すると、将来そこへ別の値が入ったときに素通りします。例外を作るなら、対象ファイル、列、利用目的、確認者、期限を記録し、データ形式が変わった時点で見直します。
失敗しやすい4つの運用
1. AIへ渡した後にスキャンする
出力確認のタイミングで入力漏えいに気づいても遅すぎます。スキャンはAIのコマンドを起動する前に実行し、失敗時は後続処理を始めない構成にします。
2. 表示だけ伏せて元ファイルを残す
画面上で氏名を *** にしても、CSV、JSON、バックアップ、変換前ファイルに原値が残っていれば隔離できていません。作業フォルダ全体を検査対象にし、old、backup、一時ファイルも確認します。
3. 入力だけ確認してログを忘れる
検査エラーへ実値をそのまま出すと、CIログや共有ターミナルへ個人情報を複製します。このスクリプトは一致した値そのものではなく、ファイル位置とルール名だけを表示します。デバッグ目的でも値全体をログへ出さないでください。
4. スキャナーをアクセス制御だと思う
検査に合格した作業コピーがあっても、AIが隣の原本フォルダを読めるなら境界は破れています。スキャナーはデータ品質のゲート、OS権限やサンドボックスはアクセス境界です。役割が異なるため、両方が必要です。
導入時のチェックリスト
- 原本フォルダとAI用フォルダが別の場所にある
- AIの実行ユーザーから原本を読めない
- 作業コピーを作る担当者と手順が決まっている
- 業界固有の禁止項目をリスト化した
- 安全な架空データで合格を確認した
- 危険な架空データで終了コード1を確認した
- 検査ログに実際の値を出していない
- AIの出力から個人を再推測できないか人が確認する
- 例外を恒久化せず、目的と期限を記録する
- データ形式や利用サービスが変わったらルールを見直す
最初から全ファイル形式へ対応しようとすると、検査器の開発自体が止まります。まずCSV、JSON、Markdown、テキストに限定し、扱えない形式は明示的にAI用フォルダへ置かない運用から始めます。
まとめ
AIエージェントへのデータ入力は、最後に注意書きを表示するだけでは守れません。
- 原本をAIから読めない場所へ隔離する
- 必要項目だけを匿名化した作業コピーを作る
- 入力前スキャナーが終了コード0になるまでAIへ渡さない
- AIの出力を人が確認してから業務へ使う
今回のコードは、見落としを減らすための停止装置です。契約、法令、サービス側のデータ取扱い、安全管理措置の確認を置き換えるものではありません。
匿名化表、Claude Codeの deny 設定、サンドボックス、失敗時の止め方まで含む完成形は、顧問先データを安全に扱う実装ガイドで確認できます。
実際に試した結果
同じ検査ロジックで、匿名化済みの架空データは検出0件、氏名フィールド・メールアドレス・12桁識別子を含む架空データは複数件を検出し、終了コード1で停止することを確認しました。実在する顧客データはテストに使用していません。