1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI に rm を打たせない — hooks 三段構えの実物

1
Posted at

本記事は Zenn に公開した記事の転載です。元記事: https://zenn.dev/berrylove/articles/ai-cant-rm-hooks-in-practice

先日公開した本『AI 国家運営』の第5章では、「指示書はサブエージェントに徹底されない」という前提に立って、削除コマンドを機械的にブロックする仕組みを「三段構え」として説明しました。本では設計の考え方に絞ったので、コードそのものは書いていません。この記事では、その3段目にあたる実装から、判定の中核部分を抜粋して紹介します。

三段構えのおさらい

1段目・2段目は運用でカバーできますが、CLAUDE.md に「削除はヘルパー経由で」と書いても、指示書が読み込まれない・読み込まれても従わないケースは実際に起こります(私の環境で起きた実例は本の第5章に書きました)。3段目の PreToolUse フックは、指示書が読まれたかどうかに関係なく、コマンド文字列そのものを検査して機械的に止めます。

実装: rm-guard.js

Claude Code の PreToolUse フックは、標準入力に JSON でツール呼び出しの情報が渡され、標準出力の JSON で判定を返す仕組みです。拒否時は deny を返し、非該当時は空の JSON を返して通常の権限判定に委ねます。rm / Remove-Item / rmdir / del などの直接削除コマンドを、コマンドの先頭トークン(sudo は読み飛ばす)としてのみ検出します。

const DANGEROUS_COMMANDS = new Set([
  'rm', 'rmdir', 'del', 'erase', 'ri', 'rd', 'remove-item',
]);
const SKIP_LEADING_TOKENS = new Set(['sudo']);

function splitIntoSubcommands(command) {
  return command
    .split(/&&|\|\||;|\|/g)
    .map((s) => s.trim())
    .filter(Boolean);
}

function firstToken(subcommand) {
  let rest = subcommand.replace(/^([A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '');
  let tokens = rest.split(/\s+/).filter(Boolean);
  while (tokens.length && SKIP_LEADING_TOKENS.has(tokens[0].toLowerCase())) {
    tokens.shift();
  }
  return (tokens[0] || '').toLowerCase();
}

function findDangerousCommand(command) {
  for (const sub of splitIntoSubcommands(command)) {
    const cmd = firstToken(sub);
    if (DANGEROUS_COMMANDS.has(cmd)) return cmd;
  }
  return null;
}

コマンド位置に限定しているのがポイントです。単純な文字列一致だと git rm(正当なサブコマンド)や、コミットメッセージにたまたま含まれる "rm" まで拾ってしまいます。&&; で連結された複数コマンドも1つずつ分解してから、それぞれの先頭トークンだけを見ます。

先に限界も書いておきます。この方式が止められるのは、列挙したコマンド名の直接入力だけです。/bin/rm のようなパス指定、スクリプトやインタプリタ経由の削除、find -deletegit rm -f のような列挙外の削除経路は対象外ですし、引用符の中に区切り文字を含むコマンドの分解にも限界があります。これらを1本のフックで塞ごうとするより、対象外の経路は1段目(可逆化)と2段目(確認)で受け止める、という役割分担で運用しています。

判定を返す部分はこうなっています。

function allow() {
  process.stdout.write(JSON.stringify({}));
  process.exit(0);
}

function deny(reason) {
  process.stdout.write(
    JSON.stringify({
      hookSpecificOutput: {
        hookEventName: 'PreToolUse',
        permissionDecision: 'deny',
        permissionDecisionReason: reason,
      },
    })
  );
  process.exit(0);
}

拒否理由(permissionDecisionReason)には「代わりにこのコマンドを使え」という誘導を必ず含めます。これがあることで、エージェントは拒否されたあと確認を挟まずに正しい手段へ切り替えやすくなります。理由を空にしたり抽象的な文言にしたりすると、エージェントが同じ操作を別の言い回しで再試行してくることがあり、誘導の具体性が実運用での再試行回数を左右します。

最後に、入力が読めない・パースできないケースはすべて allow() に倒しています。フック自体のバグが全操作の停止に直結する事故のほうが、検知漏れより悪いという判断です。素通りした先には、危険コマンドに確認を課す2段目の設定(対象コマンドを permissions で確認必須にしておく設定)を最終防衛線として残しておきます。なお、確認を省略する権限モードで運用している場合、この防衛線は効かない点には注意してください。

もう一つ: 全部を止めない設計

削除だけでなく git push にも同じ考え方のフックをかけています。ただしこちらは対象をさらに絞り、force pushmain/master への明示的な push の2パターンだけを確認対象にします。それ以外の push(フィーチャーブランチへの push、通常のコミット操作)はすべて素通しです。

以下は判定の入口部分の抜粋です(FORCE_FLAGS の定義や保護ブランチ(main/master)の判定、リポジトリ例外の適用などは完全版にあります)。

function isGitPush(tokens) {
  const idx = tokens.indexOf('git');
  if (idx === -1) return false;
  return tokens.slice(idx + 1).includes('push');
}

function hasForceFlag(tokens) {
  return tokens.some((t) => FORCE_FLAGS.includes(t));
}

全部の Bash コマンドや全部の git コマンドを確認対象にしなかったのは理由があります。本の第5章にも書いたとおり、以前 prompt 型(LLM 判定)のフックで無害な操作を繰り返し誤ブロックした経験があり、確認が頻発すると「読まずに承認する」動きが定着してしまいます。確認は、本当に取り返しがつかない2パターンだけに絞ったほうが、結果として機能します。

もう一点、このフックにはリポジトリ単位の例外リストを最初から持たせています。「main への直 push が正規の運用」というリポジトリも実際にあり、そこで毎回止められると自動化の意味がなくなるためです。

const REPO_EXCEPTIONS = [
  // '/home/you/vault',
];

検証はテストケース表で

この手のフックは、思いつきで数パターン試して「動いているはず」で終わらせると、あとで誤検知や見逃しに気づくことになります。実装時は次のような表を作り、危険系・誤検知チェック系を分けて手動実行しました。

入力コマンド 期待結果
rm -rf tmp/ deny
sudo rm file deny
git rm tracked-file allow
echo "please rm this later" allow
find . -name '*.rm' -delete allow
npm rm some-package allow

git rmfind -delete が allow なのは見逃しではなく、「このガードは列挙したコマンド名だけを対象にする」という設計どおりの挙動を確認するためのケースです。これらの削除経路は前述のとおり1段目・2段目の受け持ちになります。

rm-guard・push-guard それぞれに危険系・誤検知チェック系のテストケース表を作って全件を通し、期待どおりの判定になることを確認しています(ケースは運用の中で随時追加しています)。

使ってみる

スクリプトを配置し、settings.jsonPreToolUse にハンドラーを追加します(例は rm-guard の分。push-guard も同じ形で並べます)。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "node /path/to/rm-guard.js" }]
      }
    ]
  }
}

本の第5章では、この仕組みに至った経緯(指示書が読まれなかった実例、prompt 型フックでの誤検知の実例)を書いています。設計に至った経緯や失敗例を詳しく読みたい方はそちらもどうぞ。

なお、ここで抜粋した2本の完全版(動くファイル一式)は、CLAUDE.md のひな形や allowlist の作り方とあわせて、Claude Code のエージェント運用テンプレ集として BOOTH での公開を準備しています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?