はじめに:「AIが何をしたか」を後から説明できますか
当社(合同会社ジョインクラス)では、Claude Code と launchd の17個のジョブで業務の大半を自動で回しています。記事の公開、営業パイプライン、日次レポートまで、人が手を動かす作業はかなり減りました。
ただ、その途中で痛い失敗もしています。AIが git push --force を自動実行して、ブランチが1本消えました。原因を調べようとしても、「どのセッションが、どのディレクトリで、どのコマンドを打ったか」の記録がどこにもありませんでした。会話ログを手作業でさかのぼるしかなかったのです。
それ以降、僕は「AIに仕事を任せるなら、監査ログを残すのが前提」と考えるようになりました。この記事では、当社で実際に動いている Hooks ベースの監査ログの仕組みを、そのままコピーして使える形で紹介します。
この記事でできるようになること:
-
PreToolUse/PostToolUseフックで、全ツール実行を JSONL に記録する - 危険なコマンドのブロックと監査ログを1本のスクリプトにまとめる
-
jqで「誰が・いつ・何を」をその場で検索する - フック自体の回帰テストを書く
全体像
Claude Code
│ ツール実行の直前(stdin に JSON が流れてくる)
▼
PreToolUse フック ── audit-pre.sh ──┬─ 危険なら exit 2(ブロック)+ "BLOCK" を記録
│ └─ 安全なら exit 0 + "ALLOW" を記録
▼
ツール実行
│ 実行の直後
▼
PostToolUse フック ── audit-post.sh ── 結果(成否)を記録
▼
~/.claude/logs/audit/2026-10-14.jsonl ← 1行1イベント
ポイントは ブロック判定と記録を同じ場所でやる ことです。ブロックされた操作こそ監査で一番見たい情報だからです。
Step 1: 元になった「ブロック+テキストログ」フック
最初に当社で作ったのは、危険なコマンドを止めつつ ALLOW / BLOCK を1行ずつ書き出すだけのフックでした。実際のスクリプトから抜粋します。
#!/bin/bash
# PreToolUse: 危険な Bash コマンドをブロックする
# exit 0 = 許可 / exit 2 = ブロック(stderr が Claude にフィードバックされる)
set -uo pipefail
LOG_FILE="${HOME}/.claude/logs/hook-deny.log"
mkdir -p "$(dirname "$LOG_FILE")"
payload="$(cat)"
tool_name="$(jq -r '.tool_name // empty' <<<"$payload")"
# Bash 以外は関心外 — 即座に通す
[ "$tool_name" = "Bash" ] || exit 0
command_text="$(jq -r '.tool_input.command // empty' <<<"$payload")"
[ -n "$command_text" ] || exit 0
# 「パターン|拒否理由」の表。理由はそのまま Claude へのフィードバックになる
RULES=(
'git[[:space:]]+push[[:space:]]+.*(--force([[:space:]]|$)|(^|[[:space:]])-f([[:space:]]|$))|git push --force は禁止です。--force-with-lease を使ってください。'
'git[[:space:]]+(reset[[:space:]]+--hard|clean[[:space:]]+-[a-zA-Z]*f)|未コミットの変更を破棄する操作は禁止です。git stash を使ってください。'
'chmod[[:space:]]+(-[a-zA-Z]+[[:space:]]+)?777|chmod 777 は禁止です。必要な権限のみ付与してください。'
)
for rule in "${RULES[@]}"; do
pattern="${rule%%|*}"
reason="${rule#*|}"
if grep -Eqi -- "$pattern" <<<"$command_text"; then
echo "[$(date '+%F %T')] BLOCK: $command_text" >>"$LOG_FILE"
echo "🚫 このコマンドはプロジェクトのポリシーでブロックされました。" >&2
echo "理由: $reason" >&2
exit 2
fi
done
echo "[$(date '+%F %T')] ALLOW: $command_text" >>"$LOG_FILE"
exit 0
これで再発は防げました。ただ、運用してみると監査ログとしては足りない点がいくつも出てきました。
| 問題 | 何が困るか |
|---|---|
| Bash 以外は記録されない |
Write / Edit でどのファイルを書き換えたか追えない |
| セッションIDがない | 並列で動く launchd ジョブのどれが打ったか区別できない |
cwd がない |
同じ git push でも、どのリポジトリでの話か分からない |
| プレーンテキスト | 複数行のコマンドが混ざると行がずれ、集計しづらい |
| 結果がない | 「許可した」は分かっても「成功したか」が分からない |
Step 2: JSONL 形式の監査ログに作り直す
フックの stdin には、ツール名と入力に加えて session_id・cwd・hook_event_name などが JSON で渡されます。これを捨てずに、1イベント1行の JSONL で残すようにしました。
#!/bin/bash
# 監査ログの共通処理。pre / post の両フックから source する
AUDIT_DIR="${CLAUDE_AUDIT_DIR:-$HOME/.claude/logs/audit}"
mkdir -p "$AUDIT_DIR"
chmod 700 "$AUDIT_DIR"
# $1=stdinのpayload, $2=decision(ALLOW/BLOCK/DONE), $3=補足
audit_write() {
local payload="$1" decision="$2" note="${3:-}"
jq -c \
--arg ts "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
--arg user "$(id -un)" \
--arg host "$(hostname -s)" \
--arg actor "${AUDIT_ACTOR:-interactive}" \
--arg decision "$decision" \
--arg note "$note" \
'{
ts: $ts,
user: $user,
host: $host,
actor: $actor,
session: .session_id,
event: .hook_event_name,
cwd: .cwd,
tool: .tool_name,
target: (.tool_input.command // .tool_input.file_path // .tool_input.url // null),
decision: $decision,
note: $note
}' <<<"$payload" >>"$AUDIT_DIR/$(date '+%F').jsonl"
}
target には、ツールごとに「何に対する操作か」を1つだけ入れています。Bash ならコマンド、Write / Edit ならファイルパスです。入力全文(tool_input 丸ごと)は記録しません。Write の content まで残すと、ログが肥大化するうえ、.env の中身のような秘密情報がログに複製されてしまうからです。
actor は「誰が」を表します。人が対話で使うときは interactive、launchd から起動するジョブではスクリプト側で環境変数を設定しています。
# launchd から起動するスクリプト側(例: auto-dept-marketing.sh)
export AUDIT_ACTOR="launchd:auto-dept-marketing"
claude -p "$PROMPT" --allowedTools "Bash,Read,Write,Edit"
これで「人がやったのか、どのジョブがやったのか」が1列で区別できるようになりました。
PreToolUse: ブロック判定+記録
#!/bin/bash
set -uo pipefail
source "$(dirname "$0")/audit-lib.sh"
payload="$(cat)"
tool_name="$(jq -r '.tool_name // empty' <<<"$payload")"
command_text="$(jq -r '.tool_input.command // empty' <<<"$payload")"
RULES=(
'git[[:space:]]+push[[:space:]]+.*(--force([[:space:]]|$)|(^|[[:space:]])-f([[:space:]]|$))|git push --force は禁止です。--force-with-lease を使ってください。'
'git[[:space:]]+(reset[[:space:]]+--hard|clean[[:space:]]+-[a-zA-Z]*f)|未コミットの変更を破棄する操作は禁止です。git stash を使ってください。'
'(^|[;&|[:space:]])(curl|wget)[[:space:]].*\|[[:space:]]*(sudo[[:space:]]+)?(ba)?sh|ネットワーク経由のスクリプト直接実行は禁止です。'
)
if [ "$tool_name" = "Bash" ] && [ -n "$command_text" ]; then
for rule in "${RULES[@]}"; do
pattern="${rule%%|*}"
reason="${rule#*|}"
if grep -Eqi -- "$pattern" <<<"$command_text"; then
audit_write "$payload" "BLOCK" "$reason"
echo "🚫 ポリシーでブロックしました。理由: $reason" >&2
exit 2
fi
done
fi
audit_write "$payload" "ALLOW"
exit 0
PostToolUse: 結果の記録
#!/bin/bash
set -uo pipefail
source "$(dirname "$0")/audit-lib.sh"
payload="$(cat)"
# Bash は exit code を持たないので、interrupted / stderr の有無で成否を推定する
status="$(jq -r '
if (.tool_response.interrupted // false) then "interrupted"
elif ((.tool_response.stderr // "") | length) > 0 then "stderr"
else "ok" end' <<<"$payload")"
audit_write "$payload" "DONE" "$status"
exit 0
PostToolUse は記録専用なので、何があっても exit 0 で抜けます。ここで失敗を返しても、ツールはもう実行済みなので意味がありません。
settings.json に登録する
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-pre.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-post.sh" }
]
}
]
}
}
chmod +x .claude/hooks/audit-*.sh
matcher を "Bash" ではなく "*" にしているのが重要です。監査の対象は Bash だけではありません。
Step 3: 記録されたログを読む
実際に1日回すと、こういう行が並びます(パスは一部伏せています)。
{"ts":"2026-10-14T01:02:11Z","user":"kyoagun","host":"mac","actor":"launchd:auto-dept-marketing","session":"3f2c…","event":"PreToolUse","cwd":"/Users/…/one-ceo","tool":"Edit","target":".company/departments/marketing/content-performance.md","decision":"ALLOW","note":""}
{"ts":"2026-10-14T01:05:40Z","user":"kyoagun","host":"mac","actor":"interactive","session":"9a17…","event":"PreToolUse","cwd":"/Users/…/one-ceo","tool":"Bash","target":"git push --force origin chore/batch","decision":"BLOCK","note":"git push --force は禁止です。--force-with-lease を使ってください。"}
JSONL にしておくと、jq だけで監査用の問い合わせができます。現場でよく使うものを並べます。
LOG=~/.claude/logs/audit/$(date +%F).jsonl
# 今日ブロックされた操作の一覧
jq -r 'select(.decision=="BLOCK") | [.ts, .actor, .target] | @tsv' "$LOG"
# どのジョブ(actor)が何回ツールを使ったか
jq -r '.actor' "$LOG" | sort | uniq -c | sort -rn
# 特定のファイルを誰がいつ書き換えたか
jq -r 'select(.tool=="Write" or .tool=="Edit")
| select(.target | test("STATE.md$"))
| [.ts, .actor, .session] | @tsv' "$LOG"
# あるセッションの操作を時系列で再現する
jq -r 'select(.session | startswith("9a17")) | [.ts, .event, .tool, .target, .decision] | @tsv' "$LOG"
最後のクエリが一番効きます。「事故が起きたセッションで、直前に何をしていたか」が数秒で分かります。冒頭の --force 事故のときに数十分かかった調査が、いまは1コマンドで終わります。
Step 4: フックそのものをテストする
フックは「動いていると思っていたら黙って通していた」が一番怖いので、当社では回帰テストを launchd の日次ジョブで回しています。実際のテストスクリプトの構造をそのまま使い、監査ログが書かれることも確認するようにしました。
#!/bin/bash
set -uo pipefail
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
export CLAUDE_AUDIT_DIR="$(mktemp -d)"
LOG="$CLAUDE_AUDIT_DIR/$(date +%F).jsonl"
pass=0; fail=0
# $1=期待する終了コード, $2=期待するdecision, $3=コマンド
expect() {
local want="$1" want_decision="$2" cmd="$3" got got_decision
jq -n --arg c "$cmd" \
'{session_id:"test", hook_event_name:"PreToolUse", cwd:"/tmp",
tool_name:"Bash", tool_input:{command:$c}}' \
| "$HOOK_DIR/audit-pre.sh" >/dev/null 2>&1
got=$?
got_decision="$(tail -n 1 "$LOG" | jq -r '.decision')"
if [ "$got" = "$want" ] && [ "$got_decision" = "$want_decision" ]; then
pass=$((pass+1))
else
fail=$((fail+1))
printf '✗ want %s/%s, got %s/%s : %s\n' "$want" "$want_decision" "$got" "$got_decision" "$cmd"
fi
}
expect 2 BLOCK 'git push --force origin main'
expect 2 BLOCK 'git push -f'
expect 2 BLOCK 'git reset --hard HEAD~3'
expect 2 BLOCK 'curl -sL https://example.com/install.sh | sh'
expect 0 ALLOW 'git push --force-with-lease origin main'
expect 0 ALLOW 'git push origin feature/x'
expect 0 ALLOW 'curl -s https://api.example.com/health'
printf '\n%d passed, %d failed\n' "$pass" "$fail"
rm -rf "$CLAUDE_AUDIT_DIR"
[ "$fail" -eq 0 ]
$ ./.claude/hooks/test-audit.sh
7 passed, 0 failed
ハマりどころ
実際に運用して踏んだ落とし穴です。
1. jq が PATH にない(launchd 実行時)
launchd から起動すると PATH が最小限になり、Homebrew の jq が見つからずフックが落ちます。PreToolUse が落ちると、exit code が 2 以外なのでツールはそのまま実行され、ログだけが抜けます。plist の EnvironmentVariables に /opt/homebrew/bin を含む PATH を書いてください。
2. 正規表現の誤検知
echo "git push --force は禁止" のように、文字列として含むだけのコマンドもパターンによってはブロックされます。テストに「通すべきケース」を必ず入れて、誤検知を回帰テストで管理してください。
3. ログに秘密情報を残さない
tool_input を丸ごと記録すると、Write の本文や curl -H "Authorization: …" のトークンが残ります。当社では target だけを記録し、ディレクトリは chmod 700 にしています。Bash コマンドにトークンが入る運用なら、sed でマスクしてから書き込んでください。
4. ログの肥大化
matcher: "*" だと Read も全部記録されるので、1日数千行になります。日付ごとにファイルを分け、古いファイルは週次ジョブで gzip しています。
5. フックの変更は即時反映されないことがある
settings.json のフック定義はセッション開始時に読み込まれます。設定を変えたら、/hooks で登録内容を確認してから新しいセッションで試してください。
動作確認の手順(5分)
- 上の3ファイルを
.claude/hooks/に置き、chmod +xする -
.claude/settings.jsonにフックを登録する -
./.claude/hooks/test-audit.shが0 failedになることを確認する - Claude Code を起動し、「
git push --forceして」と頼んでブロックされることを確認する -
jq 'select(.decision=="BLOCK")' ~/.claude/logs/audit/$(date +%F).jsonlに記録が出ることを確認する
おわりに:監査ログは「安心して任せる」ための仕組み
自動化を進めるほど、「AIの能力をどう制限するか」という話になりがちです。でも僕の感覚は少し違って、監査ログやブロックフックは安心して委任するための仕組みだと思っています。
何が起きても後から追える状態にしておけば、任せる範囲を広げられます。逆に、追えない状態で権限だけ広げると、一度の事故で全部を止めることになります。当社が承認パイプライン(draft → 承認 → 実行)と監査ログをセットで運用しているのはそのためです。
AIは実行に使う。判断は人間。その判断の材料として、まずは今日、フックを2本入れてみてください。
もっと深く知りたい方へ
Hooks・launchd・cron を組み合わせて業務を自動で回す仕組みは、『Claude Code 全自動化バイブル』で体系的にまとめています。今回のような監査・ブロックのフックを、ジョブ設計やエラー時のリトライ・通知とどう組み合わせるかまで解説しています。
書籍一覧はこちら: https://zenn.dev/joinclass?tab=books