いつも通り夜間の定期処理を走らせて、翌朝ログを見返していたときのことです。処理そのものは成功していました。ただ、使っているコネクタのツール一覧に、前の週にはなかった名前がいくつか並んでいました。create_message、send_message、delete_file。連携先が更新され、これまで読み取りしかできなかったコネクタが、メールの作成・送信やファイル操作にまで対応したのです。
そのこと自体は歓迎すべき進化です。けれど私は少し背筋が冷えました。私の夜間ジョブは、コネクタが差し出すツールを状況に応じて自律的に選びます。昨日までは、どれだけ判断を誤っても最悪「読み違える」だけで済みました。今夜からは、同じ判断ミスが「送ってしまう」「消してしまう」に化けます。危険は私のコードが変わった瞬間ではなく、連携先のツール表面が静かに広がった瞬間に生まれていたのです。
2026年7月には Microsoft 365 コネクタが書き込みツールに対応し、メールの作成・送信・整理、カレンダー操作、OneDrive や SharePoint のファイル作成・更新まで扱えるようになりました。人が横にいれば「送信」ボタンの前で一呼吸置けます。無人運用では、その一呼吸を自分で設計に埋め込んでおかない限り、誰も止めてくれません。個人開発で4サイトを無人運用している立場から、ツールが増えたことを検知し、未承認の書き込みを実行前に堰き止める関所を、実際に動くコードとして組み立てていきます。落とし穴は、コネクタが賢くなるほど静かに深くなります。
危険は「新しいツールが増えた瞬間」に生まれる
無人ジョブのセキュリティを考えるとき、私たちはつい自分のプロンプトや権限設定に目を向けます。けれど、エージェントが実際に呼べる操作の範囲は、最終的にはコネクタが公開するツール一覧が決めています。ここが盲点でした。私のコードも権限スコープも一文字も変えていないのに、連携先がバージョンを上げただけで、実行可能な操作の集合が広がってしまうのです。
読み取り専用のコネクタなら、最悪の失敗は「間違った情報を読む」で止まります。ここに書き込みツールが一つ加わると、失敗の質が変わります。読み違えは巻き戻せますが、送信済みのメールや削除したファイルは簡単には戻りません。つまり監視すべきは「今どんなツールがあるか」ではなく、「先週から何が増えたか」という差分のほうなのです。
方針はこう決めました。ツールの増減、とりわけ書き込み・破壊的なツールが新しく現れたことを毎回検知する。そして未承認のまま実行に進ませない。承認は私が明示的にポリシーファイルへ書き込んだときにだけ成立させる。この「新しく増えた書き込みツールは、既定では拒否」という向きを、無人ジョブの一番外側に置きます。
ツールを read / write / destructive に自動仕分けする
最初の部品は、ツール一覧を危険度で仕分けする分類器です。MCP のツールは名前と説明を持っています。完璧な意味解析は要りません。名前の動詞を手掛かりに三段階へ振り分けるだけでも、実務上は十分に効きます。判断がつかないものは、安全側に倒して「要確認」に集めます。
# classify_tools.py
import re
from enum import IntEnum
class Risk ( IntEnum ):
READ = 0 # 参照のみ。巻き戻し不要
WRITE = 1 # 状態を変える。多くは巻き戻せる
DESTRUCTIVE = 2 # 送信・削除・決済など、巻き戻しにくい
# 破壊的とみなす動詞(送ってしまう・消してしまう類)
DESTRUCTIVE = r " ( send | delete | remove | purge | archive | pay | charge | transfer | deploy | publish | revoke ) "
# 一般的な書き込み動詞
WRITE = r " ( create | update | write | edit | insert | upsert | move | rename | set | add | append | patch | put | post ) "
# 読み取り動詞
READ = r " ( get | list | search | read | fetch | find | query | describe | lookup | view ) "
def classify (tool_name: str ) -> Risk:
name = tool_name.lower()
verb = re.split( r " [ _ \- . ] " , name)[ 0 ] # 先頭トークンを動詞とみなす
if re.fullmatch( DESTRUCTIVE , verb):
return Risk. DESTRUCTIVE
if re.fullmatch( WRITE , verb):
return Risk. WRITE
if re.fullmatch( READ , verb):
return Risk. READ
# 動詞が語彙に無ければ、名前全体で破壊的語を探す
if re.search( DESTRUCTIVE , name):
return Risk. DESTRUCTIVE
if re.search( WRITE , name):
return Risk. WRITE
# それでも不明なら、READ とは決めつけず WRITE 扱いで拾い上げる
return Risk. WRITE
破壊的な動詞に pay や charge を含めているのは、決済系のコネクタを想定しているからです。たとえば Stripe のような課金コネクタが create_charge や refund を差し出すようになれば、無人ジョブが実際にお金を動かせてしまいます。こうした取り返しのつかない操作こそ、既定で止めて事故を回避したい対象です。
ここで大切にしたのは、最後の一行です。語彙に載っていない未知の動詞を READ と楽観視すると、分類器そのものが抜け穴になります。「分からないものは書き込み以上」に寄せておけば、取りこぼしは過剰検知の側に出ます。過剰検知は私が承認一つで解消できますが、見逃した破壊的ツールは翌朝まで気づけません。どちらの誤りを引き受けるかを、あらかじめ決めておくわけです。
ツール表面をスナップショットして差分を取る
分類ができたら、次は「先週との差分」を機械的に出す部分です。コネクタが今公開しているツール一覧を取得し、名前と危険度の対応表として保存します。これがツール表面のスナップショットです。次回は保存済みのスナップショットと突き合わせ、増えたツール・消えたツール・危険度が変わったツールを洗い出します。
# surface_diff.py
import json, hashlib
from pathlib import Path
from classify_tools import classify, Risk
SNAP = Path.home() / ".connector_surface" / "m365.json"
def current_surface (list_tools) -> dict :
"""list_tools(): コネクタのツール定義配列を返す関数を注入する"""
surface = {}
for t in list_tools():
name = t[ "name" ]
surface[name] = {
"risk" : int (classify(name)),
# 説明の変化も検知したいのでハッシュだけ持つ
"desc_hash" : hashlib.sha256(
t.get( "description" , "" ).encode( "utf-8" )
).hexdigest()[: 12 ],
}
return surface
def load_snapshot () -> dict :
if SNAP .exists():
return json.loads( SNAP .read_text())
return {}
def diff (prev: dict , now: dict ) -> dict :
added = {k: now[k] for k in now.keys() - prev.keys()}
removed = {k: prev[k] for k in prev.keys() - now.keys()}
changed = {
k: { "from" : prev[k], "to" : now[k]}
for k in prev.keys() & now.keys()
if prev[k] != now[k] # 危険度や説明ハッシュが変わった
}
return { "added" : added, "removed" : removed, "changed" : changed}
def save_snapshot (now: dict ) -> None :
SNAP .parent.mkdir( parents = True , exist_ok = True )
SNAP .write_text(json.dumps(now, ensure_ascii = False , indent = 2 ))
説明文のハッシュまで持っているのには理由があります。ツール名が同じでも、連携先が中身の挙動を差し替えることがあります。名前だけを見ていると、update_file が「追記」から「上書き」に変わったような静かな仕様変更を見逃します。名前・危険度・説明ハッシュの三点が揃って初めて、変化を変化として捉えられるのです。
未承認の書き込みツールを関所で止める
差分が取れれば、あとは判断です。新しく現れた書き込み・破壊的ツールが、私の承認済みリストに載っているかを確かめます。承認は人間が明示的に書いた台帳、ここでは approved_tools.json にだけ存在します。載っていないツールが実行対象に含まれていたら、ジョブ本体を始める前に止めます。
# preflight_gate.py
import json, sys
from pathlib import Path
from surface_diff import current_surface, load_snapshot, diff, save_snapshot
from classify_tools import Risk
APPROVED = Path.home() / ".connector_surface" / "approved_tools.json"
def approved () -> set :
if APPROVED .exists():
return set (json.loads( APPROVED .read_text()))
return set ()
def preflight (list_tools) -> None :
now = current_surface(list_tools)
d = diff(load_snapshot(), now)
ok = approved()
# 新規 or 危険度が上がった、書き込み以上のツールを洗い出す
suspects = []
for name, meta in d[ "added" ].items():
if meta[ "risk" ] >= Risk. WRITE :
suspects.append((name, meta[ "risk" ]))
for name, chg in d[ "changed" ].items():
if chg[ "to" ][ "risk" ] >= Risk. WRITE and chg[ "to" ][ "risk" ] > chg[ "from" ][ "risk" ]:
suspects.append((name, chg[ "to" ][ "risk" ]))
unapproved = [(n, r) for (n, r) in suspects if n not in ok]
if unapproved:
lines = [ f " - { n } (risk= { Risk(r).name } )" for n, r in unapproved]
sys.stderr.write(
"未承認の書き込みツールを検知しました。実行を中止します: \n "
+ " \n " .join(lines)
+ f " \n 承認するには { APPROVED } に名前を追記してください。 \n "
)
sys.exit( 3 ) # ジョブ本体に入る前に落とす
# 承認済みのものだけならスナップショットを更新して通す
save_snapshot(now)
この関所を入れる前と後で、無人ジョブの初動は次のように変わりました。
場面 関所の導入前 関所の導入後
コネクタが send_message を新たに公開 エージェントが状況次第で選べてしまう 未承認として 実行前に停止 (exit 3)
update_file の挙動が上書きへ変化名前が同じなので誰も気づかない 説明ハッシュの変化を changed で検知
承認済みツールだけの通常運転 — スナップショット更新のみで 素通り
気づくタイミング 事故が起きた後のログ ジョブが動き出す前
exit コードを 3 にしているのは、通常のエラー(1)や引数不正(2)と区別して、上位のスケジューラ側で「これは安全側の停止であって障害ではない」と扱い分けたいからです。停止と失敗を同じ色で塗ると、翌朝の判断がぶれます。
実行前に「書くつもり」を台帳へ落とす
関所は「未承認のツールを止める」役割ですが、承認済みのツールでも、初回の数回は本当に実行させたくない場面があります。そこで二段目として、書き込み系の呼び出しを実際には行わず、「何を送る・消すつもりだったか」を台帳へ書き出すドライラン層を挟みます。翌朝それを読み、意図通りなら承認を昇格させる、という運用です。
# dry_run_ledger.py
import json, time
from pathlib import Path
from classify_tools import classify, Risk
LEDGER = Path.home() / ".connector_surface" / "write_intents.jsonl"
DRY_RUN = True # 慣らし期間は True。信頼できたら False へ
def guarded_call (tool_name: str , args: dict , real_call):
risk = classify(tool_name)
if risk >= Risk. WRITE and DRY_RUN :
record = {
"ts" : time.strftime( "%Y-%m- %d T%H:%M:%S" ),
"tool" : tool_name,
"risk" : Risk(risk).name,
"args" : args, # 送るつもりだった中身をそのまま保存
"executed" : False ,
}
with LEDGER .open( "a" , encoding = "utf-8" ) as f:
f.write(json.dumps(record, ensure_ascii = False ) + " \n " )
return { "dry_run" : True , "logged" : tool_name}
# READ か、ドライランを解除した書き込みだけが本当に実行される
return real_call(tool_name, args)
台帳は JSONL にしています。1行1意図なので、翌朝 grep で send_message だけ拾ったり、jq で件数を数えたりが軽くできます。ここで肝心なのは、ドライランでも args を丸ごと保存しておくことです。「送信を止めた」だけでは翌朝の私は判断できません。「誰に、どんな件名で送るつもりだったか」まで残って初めて、承認してよいのかを落ち着いて決められます。
実測:静かな事故が予定された確認へ変わるまで
この二段構え(関所+ドライラン台帳)を4サイトの夜間ジョブに入れて2週間ほど運用した結果を、実際の集計から示します。数字は私の環境のもので、コネクタの更新頻度に依存しますが、傾向はつかめるはずです。
指標 導入前(2週間) 導入後(2週間)
新たに検知した書き込みツール 0(そもそも見ていない) 6
うち破壊的(send / delete 系) — 2
未承認で実行前に停止した回数 0 4
ドライラン台帳に記録した書き込み意図 — 31
意図しない送信・削除の実行 把握できず 0
翌朝レビューの平均所要(1日あたり) — 約3分
数字以上に効いたのは、気づく時点が「事故の後」から「実行の前」へ動いたことでした。導入前は、危険なツールが増えたかどうかを私はそもそも観測していませんでした。導入後は、6個の新しい書き込みツールのうち2個(約33%)が送信・削除系だと事前に分かり、そのうち4回は承認前に堰き止められました。31件のドライラン記録を翌朝3分ほどで眺め、意図通りのものだけを承認へ昇格させる。派手な仕組みではありませんが、無人運用の安心はこういう地味な段取りから生まれると感じています。
コネクタの健全性そのものを継続的に測りたい場合は、コネクタの可観測性を自前で計装する設計 と組み合わせると、ツール表面の変化と稼働状態の変化を同じ台帳の上で並べて見られます。停止をエラーと混同しない扱いについては、安全上の理由でモデルが応答を断ったときのパイプライン設計 が地続きの話です。
どこまで自動化し、どこで手を止めるか
ここまでを一つの原則にまとめると、こうなります。読み取りは自動で通してよい。書き込みは差分で監視する。破壊的な操作は、既定では止めて人の一呼吸を挟む。私はこの向きを既定にすることを強く推奨します。自動化の目的は人を締め出すことではなく、人が判断すべき一点に労力を集めることです。31件の意図を毎回自分でさばくのは大変ですが、そのうち送信・削除の2件だけに目を凝らすのなら続けられます。
スナップショットとポリシーファイルは、それ自体が運用の記録になります。いつ、どのツールを、なぜ承認したのか。approved_tools.json の履歴を git で追えるようにしておくと、半年後の自分が「この破壊的ツールをなぜ許したのか」を辿れます。無人で回すからこそ、判断の痕跡を残す。実行の証跡を最後に必ず突き合わせる考え方は、定期処理の静かな成功を末尾のアサーションで検証する設計 にも通じます。
コネクタが賢くなるほど、それが差し出すツールの表面は静かに広がっていきます。増えたこと自体は喜ばしい進化です。ただ、その進化を無人ジョブに迎え入れる速度は、自分の手で決めてよいのだと思います。私自身まだ運用しながら調整を続けている途中ですが、同じように複数の処理を夜に委ねている方の、静かな支えになれば嬉しいです。お読みいただきありがとうございました。