🛡️ launchdスケジュールが勝手に書き換えられていた——plist整合性ガードを作るまで
2026年8月23日12時04分、わたしの営業DMが深夜2時31分・10時31分・18時31分の1日3回だけ動く設定に書き換えられていた。被害は気づくまでの数日間、毎日数十件の機会損失だった。犯人は今も特定できていない。
なぜこの仕組みが効くのか
自動化が「止まっていること」に気づけない問題
個人開発で稼ぐ仕組みは、突き詰めると「自動化の稼働率=収益」という等式に行き着きます。月10万の大学生だったわたしが掛け持ち月60万になり、会社都合で解雇されてゼロに戻り、半年でClaude Codeの自律環境を組み上げて月商120万を作れたのは、人間が寝ている間も動き続ける営業DMのレーンがあったからです。
営業DMの自動送信は4本のlaunchd jobで構成されています。Instagram・Threads・フォロワー開拓・YouTubeと、プラットフォームごとに独立したplistとPythonスクリプトが存在します。これらはStartCalendarIntervalで起動時刻を制御しており、たとえばcom.lily.outreach-igは8:20・10:20・12:20・14:20・16:20・18:20・20:20・22:20の8回、com.lily.outreach-thは9:38から21:38まで2時間おきに7回、日中の時間帯に送信が走ります。
問題は、launchdのplistは単なるXMLファイルであり、誰でも(あるいは何でも)書き換えられるという点です。macOSはplistが変更されてもユーザーに通知しません。launchctl listでジョブが"running"と表示されていても、スケジュール自体は書き換えられた後の値で動いている可能性があります。8月23日の件がまさにそれで、ジョブは生きていた。ただし「1日3回・深夜帯」という別の設定で。
気づいたきっかけは、ある日の夕方に手動でDM送信数のログを見たことでした。12時台と16時台にゼロが続いていたので調べると、com.lily.outreach-igのplist内StartCalendarIntervalが2:31 / 10:31 / 18:31の3エントリだけになっていました。同時にcom.lily.outreach-thも7回から3回に減らされていた。mtime(最終更新時刻)は2026-08-23 12:04でした。
この経験から学んだのは、「動いている」と「正しく動いている」は別物だということです。監視すべきはプロセスの生死ではなく、plist内の設定値そのものです。
「戻す」だけでなく「記録する」設計の価値
単純な整合性チェックなら、cronやWatch Pathsで実装する方法もあります。しかしわたしが今回こだわったのは、復元と同時にforensic(法的証拠)ログを残すという設計でした。
理由は二つあります。一つ目は再現性の確保です。同じ書き換えが繰り返されるなら、どのプロセスが直前に走っていたかのスナップショットがあれば犯人候補を絞れます。二つ目は「なぜ変わったのかを後から追える」安心感です。自動化の環境は複雑で、自分がどこかのスクリプトで誤って書き換えた可能性も否定できません。ログがあれば「自分のミスか外部の干渉か」の切り分けができます。
outreach-schedule-guard.shが検知時にやることは4つです。mtime付きで書き換えを記録する・直前のプロセス一覧を残す・plistをタイムスタンプ付きバックアップとしてコピーする・正しい値に戻してlaunchdに再読み込みさせる。このうちforensicの部分はスクリプト50行のうち半分近くを占めています。「動けばいい」と割り切って復元だけにすることもできましたが、原因究明を諦めたくなかった。
launchd plistが持つ構造の急所
StartCalendarIntervalの構造を理解すると、なぜpython3のplistlibで読み取ることが最善なのかがわかります。
launchd plistはXML形式(またはplutil -convert binary1でバイナリ形式)で保存されます。com.shun.self-repair.plistを例に取ると、以下のような配列構造になっています。
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>20</integer>
</dict>
<dict>
<key>Hour</key>
<integer>13</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
<dict>
<key>Hour</key>
<integer>19</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
</array>
このHourとMinuteが整数型で格納されているという点が肝です。シェルスクリプトでXMLをgrepやawkでパースするのは型の保証がなく、バイナリplistにはまったく通用しません。python3 -c "import plistlib" でplistを読めば、テキスト形式でもバイナリ形式でも同じAPIで扱え、Hourの値は確実にint型として取り出せます。
書き換え検知はこの読み取り値とSPECS配列の期待値を文字列比較するだけです。期待値は"8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20"という形式で生成し、実際のplistから取り出した値と一致しなければアラートを出します。
全体の流れ
アーキテクチャ概要図
定期実行(launchd job)
│
▼
outreach-schedule-guard.sh
│
├─ SPECS配列から期待スケジュールを生成
│ com.lily.outreach-ig → 8,10,12,14,16,18,20,22 時 :20
│ com.lily.outreach-th → 9,11,13,15,17,19,21 時 :38
│ com.lily.followers-outreach → 8,10,12,14,16,18,20,22 時 :35
│ com.lily.outreach-yt → 10,12,14,16,18,20 時 :52
│
├─ python3 plistlib で各 plist の StartCalendarInterval を実読み取り
│
├─ [一致] → 処理なし・終了
│
└─ [不一致] ─────────────────────────────────────────┐
│
① forensicログ出力 │
- plist の mtime を記録 │
- 現在値 vs 期待値を並べて記録 │
- ps で python/node/bash/launchctl/plutil │
の直近プロセスをキャプチャ(最大25件) │
│
② バックアップ作成 │
$label.plist.bak-guard-YYYYmmdd-HHMMSS │
│
③ python3 plistlib で正しい値に書き戻し │
d['StartCalendarInterval'] = [ │
{'Hour': int(h), 'Minute': minute} │
for h in hours.split(',') │
] │
│
④ plutil -lint で整合性検証 │
OK → launchctl bootout → sleep 1 │
→ launchctl bootstrap → 復元ログ │
NG → "🔴 復元後のplistが壊れている"ログ │
→ 手動対応に委ねる │
コアロジックの実装:plist読み取りパート
スクリプトはシェルのforループで4本のジョブを順番に処理します。各反復の最初にやることは、plistをpython3のヒアドキュメントで読み取り、Hour:MinuteのCSVを標準出力に吐くことです。
actual="$(python3 - "$plist" <<'PY'
import plistlib, sys
try:
with open(sys.argv[1],'rb') as f: d = plistlib.load(f)
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]
print(','.join(f"{r.get('Hour')}:{r.get('Minute')}" for r in rows))
except Exception as e:
print('ERR')
PY
)"
open(sys.argv[1], 'rb')でバイナリモードで開いているのは、plistがXML形式でもバイナリ形式でもplistlib.load()が自動判定するためです。rows = d.get('StartCalendarInterval') or []としているのは、エントリが存在しない場合のNone対策です。またisinstance(rows, dict)のチェックは、StartCalendarIntervalが配列ではなく単一の<dict>として記述されていた場合(1時刻のみ登録したケース)に壊れないための防衛処理です。
出力されるactualは"8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20"のような文字列になります。
コアロジックの実装:期待値生成と比較パート
SPECS配列の各エントリは"label:minute:hours"という独自形式で定義されています。
SPECS=(
"com.lily.outreach-ig:20:8,10,12,14,16,18,20,22"
"com.lily.outreach-th:38:9,11,13,15,17,19,21"
"com.lily.followers-outreach:35:8,10,12,14,16,18,20,22"
"com.lily.outreach-yt:52:10,12,14,16,18,20"
)
ここからlabel・minute・hoursをシェルのパラメータ展開で分解し、expectedを組み立てます。
label="${spec%%:*}"; rest="${spec#*:}"
minute="${rest%%:*}"; hours="${rest#*:}"
続いてIFS=','で時間の配列を分割し、"Hour:Minute"形式のCSVを生成します。outreach-igならexpectedは"8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20"になります。
[ "$actual" = "$expected" ] && continueの1行が判定の本体です。一致すればそのジョブは素通りし、次のジョブへ。不一致があった瞬間から、forensic記録と復元のシーケンスが走ります。
コアロジックの実装:復元パート
書き戻しも読み取りと同じくpython3のヒアドキュメントで行います。
python3 - "$plist" "$minute" "$hours" <<'PY'
import plistlib, sys
path, minute, hours = sys.argv[1], int(sys.argv[2]), sys.argv[3]
with open(path,'rb') as f: d = plistlib.load(f)
d['StartCalendarInterval'] = [{'Hour': int(h), 'Minute': minute} for h in hours.split(',')]
with open(path,'wb') as f: plistlib.dump(d, f)
PY
sys.argv[2]でminuteを受け取りint()にキャストするのは、SPECS文字列から来た値がstr型だからです。plistlib.dump(d, f)はデフォルトでXML形式(fmt=plistlib.FMT_XML)で書き出すため、元のバイナリplistをXMLに変換してしまう副作用があります。ただしlaunchdはXML形式のplistも問題なく読み込むため、実用上は支障ありません。
書き戻し後はplutil -lint "$plist"でXML構造を検証します。launchdに壊れたplistを食わせると予期しない動作を引き起こすため、このゲートは省略できません。検証が通った場合のみ、以下の順序でlaunchdへ再登録します。
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
sleep 1
launchctl bootstrap "gui/$(id -u)" "$plist"
bootoutとbootstrapの間にsleep 1を挟むのはlaunchdが内部状態を更新する猶予を与えるためです。これを省くとbootstrapがエラーを返すケースがあります。bootoutの失敗は2>/dev/nullで無視しているのは、すでにunloadされていた場合でもbootstrapさえ通れば問題ないからです。
forensicログの実装
書き換えを検知した瞬間に残す情報は3種類です。
log "$label: 書き換え検知 mtime=$(stat -f '%Sm' -t '%F %T' "$plist")"
log "$label: 現在 = $actual"
log "$label: あるべき= $expected"
ps -Ao pid,lstart,comm | tail -n +2 | \
grep -iE "python|node|bash|launchctl|plutil" | tail -25 | \
while read -r l; do log "$label: ps> $l"; done
stat -f '%Sm' -t '%F %T'はmacOS固有の書式で、plistの最終更新時刻をYYYY-MM-DD HH:MM:SS形式で取得します。これが「いつ書き換えられたか」の唯一の物理的証拠です。
psのフィルタでpython|node|bash|launchctl|plutilを対象にしているのは、plistを書き換えられる手段がおおむねこれらのコマンドに限られるからです。tail -25で最大25行に絞り、ログが肥大化しないようにしています。
8月23日12時04分の実際のインシデントでは、このforensicログが残っていれば容疑プロセスの候補を絞れたはずです。皮肉なことに、ガードスクリプト自体はインシデントの後に作ったものなので、当時のpsスナップショットはありません。「作っておけばよかった」という後悔から設計したのが、このforensic記録の仕組みです。
実装の詳細
set -uo pipefail ── -e を外した理由
スクリプトの冒頭は set -uo pipefail です。-e がありません。
set -uo pipefail
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
最初は -euo pipefail で書いていました。「エラーが出たら即終了」がベストプラクティスのはずだからです。ところが、forensicのps出力部分でスクリプトが突然死するバグが出ました。原因はここです。
ps -Ao pid,lstart,comm | tail -n +2 | while read -r p rest2; do echo "$p $rest2"; done \
| grep -iE "python|node|bash|launchctl|plutil" | tail -25 \
| while read -r l; do log "$label: ps> $l"; done
grep -iE "python|node|bash|..." は、マッチする行がゼロ件のとき終了コード1を返します。set -e 環境では終了コード非ゼロ=即exit扱いなので、怪しいプロセスが一件も走っていない正常な状態でスクリプトが死ぬ、というバグでした。forensicパートは「何もなければ何も出力しない」が正しい動作なのに、-e のせいで「何もなければ死ぬ」になっていた。
-e を外して pipefail だけ残すことで、意図しないサイレント失敗(パイプ途中でのエラー)は検知しつつ、grep空振りはそのまま継続できるようになりました。
PATH の明示とlog関数
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
LOG="$HOME/.claude/logs/outreach-schedule-guard.log"
launchdジョブはユーザーのシェル環境を継承しません。TerminalからPATHが通っているように見えても、launchdが起動したシェルでは /opt/homebrew/bin が存在しないことがあります。python3 はHomebrew経由でインストールしているため、PATHを明示しないとジョブ起動直後に python3: command not found でサイレントに落ちます。この経験から、launchd用のシェルスクリプトでは必ず冒頭でPATHを上書きすることを癖にしました。
ログ関数は printf ベースです。
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
最初は echo "$(date '+%F %T') $*" >> "$LOG" と書いていました。しかし echo はシェル実装や -e オプションの有無によって挙動が変わります。ログメッセージの末尾に書き込む文字列が \n を含む場合、echoが展開するかどうかで出力が変わります。printf はフォーマット文字列と値を明確に分離するので、ログ出力の改行・空白がどの環境でも同じになります。
plistが存在しない場合のガード
[ -f "$plist" ] || { log "$label: plist が無い"; continue; }
SPECS配列に定義したラベルのplistが実際に存在しない場合、python3のopen()がエラーを出してスクリプトが止まります。存在チェックを入れてログを残してから次のジョブに進む、という設計にしています。「plistが無い」がログに残ることで、launchd側でアンロードしたのか、ファイルごと削除されたのかを後から判断できます。
バックアップのネーミング
復元前に現状のplistを必ずコピーします。
cp "$plist" "$plist.bak-guard-$(date +%Y%m%d-%H%M%S)"
たとえば com.lily.outreach-ig.plist.bak-guard-20260823-120404 というファイルが残ります。タイムスタンプをファイル名に焼き込む理由は、同じplistが複数回書き換えられたときに、どの時点のものかをlsだけで確認できるからです。拡張子を .bak でなく .bak-guard-日時 にしているのも意図的で、launchdが誤って読み込まないよう、plistとして認識されにくい名前にしています。
なお現在、バックアップの自動削除処理は入れていません。同じplistが短期間に何度も書き換えられると .bak が増殖しますが、それはそれで「書き換えが繰り返されている」という証拠としての価値があるので、削除は手動にしています。
self-repairジョブのplistが教えてくれること
com.shun.self-repair.plist は、outreach-schedule-guardスクリプト本体を定期起動するlaunchdジョブのplistです。このplistを直接読むと、バックグラウンドジョブの設計として参考になる値がいくつかあります。
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
<key>RunAtLoad</key>
<false/>
LowPriorityIO: true はIOの優先度を下げ、他のプロセスのディスクアクセスを妨げない設定です。ガードスクリプトはplistのread/writeとログ書き込みしかしないので、低IO優先で問題ありません。
Nice: 10 はCPU優先度を10段階下げます。0がデフォルトで値が大きいほど優先度が低くなります。監視ジョブがCPUを食い荒らしてメインの営業DMスクリプトを遅らせては本末転倒なので、明示的に下げています。
ProcessType: Background はmacOSのActivity Monitorで「バックグラウンド」カテゴリに分類されることを示します。LowPriorityIO との組み合わせで、OSのバッテリー・CPU最適化の対象に含まれます。
RunAtLoad: false は起動直後の即時実行をしない設定です。launchdはジョブをロードした瞬間に一度トリガーする機能を持っており、true にすると launchctl bootstrap した瞬間に走ります。ガードスクリプトはStartCalendarIntervalで定刻に走れば十分なので、不要な即時実行は切っています。
StartCalendarInterval は9:20・13:30・19:30の1日3回です。営業DMが8時から22時まで動いているのに対してガードを3回だけにしているのは、書き換えは連続的に発生するものではなく「一斉に書き換えられてしばらく気づかない」パターンだからです。1日3回で十分に検知できるし、チェック自体がplistをread/writeするのでその分を最小化しています。
私が詰まった話
詰まり①:python heredocが変数展開して壊れた
最初に書いたコードは <<'PY' ではなく <<PY でした。
# NG: クォートなしヒアドキュメント
actual="$(python3 - "$plist" <<PY
import plistlib, sys
...
PY
)"
クォートなしの <<PY はシェルがヒアドキュメントの中身を変数展開します。Pythonコードの中に $() や $変数 が一切なくても、たとえば sys.argv[1] の [1] がzsh環境でグロブ展開の対象になるケースがあります。実際には [1] は展開されませんでしたが、別の行で f"{r.get('Hour')}:{r.get('Minute')}" 内の { と } がシェルに解釈されてSyntax errorが出ました。
<<'PY' とシングルクォートで囲むことで、ヒアドキュメントの内容がそのままPythonインタープリタに渡されます。以来、Bashからpython/ruby/nodeのコードをヒアドキュメントで渡すときは必ず <<'EOXX' 形式にしています。
詰まり②:StartCalendarInterval が単一エントリのとき dict が返る
plistlib でplistを読んだとき、StartCalendarInterval の値は通常list(Python配列)です。しかしplistに <array> タグなしで <dict> が直接書かれていた場合、plistlibはdictを返します。
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>10</integer>
<key>Minute</key><integer>0</integer>
</dict>
最初は rows = d.get('StartCalendarInterval') or [] として for r in rows を回していました。dictをイテレートすると 'Hour' と 'Minute' という文字列キーが返るので、r.get('Hour') は文字列に対するgetになりNoneが出ます。出力は "None:None" になり、比較は当然不一致→毎回復元処理が走る、という無限ループ状態になりました。
# 修正後
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]
isinstance(rows, dict) チェックで単一dictをリストに包む1行が解決策でした。これを入れるまで、1時刻だけ設定したテスト用plistを使って動作確認するたびに復元ログが出続けていたので、最初はロジックのバグだと思って何時間も別の場所を探し回りました。
詰まり③:${expected:+,} を知らずに先頭カンマが入り続けた
期待値のCSV生成で最初に書いたコードはこれです。
expected=""
for h in "${harr[@]}"; do expected="${expected},${h}:${minute}"; done
com.lily.outreach-ig の場合、ループが回ると expected は ",8:20,10:20,12:20..." になります。先頭にカンマが付きます。当然 actual("8:20,10:20,..." )と一致しません。毎回不一致→毎回復元処理、という状態になり、ガードスクリプト自体が毎回plistを書き換えていました。
Bashの変数展開 ${var:+value} は「varが空でなければvalueを展開、空ならば空文字」という意味です。
for h in "${harr[@]}"; do expected="${expected}${expected:+,}${h}:${minute}"; done
${expected:+,} とすることで「すでに何かあるときだけカンマを先に置く」動作になります。最初の要素ではexpectedが空なのでカンマは挿入されず、2番目以降は , が入る。結果として "8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20" という正しいCSVになります。
Bashのパラメータ展開は :-(デフォルト値)や :=(代入)はよく知っていたのに、:+(非空時のみ展開)はこのバグを踏むまで意識したことがありませんでした。
詰まり④:launchctl bootstrap が即座にエラーを返した
復元処理の最初のバージョンは bootout の直後に bootstrap を呼んでいました。
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
launchctl bootstrap "gui/$(id -u)" "$plist" # sleep なし
この順番で実行すると、bootstrap が Load failed: 5: Input/output error を返して失敗しました。launchdは bootout を受け取った後、内部的にジョブの終了処理を非同期で完了させます。bootout の返り値が0でも、launchdの内部状態がまだクリアされていない瞬間に bootstrap を叩くと競合してエラーになります。
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
sleep 1
if launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null; then
log "$label: 復元して再読込した"
else
log "$label: 🔴 bootstrap に失敗した(手動確認が要る)"
fi
sleep 1 を挟むことで解消しました。「1秒待つ」という解決策はエレガントではありませんが、launchdのドキュメントには非同期完了の保証に関する記述がなく、経験的に1秒が安全マージンとして十分であることが確認できたため、これ以上調べる時間をかけずに採用しました。
現場で動かしてみると「なぜか動かない」→「sleep 1を入れたら動いた」という解決は往々にして理由が不透明なまま先に進みがちですが、ここではログに 🔴 bootstrap に失敗した(手動確認が要る) を残すことで、仮に再度失敗したときに即気づける安全網を作っています。
詰まり⑤:psパイプで read -r p rest2 の rest2 が必要だった理由
forensicのps処理は、実は一度シンプルに書こうとしてうまくいきませんでした。
# 最初のナイーブな実装
ps -Ao pid,lstart,comm | grep -iE "python|node|bash|launchctl|plutil" | tail -25
これでも動くように見えましたが、grep に bash を含めているため、パイプを処理している bash プロセス自体がgrepにヒットして出力に混入しました。また、psの出力フォーマットが環境によって列幅が変わるため、whileでフィールドを読み取る際に read -r l だけだと行末の空白まで変数に入ってログが汚くなりました。
実際のコードは2段階のパイプになっています。
ps -Ao pid,lstart,comm | tail -n +2 | while read -r p rest2; do echo "$p $rest2"; done \
| grep -iE "python|node|bash|launchctl|plutil" | tail -25 \
| while read -r l; do log "$label: ps> $l"; done
while read -r p rest2 の rest2 は使っていないように見えますが、これが重要です。read -r p rest2 とすることで p に最初のフィールド(PID)が入り、rest2 に残り全フィールドが入ります。その後 echo "$p $rest2" で再出力することで、行頭のヘッダ行(tail -n +2 で除去済み)を除いた整形済みの行になります。read -r l だけで全行を受け取ると、psのフォーマット由来の余分なスペースが保持されますが、p rest2 で一度分解して再組み立てすることで連続スペースがシングルスペースに正規化されます。
結果として forensicログの ps> 行は 12345 Fri Aug 23 12:04:11 2026 python3 という読みやすい形式で残ります。8月23日のインシデントが起きたとき、このフォーマットが定まっていれば真っ先にそのpid・実行時刻・コマンド名を突き合わせることができたはずです。
次のパートでは、このガードスクリプト自体をlaunchdに登録する手順と、実際にガードが働いたとき(テストで意図的にplistを書き換えて確認したとき)のログ出力の実例を見ていきます。
つまずきポイント
p2で挙げた5つの詰まりに加えて、実際に作りながらぶつかった問題をまとめます。箇条書きで網羅します。
① self-repair.plistのProgramArgumentsがスクリプト直呼びでなかった
com.shun.self-repair.plistを初めて読んだとき、ProgramArgumentsが想定と違いました。
<array>
<string>~/.claude/scripts/claude-quota-guard.py</string>
<string>--job</string>
<string>com.shun.self-repair</string>
<string>--</string>
<string>/bin/bash</string>
<string>~/.claude/scripts/self-repair.sh</string>
</array>
/bin/bash self-repair.shを直接呼ぶのではなく、claude-quota-guard.pyを経由しています。最初は「なぜ一枚噛ませているのか」が分からず、素直に/bin/bash outreach-schedule-guard.shを直接ProgramArgumentsに書いてテストしていました。ガードスクリプト自体がClaude APIを呼ぶ処理(将来の拡張含む)を持つことを想定し、クォータ管理の傘の下に置くための設計です。ガード系のジョブは全部このラッパー経由にするルールを後から設けました。
② launchctl listでジョブが"running"なのにPIDが-のまま
launchctl bootstrapが成功した直後にlaunchctl list | grep com.shun.self-repairを確認すると、PIDが-でstatusが0でした。「ロードに失敗した?」と焦りましたが、これは正常動作です。RunAtLoad: falseかつStartCalendarIntervalでの定刻実行設定の場合、launchdはジョブを登録するだけで次の定刻まで起動しません。PIDが-は「待機中」を意味します。実際に9:20・13:30・19:30のいずれかの時刻を過ぎてからもう一度launchctl listを確認してはじめてPIDの値が入ります。PIDで生死を確認するのではなく、定刻後にログファイルをtail -fで見張るのが正しい確認手順です。
③ EnvironmentVariablesのHOMEを明示しないと$HOMEが空になる
plistにEnvironmentVariablesブロックを入れる前は、self-repair.shの中で$HOME/.claude/logs/へのパスが空文字列になるバグが発生しました。launchdはGUI sessionのユーザー環境変数を部分的にしか引き継がず、HOMEが未定義の状態でシェルスクリプトが起動するケースがあります。
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/実ユーザー名</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
<key>PATH</key>
<string>/Users/実ユーザー名/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:...</string>
</dict>
HOMEは~展開が機能しないplist内では絶対パスで書くしかありません。nvm経由のNode.jsを使うスクリプトを呼ぶ場合、PATHにも~/.nvm/versions/node/vXX.X.X/binを含める必要があります。これを忘れるとnode: command not foundがStandardErrorPathのログに静かに積まれます。
④ StandardOutPath・StandardErrorPathのディレクトリが存在しないとジョブが起動しない
plistに以下を書いておきながら、ディレクトリを先に作っていなかったことがあります。
<key>StandardErrorPath</key>
<string>~/.claude/logs/self-repair.launchd.err.log</string>
<key>StandardOutPath</key>
<string>~/.claude/logs/self-repair.launchd.log</string>
~/.claude/logs/が存在しない状態でlaunchctl bootstrapすると、ジョブのロードは成功(launchctl listでも表示される)にもかかわらず、定刻にスクリプトが実行されません。エラーログが書けないので何も残らず、ただ「動いていない」という結果だけが残ります。解決策はmkdir -pを先に実行することと、outreach-schedule-guard.sh冒頭のmkdir -p "$(dirname "$LOG")"パターンをplist登録前のセットアップスクリプトにも組み込むことです。
⑤ plistlib.dump()がバイナリplistをXMLに変換してしまう
書き戻し処理ではplistlib.dump(d, f)をデフォルト引数で呼んでいます。デフォルトはfmt=plistlib.FMT_XMLなので、元のplistがバイナリ形式(plutil -convert binary1で変換済みのもの)でも、書き戻した後はXML形式になります。
with open(path,'wb') as f: plistlib.dump(d, f)
# ↑ デフォルトはXML形式で出力。バイナリ維持したい場合は:
with open(path,'wb') as f: plistlib.dump(d, f, fmt=plistlib.FMT_XML)
# または
with open(path,'wb') as f: plistlib.dump(d, f, fmt=plistlib.FMT_BINARY)
launchdはXML形式もバイナリ形式も問題なく読むため実害はありませんが、plutil -pで中身を確認するときのフォーマットが変わります。「なんか書き換えられた?」とバックアップと比較して混乱したことがあります。元フォーマットを維持したい場合は読み込み時にバイナリか否かを判定してから書き出すフォーマットを分岐する必要があります。今回は「launchdが読めればXMLで統一」という判断で割り切りました。
⑥ バックアップ.bakファイルをlaunchdが誤ってジョブとして読む
LaunchAgentsディレクトリに拡張子.plistのファイルがあれば、macOSのlaunchd管理機能はそれをジョブ候補として認識します。バックアップファイルに.plist拡張子を付けていた初期バージョンでは、com.lily.outreach-ig.plist.bakではなくcom.lily.outreach-ig.bak.plistという命名をしてしまい、launchd GUIツール(LaunchControl等)がこれをジョブとして表示するという混乱が発生しました。現在の命名規則$label.plist.bak-guard-YYYYmmdd-HHMMSSは、.plistで終わらないことを意図的に確保しています。
⑦ forensicのpsフィルタでbashを含めると自分自身がヒットする
grep -iE "python|node|bash|launchctl|plutil"
このパターンでbashを含めているため、パイプ処理を担当しているbashプロセス自身がgrepにヒットします。ログには/bin/bash /path/to/outreach-schedule-guard.shという行が必ず出力されます。これはノイズですが、あえて除外していません。「ガードスクリプト自身の起動時刻がpsに残っている」という事実そのものが証拠になりえるからです。ただし、psのスナップショットを見るとき「bash」の行はガードスクリプト自身だと解釈するのがほぼ確実です。
⑧ ガードの実行頻度とDMの送信頻度のギャップ
com.shun.self-repair.plistのStartCalendarIntervalは9:20・13:30・19:30の3回です。一方、営業DMはcom.lily.outreach-igが8:20から22:20まで2時間おきに8回動いています。「ガードが最後に動いたのが19:30で、書き換えが20:00に起きた場合、次の検知は翌朝9:20まで13時間かかる」という計算になります。
この設計上の欠点は認識済みです。対策として、ガードの実行頻度を上げる(例えば1時間おきにする)か、営業DMスクリプト自体に起動前の自己整合チェックを組み込むかを検討しています。現時点では「書き換えが繰り返される頻度は高くない」という前提で3回に抑え、plistのread/writeによる副作用とのトレードオフを取っています。8月23日のインシデントでは「数日間気づかなかった」という実害があったので、この判断は将来見直す可能性があります。
⑨ stat -f '%Sm'のフォーマットはmacOS固有
forensicログでmtimeを取得しているstat -f '%Sm' -t '%F %T'は、macOSのstatコマンドの書式です。GNU Linux環境ではstat -c '%y'が同等ですが、このスクリプトはmacOS専用なので問題ありません。ただし、他のmacを使っている同僚に「このスクリプトLinuxでも動く?」と聞かれたとき、statの部分は動かないと伝える必要があります。クロスプラットフォーム化するならpython側でos.path.getmtime()を使って統一する選択肢があります。
ベストプラクティス
実装と運用を通して得た判断基準をまとめます。
1. launchd用シェルスクリプトの冒頭でPATHを上書きする
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
ターミナルでは動くのにlaunchdからだとcommand not foundになるほぼすべての原因がPATHです。nvm配下のNodeやHomebrewのpython3はシステムPATHに存在しません。launchd用スクリプトのテンプレート1行目は常にこれです。
2. set -eは外してpipefailだけ残す
監視・復元スクリプトにおいて-eは百害あって一利なしです。grepの空振り(終了コード1)でスクリプトが死ぬというp2で説明した理由に加えて、launchctl bootoutの失敗(すでにunload済みのとき)も-e環境では致命的になります。pipefailだけ残せば、パイプ途中のサイレントエラーは検知できます。
3. plistの読み書きはpython3 plistlibで行う
シェルのgrep/awkによるXMLパースは型保証がなく、バイナリplistにはまったく対応できません。python3は標準ライブラリのplistlibでXML/バイナリ両形式を自動判定でき、HourとMinuteを確実に整数として取り出せます。python3 -c "import plistlib" が1行で完結するため、外部依存ゼロです。
4. ヒアドキュメントは必ず<<'EOF'形式でクォートする
BashからPythonコードをヒアドキュメントで渡す際、クォートなし<<EOFはシェルがPythonコードを変数展開しようとして壊します。{ } $を含むPythonコードは特に危険です。<<'PY'とシングルクォートで囲む習慣を最初から持つことで、デバッグ難度の高いバグを防げます。
5. 復元前に必ずタイムスタンプ付きバックアップを作る
cp "$plist" "$plist.bak-guard-$(date +%Y%m%d-%H%M%S)"
復元処理が誤動作したとき(たとえばSPECS配列のミスで正しい設定をずっと「不一致」と判定し続けた場合)、バックアップがなければ元の設定に戻せません。タイムスタンプを焼き込む理由は、複数回の書き換えが起きたときに時系列をls -ltだけで追えるからです。
6. plutil -lintでplist構造を検証してからlaunchdへ渡す
書き戻し後に構造が壊れたplistをlaunchdに食わせると、ジョブがsilentに起動しなくなります。
if plutil -lint "$plist" >/dev/null 2>&1; then
# launchctl bootstrap へ進む
else
log "🔴 復元後のplistが壊れている(戻していない)"
fi
このゲートがあれば、plistlibのバグや書き込み中断による中途半端なXMLでlaunchdを汚染するリスクを排除できます。
7. bootoutとbootstrapの間にsleep 1を挟む
launchdのbootoutは非同期で完了します。bootoutのreturn値が0でも、launchd内部のジョブ終了処理が終わっていない瞬間にbootstrapを叩くとLoad failed: 5: Input/output errorが返ります。1秒の待機は経験的に安全マージンとして十分です。さらにbootstrapの成否をif文で受けてログに残すことで、仮に再失敗したときに即気づけます。
8. forensicログ(mtime + psスナップショット)を必ず残す
復元だけして終わる実装は「何が起きたか」を闇に葬ります。書き換えを検知した瞬間のplistのmtimeと、実行中のプロセス一覧を残しておくことで、原因究明の手がかりになります。8月23日のインシデントで「ガードスクリプトがあれば容疑プロセスを特定できた」という後悔が、この設計の動機です。ログさえあれば、同じ書き換えが繰り返されたときにパターンが見えてきます。
9. printfベースのログ関数を作る
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
echoはシェル実装と-eフラグの有無によって改行の扱いが変わります。ログメッセージにバックスラッシュ含む文字列が混入したとき、echoだと環境によって\nが改行に展開されたりされなかったりします。printfのフォーマット文字列と値の分離は一貫性を保証します。
10. plist不在のガードを入れる
[ -f "$plist" ] || { log "$label: plist が無い"; continue; }
SPECS配列に定義したラベルのplistが消えていた場合、python3のopen()が例外を出してスクリプト全体が止まります。存在チェック+ログ+次ループへのcontinueにすることで、4本のジョブのうち1本だけplistが消えていても残り3本は正常にチェックが走ります。
11. RunAtLoad: falseで登録直後の即時実行を防ぐ
launchdはlaunchctl bootstrapでジョブをロードした瞬間に一度だけ起動する機能(RunAtLoad: true相当)を持っています。ガードスクリプトは定刻に走れば十分なので、RunAtLoad: falseで不要な即時実行を切っています。復元処理は冪等ですが、plistのread/writeはゼロでないコストがあります。
12. LowPriorityIOとNice: 10で監視ジョブのリソース影響を最小化する
com.shun.self-repair.plistには以下が設定されています。
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
監視ジョブがCPU・IOを食い荒らして、監視対象である営業DMスクリプトのパフォーマンスを落とすのは本末転倒です。Nice: 10はCPU優先度を10段階下げ(0がデフォルト)、LowPriorityIOはIOの優先度を下げます。ProcessType: Backgroundとの組み合わせでOSのバッテリー・CPU最適化の対象に含まれます。
13. StartCalendarIntervalの単一エントリ問題をisinstanceチェックでガードする
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]
この1行がなければ、1時刻だけ設定したplist(<array>タグなしで<dict>が直書き)に対して毎回復元処理が走ります。plistlibがdictを返すのか listを返すのかはXMLの構造次第なので、防衛的にチェックする必要があります。
14. CSVの先頭カンマ問題は${var:+,}で解決する
for h in "${harr[@]}"; do expected="${expected}${expected:+,}${h}:${minute}"; done
${expected:+,}は「expectedが空でなければカンマを挿入、空ならば何もしない」というBashのパラメータ展開です。expected="${expected},${h}:${minute}"と書くと先頭にカンマが入り、期待値と実値が永遠に一致しない無限復元ループになります。
15. ガード自身のlaunchd登録はbootout → sleep 1 → bootstrapの手順で毎回行う
既存のジョブを更新するとき(ProgramArgumentsやStartCalendarIntervalを変えたとき)、launchctl unloadやbootoutなしにbootstrapしても変更が反映されません。以下の手順をセットアップスクリプトにまとめておくと、毎回迷わずに済みます。
launchctl bootout "gui/$(id -u)/com.shun.self-repair" 2>/dev/null
sleep 1
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.shun.self-repair.plist
launchctl list | grep self-repair
最後のlaunchctl list確認でPIDが-でもstatusが0なら正常登録です。
まとめ
2026年8月23日12時04分のスケジュール書き換えから始まったこの仕組みは、結果的に「自動化の稼働率を人間の目で担保しなくていい環境」を作るための一歩になりました。営業DM4本レーンが1日合計29回正しいスケジュールで動いているかを、わたしが確認しなくてもoutreach-schedule-guard.shが9:20・13:30・19:30の3回チェックして異変があれば自動で戻します。
コアの実装は73行です。plistlibで読んで、期待値と比較して、違ったらバックアップして戻してlaunchdに再登録する。その間にforensicログを残す。それだけです。複雑に見えるlaunchdの内部も、XMLを直接読んで書けるpython3の標準ライブラリがあれば、シェルスクリプトから制御できます。
個人開発で月商120万を維持しているのは、「人間が見ていない時間に自律で動き続ける仕組み」を積み上げてきたからです。一つひとつの仕組みはシンプルで、でも止まると収益に直結するから、止まったときに自分で直る設計にする。この記事で紹介したガードスクリプトは、その思想の一例です。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!