Claude Media
Claude Codeでrsyncのバックアップを組む — --deleteはdry-runで先に確認

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.02

deleting 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
fi

chmod +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+++++++ k

3件のうち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でも防ぎきれないため、この歯止めをスクリプト自体に持たせておくのが現実的です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/permissions
  2. code.claude.com/docs/en/hooks
  3. download.samba.org/pub/rsync/rsync.1
この記事を共有:XはてブLinkedIn