スケジュール実行のいちばん怖い瞬間は、処理が失敗したときではありません。意図していない端末で、何事もなく成功してしまったとき です。私自身、複数のサイトの記事更新を時間をずらして無人で回していますが、メインの Mac とは別に作業用のマシンも持っていて、両方に同じトークンや設定が同期されてしまうことがあります。そういうとき、「本来動かすつもりのない端末で、夜中にひっそり同じ処理が二重に走っていた」という事故は、画面を誰も見ていないぶん、気づくのがいつも遅れます。
2026年6月28日のアップデートで、Claude Code に Trusted Devices が入りました。Team / Enterprise 向けに、リモートで Claude Code セッションを始める前に端末そのものを検証する仕組みです。個人プランでは使えませんが、「どの端末から動かしているか」を運用の起点に置くという発想は、一人の小さな自動運用にこそ効きます。ここでは、その考え方をコードに落として、許可した端末以外では処理が始まらないようにする方法を書いていきます。
守りたいのは「モデル」ではなく「動かしている端末」
無人運用のセキュリティというと、つい API キーの強さや権限スコープに目が向きます。もちろんそれも大事です。ただ、個人開発で実際に困るのは、もっと地味な「実行主体のずれ」のほうでした。
具体的には、こういう場面です。古いノート PC を初期化せずに放置していたら、同期していた cron 設定が生きていて勝手に走っていた。VM を複製して検証用にしたつもりが、本番と同じスケジュールを引き継いでいた。クラウド同期フォルダに置いたスクリプトが、想定外のマシンでもマウントされていた。どれも「不正アクセス」ではなく、自分の資産が、自分の想定とずれた場所で動いている という問題です。
Trusted Devices が示しているのは、まさにこの軸です。鍵を持っているかどうかではなく、その鍵を使っている端末を運用者が事前に把握しているか。一人運用に翻訳するなら、「許可した端末の一覧を自分で持ち、それ以外では資格情報を読ませない」という最小限のゲートになります。
Trusted Devices が埋めるもの、一人運用に残る穴
まず、公式機能とのスコープの違いを整理しておきます。借りられる発想と、自前で埋める必要がある穴がはっきりします。
観点 Trusted Devices(Team / Enterprise) 一人運用で自前にする部分
検証の対象 リモートセッションを始める端末 cron / launchd が走る端末
許可リストの管理 管理者がコンソールで承認 自分で許可端末リストを 1 ファイルで管理
拒否時の挙動 セッション開始をブロック 資格情報を読む前に exit し、ログと通知を残す
適用範囲 対話的・リモート操作が中心 完全無人のバッチ処理が中心
一人運用で本当に欲しいのは、対話の入口ではなくバッチの入口 を守ることです。深夜のスケジュール実行はリモートセッションではないので、Trusted Devices の対象外です。だからこそ、同じ発想を「実行直前のプリフライト」として自分で持っておく価値があります。
端末を一意に、壊れにくく識別する
ゲートの土台は「端末をどう識別するか」です。ここを間違えると、正規の端末まで弾いてしまい、運用が止まります。
結論から書くと、hostname・IP アドレス・MAC アドレスは識別子に使わないでください 。私は最初これらで組んでしまい、Wi-Fi を切り替えただけで自分の Mac が弾かれて、原因究明に小一時間を溶かしました。これらは「端末の所在」を表すものであって、「端末そのもの」を表す値ではないのです。
代わりに、OS が持つ安定したハードウェア/インストール識別子を使います。
識別子 安定性 判定
hostname ユーザーがいつでも変更できる 使わない
IP / MAC アドレス ネットワークや乱数化で揺れる 使わない
macOS: IOPlatformUUID ハードウェアに紐づき安定 使う
Linux: /etc/machine-id OS インストール単位で安定 使う
両 OS で同じ関数として取れるよう、薄いラッパーにしておきます。
#!/usr/bin/env bash
# device_id.sh — 端末の安定識別子を1行で返す(macOS / Linux 両対応)
set -euo pipefail
device_id () {
case "$( uname -s )" in
Darwin )
# IOPlatformUUID はハードウェアに紐づき、再起動やネット変更で変わらない
ioreg -d2 -c IOPlatformExpertDevice \
| awk -F \" '/IOPlatformUUID/{print $4; exit}'
;;
Linux )
# machine-id は OS インストール単位で安定。systemd 系・非 systemd 系の両方を見る
cat /etc/machine-id 2> /dev/null || cat /var/lib/dbus/machine-id
;;
*)
echo "unsupported-os" >&2
return 1
;;
esac
}
device_id
生の UUID をそのまま許可リストに書いてもよいのですが、私は短いハッシュに畳んでから扱うようにしています。リストを誤って共有してしまっても、端末の生 UUID を晒さずに済むからです。
# 生 UUID を SHA-256 の先頭12桁に畳む(リストに書くのはこの値)
short_id () { device_id | shasum -a 256 | cut -c1-12 ; }
「許可した端末からだけ動く」プリフライトガード
識別子が安定して取れれば、ゲート本体はとても小さく書けます。許可リストは 1 ファイルに 1 行 1 端末で持ち、実処理の前に必ずこのガードを通します。
#!/usr/bin/env bash
# preflight_device_guard.sh — 許可端末以外では資格情報を読む前に止める
set -euo pipefail
HERE = "$( cd "$( dirname " $0 ")" && pwd )"
source " $HERE /device_id.sh"
ALLOWLIST = " $HERE /allowed_devices.txt" # 1行に short_id + 任意のラベル
ID = "$( short_id )"
# コメント(#始まり)と空行を除いて、先頭トークンだけを照合する
if ! awk 'NF && $1!~/^#/{print $1}' " $ALLOWLIST " | grep -qx " $ID " ; then
echo "BLOCKED: device $ID is not on the allowlist" >&2
exit 70 # 資格情報を一切読まずにここで終了する
fi
echo "OK: trusted device $ID "
許可リストは、こういう素朴な形で十分です。
# allowed_devices.txt — 許可した端末だけを並べる
a1b2c3d4e5f6 main-mac-studio # 本番の自動投稿はこの端末から
9f8e7d6c5b4a backup-mini # 障害時の予備
ポイントは、ガードを「処理の最初の一歩」ではなく「資格情報を読む前の一歩」に置く ことです。先にトークンを環境変数へ展開してからチェックしても、すでにメモリにキーが乗っているので意味が薄れます。順序は「端末を確認 → 通れば資格情報を読む → 実処理」を厳守します。
cron や launchd からは、実処理スクリプトを直接呼ばず、ガード経由で呼びます。
#!/usr/bin/env bash
# run_daily_job.sh — スケジューラはこれを叩く
set -euo pipefail
HERE = "$( cd "$( dirname " $0 ")" && pwd )"
# 端末が許可されていなければ exit 70 でここで止まる
" $HERE /preflight_device_guard.sh"
# ここから先は許可端末でのみ到達する
source " $HERE /load_credentials.sh" # 端末チェック後にだけ資格情報を読む
exec " $HERE /post_articles.sh"
資格情報を端末に縛る
ガードだけでも「うっかり別端末で走る」事故はほぼ防げます。ただ、ファイルをコピーすればガードを外して動かせてしまうので、もう一段、資格情報そのものを端末に紐づけておくと安心です。
手堅いのは、API キーを平文で置かず、端末識別子から導いた鍵で復号する形にすることです。下のローダは、許可端末でしか平文キーが得られないようにします。別の端末に丸ごとコピーしても、short_id が一致しないので復号に失敗します。
#!/usr/bin/env python3
# load_credentials.py — 端末識別子に紐づけて API キーを復号する
import base64, hashlib, hmac, os, subprocess, sys
def short_id () -> str :
out = subprocess.check_output(
[ "bash" , os.path.join(os.path.dirname( __file__ ), "device_id.sh" )]
).strip()
return hashlib.sha256(out).hexdigest()[: 12 ]
def _stream_key (device_short_id: str , salt: bytes , length: int ) -> bytes :
# 端末識別子 + salt から決定的にキーストリームを作る(依存追加なしの簡易版)
seed = device_short_id.encode()
out, counter = b "" , 0
while len (out) < length:
out += hmac.new(seed, salt + counter.to_bytes( 4 , "big" ), hashlib.sha256).digest()
counter += 1
return out[:length]
def load_api_key (enc_path: str ) -> str :
raw = base64.b64decode( open (enc_path, "rb" ).read())
salt, payload = raw[: 16 ], raw[ 16 :]
stream = _stream_key(short_id(), salt, len (payload))
plain = bytes (a ^ b for a, b in zip (payload, stream))
if not plain.startswith( b "sk-" ): # 端末違いだと復号がゴミになり、ここで弾ける
print ( "BLOCKED: credential is not bound to this device" , file = sys.stderr)
sys.exit( 70 )
return plain.decode()
if __name__ == "__main__" :
os.environ[ "ANTHROPIC_API_KEY" ] = load_api_key(sys.argv[ 1 ])
print ( "credential loaded for this device" )
ここで使っている sk- の接頭辞チェックは、復号が正しく行われたかの安価な健全性確認です。別端末でコピーして実行すると short_id が変わり、復号結果が壊れて接頭辞が一致しなくなるため、平文キーがメモリに乗る前に止まります。なお、より厳密にやるなら cryptography などの認証付き暗号(AES-GCM)に置き換えてください。ここでは依存を増やさず仕組みを示すために、標準ライブラリだけで書いています。
実運用では、暗号化済みファイルは許可端末ごとに作り分けます。新しい端末を許可するときに、その端末上で一度だけ暗号化スクリプトを走らせて *.enc を生成する、という運用にすると、鍵の生成と端末の承認が自然に一対一で揃います。
拒否を黙って終わらせない
ここが、私が過去にいちばん痛い目を見た部分です。ガードを入れた当初、拒否されたときに静かに exit するだけにしていました。すると、メインの Mac を OS 再インストールして識別子が変わったとき、毎晩のジョブが全部「成功扱いで何もせず終わる」状態 に陥り、二日ほど更新が止まっているのに誰も気づきませんでした。
拒否は失敗です。失敗は、必ず見える場所に出さなければいけません。ガードを少し足して、ブロック時にログと通知を残すようにします。
# preflight_device_guard.sh の末尾を差し替え
if ! awk 'NF && $1!~/^#/{print $1}' " $ALLOWLIST " | grep -qx " $ID " ; then
MSG = "BLOCKED device= $ID host=$( hostname ) at $( date -u +%FT%TZ)"
echo " $MSG " >> " $HERE /device_guard.log"
# 通知は Webhook 1本で十分(URL は環境変数に置く)
if [ -n "${ ALERT_WEBHOOK :- }" ]; then
curl -fsS -X POST " $ALERT_WEBHOOK " \
-H 'Content-Type: application/json' \
-d "{ \" text \" : \" $MSG \" }" > /dev/null || true
fi
exit 70
fi
通知先は何でも構いませんが、「拒否されたこと」と「成功して何もしなかったこと」が見分けられる状態 にしておくことが肝心です。終了コードを 0 ではなく 70 のような専用の値にしておくと、スケジューラ側のログでも「これは正常終了ではなくブロックだ」と一目で判別できます。
つまずきどころ(再インストール・VM クローン・CI)
最後に、本番運用で必ず踏む 3 つの落とし穴と、その対処法を共有します。どれも私が実際に踏んで原因究明に時間を溶かしたものです。
1. OS 再インストールで識別子が変わる
OS の再インストールやメジャーアップグレードで、Linux の machine-id は変わることがあります。macOS の IOPlatformUUID は基本的に維持されますが、ロジックボード交換では変わります。対処は単純で、識別子が変わったら許可リストを更新する という手順を最初から運用に組み込んでおくことです。私はこの更新を 1 行のスクリプト(現在の short_id を表示してコピーするだけ)にして、再インストール直後のチェックリストに入れることを推奨します。
2. VM クローンで machine-id ごと複製される
VM やコンテナを複製すると、machine-id まで一緒に複製されることがあります。複製した検証環境が本番と同じ識別子を名乗ってしまうと、ガードをすり抜けるという厄介な罠です。対処として、テンプレートから VM を作る運用なら、初回起動で machine-id を再生成する(systemd 系なら machine-id を空にして再生成させる)手順を必ず挟みます。
3. CI ではランナーの識別子が毎回変わる
CI 上で同じジョブを走らせたい場合は、ランナーの識別子が毎回変わるため、このガードはそのままでは通りません。この場合は端末固定の代わりに、OIDC ベースの短命資格情報など別の軸で守るのが筋です。私は端末ピン留めを「自分が物理的に管理している決まった端末で回す自動処理」に限定して使うことを個人的に勧めます。適用範囲を割り切っておくと、CI とローカル運用を混同せずに済みます。
次の一歩
まずは今夜のジョブに、上の preflight_device_guard.sh を 1 枚だけ噛ませてみてください。許可リストには、いま動かしているその端末の short_id を 1 行書くだけです。資格情報の端末縛りや暗号化は、その先で必要になってから足せば十分です。「どの端末から回しているか」を一度ファイルに書き出すだけでも、無人運用の見通しは驚くほど良くなります。私自身、ここを明文化してから、夜中の処理に対する漠然とした不安がずいぶん軽くなりました。