Claude Codeでrsyncのバックアップを組む — --deleteはdry-runで先に確認
rsyncの--deleteで消える対象を--dry-runで先に洗い出し、Claude Codeには「dry-runだけ許可」の権限とhookを渡してバックアップスクリプトを任せる手順です。
rsyncのバックアップで怖いのは、コピーの失敗よりも--deleteが宛先のファイルを消すことです。送り元と宛先を取り違えたり、末尾のスラッシュを1文字落としたりするだけで、消える対象が変わります。
Claude Codeにスクリプトを書かせるなら、最初の実行は必ず--dry-runにします。そのうえで「dry-runなら確認なしで通し、本実行は人が承認する」という線を、権限ルールとhookで機械的に引いておきます。この記事では、dry-runの出力の読み方、権限の書き方、hookの例、バックアップスクリプトの形を順に示します。
--deleteが消すのは「送り元に無いもの」すべて
--deleteは、送り元に存在しないファイルを宛先から消すオプションです。rsyncのmanページは、このオプションが正しく使わないと危険であること、先に--dry-run(-n)で消える対象を確かめるのがよいことを明記しています。
--dry-runは変更を加えない試走で、出力は本実行とほぼ同じになります。manページによれば、--itemize-changes(-i)の出力は、外部要因で状態が変わらない限り、dry-runと続く本実行で同じになるはずのものです。統計値(送信バイト数やspeedup)は、実データを送らないため小さく出ます。見るべきなのは統計ではなく、deletingで始まる行です。
実際に確かめます。送り元にkeep.txtだけ、宛先にkeep.txtとstale.txtを置いた一時ディレクトリで、macOS同梱のrsync(rsync --versionが「openrsync: protocol version 29 / rsync version 2.6.9 compatible」と表示する実装)を動かしました。パスは~/tmpに置き換えています。
rsync -avn --delete ~/tmp/src/ ~/tmp/dst/Transfer starting: 2 files
deleting stale.txt
sent 111 bytes received 20 bytes 31951 bytes/sec
total size is 2 speedup is 0.02deleting stale.txtの1行が、本実行で消えるファイルです。この後に宛先をlsしてもstale.txtは残っており、dry-runが何も変えていないことも確かめられます。-iを足すと、同じ行が*deleting stale.txtの形で出ます。
末尾のスラッシュ1文字で、消える対象が変わる
dry-runで見落としやすいのが、送り元の末尾スラッシュです。manページは、送り元の末尾にスラッシュを付けると「ディレクトリそのもの」ではなく「ディレクトリの中身」を対象にすると説明しています。
同じ一時ディレクトリで、送り元から末尾のスラッシュを外して再度dry-runしました。
rsync -avn --delete ~/tmp/src ~/tmp/dst/Transfer starting: 2 files
src/
src/keep.txt
sent 129 bytes received 32 bytes 146363 bytes/sec
total size is 2 speedup is 0.01今度はdeletingの行がありません。dst/src/というサブディレクトリを新しく作る動作になり、宛先直下のstale.txtは削除の対象になりません。意図したのが「中身の同期」なら、これは取り違えです。逆に、宛先を誤って大きなディレクトリにすると、そこにある無関係なファイルがdeletingに並びます。
dry-runの出力を見るときは、次の3点を確かめます。
deletingの行数が、想定した件数に収まっているか- 行に並ぶパスが、バックアップ対象の中のものだけか
- コピーされるファイルの置き場所が、
dst/src/のように1段深くなっていないか
「dry-runだけ許可」を権限ルールで書く
Claude Codeのpermissionsは、Bash(コマンド)の形でルールを書けます。ルールの評価順はdeny、ask、allowで、最初に当たったものが結果を決めます。この順序があるので、「rsync全般はask、dry-runだけallow」という書き方は成立しません。askが先に当たり、dry-runも確認に回ってしまいます。
そこで、dry-runの形だけをallowに書き、本実行はallowに載せません。載っていないBashコマンドは、既定の確認モードでは承認を求められます。
{
"permissions": {
"allow": [
"Bash(rsync -avn --delete *)"
]
}
}*は末尾に置いています。ルールは*より前の文字列をそのまま照合するので、rsync -avn --deleteで始まる呼び出しだけが通ります。rsync -av --delete ...(nが無い本実行)は一致せず、確認が出ます。
この書き方には、三つの限界があります。
- 複合コマンドは、各部分が許可されていないと確認が出ます。
cd 作業ディレクトリ && rsync -avn ...のcdはread-only扱いで通りますが、rsync ... && rsync -av ...は2つ目で止まります - 引数を絞るパターンは壊れやすい、とpermissionsのページ自身が警告しています。
rsync -avn --deleteの後ろは*なので、後続のオプションは何でも通ります /usr/bin/rsyncのように絶対パスで呼ぶと、このルールには一致しません。allowが外れる向きなので安全側ですが、dry-runまで毎回確認になります
hookで「dry-runなしの削除」を止める
権限ルールだけでは、-avn以外の書き方で--delete付きのrsyncが提案されたときに、人が確認画面を見落とす余地が残ります。確認を出すだけでなく、dry-runの無い削除系の呼び出しを実行前に止めるのが、PreToolUse hookです。hookが終了コード2で終わるか、permissionDecisionにdenyを返すと、ツール呼び出しは実行前に止まります。公式の例と同じ形で、rsync用のガードを書くと次のようになります。
#!/bin/bash
# .claude/hooks/rsync-guard.sh
COMMAND=$(jq -r '.tool_input.command')
# 削除系のrsyncで、dry-runが付いていなければ止める
if echo "$COMMAND" | grep -Eq '(^|[;&| ])rsync ' \
&& echo "$COMMAND" | grep -Eq -- '--delete|--remove-source-files' \
&& ! echo "$COMMAND" | grep -Eq -- '--dry-run|( -[a-zA-Z]*n[a-zA-Z]* )'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "削除系のrsyncは先に--dry-runで確認してください"
}
}'
else
exit 0
fichmod +xで実行権限を付けたうえで、.claude/settings.jsonに登録します。ifにBash(rsync *)を指定すると、rsyncを含む呼び出しのときだけスクリプトが起動します。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rsync *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/rsync-guard.sh",
"args": []
}
]
}
]
}
}スクリプト単体の動作は、標準入力にJSONを流して確かめました。
| 入力したコマンド | 結果 |
|---|---|
rsync -av --delete src/ dst/ | 結果deny |
rsync -avn --delete src/ dst/ | 結果判断なし(通常の権限判定へ) |
rsync -av --dry-run --delete src/ dst/ | 結果判断なし |
rsync -av src/ dst/(削除オプションなし) | 結果判断なし |
cd x && rsync -a --delete src/ dst/ | 結果deny |
grepによる文字列判定なので、取りこぼしはあります。/usr/bin/rsyncのように絶対パスで呼ばれると最初の判定に当たらず、素通りします。シェルの変数やbash -cの中に隠れた呼び出しも見ません。hookは「うっかり」を止める安全柵であり、迂回を防ぐ境界ではないと考えてください。書き込み先そのものを制限したいなら、OSレベルで分離するsandboxingと組み合わせます。
バックアップスクリプトの形
権限とhookの土台ができたら、スクリプトはdry-runを既定にします。本実行は--applyを明示したときだけです。Claude Codeに依頼するときも、この形を指定すると、毎回の最初の呼び出しがdry-runになります。
#!/bin/bash
# backup.sh <src> <dst> [--apply]
set -euo pipefail
SRC="${1:?src}"; DST="${2:?dst}"
MODE="-n" # 既定はdry-run
[ "${3:-}" = "--apply" ] && MODE=""
MAX_DELETE=50 # これを超える削除はスキップして異常終了
rsync -a $MODE --delete --max-delete="$MAX_DELETE" -i "${SRC%/}/" "${DST%/}/"先に書いた二つの仕組みは、スクリプトの中のrsyncを見ません。allowのBash(rsync -avn --delete *)は./backup.sh 送り元 宛先に一致しないので、dry-runでも毎回確認が出ます。hookもifがBash(rsync *)で、コマンド文字列にrsyncが現れない./backup.sh ... --applyは対象外です。そこでスクリプト用に、本実行だけを確認に回すルールを足します。askはallowより先に評価されるので、--apply付きの呼び出しはaskに当たり、それ以外がallowで通ります。
{
"permissions": {
"allow": [
"Bash(./backup.sh *)"
],
"ask": [
"Bash(./backup.sh * --apply)"
]
}
}引数の位置が違う書き方(--applyを途中に置くなど)はこのaskに当たらないので、--applyは末尾に置くとCLAUDE.mdで決めておきます。スクリプトの設計では、次の2点を意図しています。
1つ目は、"${SRC%/}/"で末尾のスラッシュを必ず1つにそろえていることです。呼び出し側が付けても付けなくても「中身の同期」になり、前節の取り違えが起きません。
2つ目は、--max-deleteです。manページによれば、この件数を超える削除は以降すべてスキップされ、最後に警告と共に終了コード25で終わります。送り元が空になる事故(マウントの外れなど)で、宛先が全消去されるのを防ぐ歯止めになります。一時ディレクトリでMAX_DELETE=1に絞り、宛先に3ファイルを置いて--applyを実行すると、次の出力でした。
*deleting old3
Deletions stopped due to --max-delete limit (2 skipped)
>f+++++++ k3件のうち1件を消したところで止まり、残り2件は消えていません。manページには、--max-delete=0なら削除せずに余分なファイルへの警告だけを出せる、ともあります。ただし、3.0.0より古いクライアントは0を「無制限」と解釈するので、古い環境が混ざるなら-1を使うよう注意書きがあります。
送り元側でI/Oエラーを検知すると、宛先の削除は自動的に無効になる仕様です。一時的なNFS障害のような場合に、大量削除を避ける働きをします。
Claude Codeに渡す取り決め
スクリプトの実行手順を、CLAUDE.mdに短く書いておきます。権限とhookが機械で止める部分、CLAUDE.mdは判断を揃える部分、と役割を分けます。
## rsyncバックアップ
- backup.shは最初に必ず--applyを付けずに(dry-runで)実行する
- 出力の`deleting`行を数え、パスを一覧にして報告する
- 件数が想定外、またはバックアップ対象の外のパスがあれば、--applyを提案しない
- --applyはユーザーの承認後に、引数の末尾に付けて1回だけ実行するdry-runの出力を読ませるときは、「消える一覧を、削除予定のパスだけの箇条書きにして」と依頼します。Claudeが数えた件数と、手元でbackup.sh 送り元 宛先 | grep -c deletingを打った件数が一致するかを、人が1回は見比べるとよいでしょう。要約を信用する前に生の出力と突き合わせる習慣が、削除系の操作では効きます。
定期実行に載せる場合は、cronならflockで多重起動を防ぐ書き方、macOSならlaunchdのplistが使えます。ただし--apply付きの本実行を定期ジョブに入れる前に、dry-runの結果を人が確認する工程は残してください。
よくあるつまずき
- dry-runで削除が出ないのに本実行で消える: dry-runと本実行の間に、送り元や宛先が書き換わると差が出ます。本実行の直前にもう一度dry-runを走らせます
- 統計が小さすぎる: dry-runは実データを送らないため、バイト数と
speedupの値は実際より小さく出ます。転送量の見積もりには使えません - 宛先が未マウントで空だった: 送り元が空の場合、宛先の全ファイルが
deletingに並びます。--max-deleteが効くのはこの場面です - 稼働中のSQLiteをファイル同期で写した: rsyncはファイル単位のコピーなので、書き込み中のデータベースは整合が取れないままコピーされることがあります。この用途はLitestreamのバックアップのほうが向きます
--remove-source-filesの見落とし: こちらは宛先ではなく送り元のファイルを消すオプションです。前節のhookはこれも対象に含めています
まとめ
rsyncの--deleteは、先に--dry-runでdeleting行を見れば、消える対象を事前に確かめられます。Claude CodeにはBash(rsync -avn --delete *)のようにdry-runの形だけをallowし、本実行は都度承認にとどめる運用が合います。hookは、dry-runなしの削除系を実行前に止める二重の柵です。
スクリプトはdry-runを既定にして、--max-deleteで最悪の被害に上限を付けます。送り元が空になる事故は、権限でもhookでも防ぎきれないため、この歯止めをスクリプト自体に持たせておくのが現実的です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。