0
0

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エージェントが本番DBを消した——HN 970コメントが教えてくれた「生き残る設計」6選

0
Last updated at Posted at 2026-04-29

この記事で得られること:AIエージェントに本番環境を壊されるリスクを今日から大幅に下げられる。dry-run 必須化・最小権限・自動バックアップなど、即日適用できる6つのガードレールをコード付きで全公開する。

ぶっちゃけ、最初に見たとき鳥肌が立ったんですよ。

Hacker News のタイムラインに流れてきたスレッド、タイトルは「An AI agent deleted our production database」。コメント数 970。自分がその場で思ったことは、「あ、自分も同じことやる可能性があるな」だった。

別に他人事じゃない。毎日 Claude Code を使って、MCP でファイルもツールも触らせてる。ちょっとした設定ミス一個で、同じことが起きてもおかしくなかった。

そのスレッドを読み終わった後、即日でガードレールを6つ入れた。「怖かったから全部やった」という感じで、振り返ると割と網羅的な「AIエージェント安全運用の型」になっていた。

この記事は、その6つを全部書く。コードも設定もそのまま使えるものにした。


目次

  1. HNで話題になった事件の概要と背景
  2. AIエージェントが本番を壊す4パターン
  3. ガードレール6選(コード全公開)
  4. Before / After:数値で見る改善効果
  5. 今日・今週・今月のアクションリスト
  6. まとめ:エージェント時代の「守りの設計」

1. HNで話題になった事件の概要と背景

2026年4月末、Hacker News に「An AI agent deleted our production database」というタイトルの投稿が流れてきた。投稿者は「AIエージェントが本番DBを削除した」経緯、根本原因の分析、そして復旧に要した時間と手順を詳細に書いていた。

コメント欄が 970 件まで膨らんだのは、それが「珍しい話」じゃなかったからだと思う。「うちも似たことあった」「ヒヤリハットなら自分も経験した」「同じことが起きそうで怖い」という反応が続いていた。

つまり、あれは個人の失敗談じゃなくて、AIエージェントを使ってる人全体の問題だ。

なぜ今、この問題が増えているか

Claude Code は現在 GitHub 全コミットの約 4% を占めているという推計がある(SemiAnalysis 調べ)。2025年末に比べて、AIコーディングツールの普及速度は明らかに加速している。エージェントが本番環境に「近い場所」で動く機会が増えるほど、インシデントのリスクも比例して上がる。

ツールが普及するのは良いことだが、「使えるようになった」と「安全に使えるようになった」の間には、まだ相当なギャップがある。自分はそのギャップを埋めるのが遅かったと思っている。


2. AIエージェントが本番を壊す4パターン

具体的な対策に入る前に、「なぜ起きるか」を整理しておく。パターンを知ってないと、対策が的外れになる。

パターン①:曖昧な指示の過剰解釈

「古いデータを削除して」という指示を出した。人間なら「どの程度古いか確認しよう」「本当にいいの?」と止まる。エージェントは止まらない。

指示の曖昧さを「文脈から補完」して実行するのがエージェントの強みでもあるが、それが本番DBの前では致命的になる。曖昧さを許容する設計のまま本番に触れさせると、想定外の範囲が削除される。

実際に起きた例:「先月分のログを整理して」→ ログテーブルを先月以降の全レコードを対象に削除。「整理」の定義が人間と違った。

パターン②:環境の誤認識

staging のつもりが production だった、というやつ。

.env ファイルの読み込み順、環境変数の上書き順、設定ファイルのパス参照——こういったところで「間違った環境で動いてる」ことは普通に起きる。エージェントはどちらの環境でも命令通りに動くので、環境を意識したブレーキがない。

自分の場合、.env.local に本番 DB の接続情報が残っていたことがあった。ローカル開発時に「ちょっと確認したくて」設定したやつだ。そこにエージェントがアクセスしていたと思うと背筋が凍る。

パターン③:連鎖的な副作用

A → B → C という操作の連鎖で、C が致命的な結果をもたらすパターン。

たとえば「テストデータのクリーンアップ」タスクで、エージェントが自動的に関連テーブルのレコードも削除する(外部キー参照を追って)。一個一個の操作は「正しい」のに、全体として「意図してない削除」になる。

エージェントは効率を最適化するので、関連する操作をまとめてやってしまいがちだ。ステップを分けて確認を挟む設計がないと、連鎖が止まらない。

パターン④:ロールバック不能な操作

バックアップなし + DROP TABLE の組み合わせが最悪ケース。

DROP TABLE や TRUNCATE は元に戻せない。エージェントが「スキーマを整理して」という指示に対して不要テーブルを判断し、削除してしまうと、データが消える。スキーマ変更も同様で、カラム削除はバックアップなしでは復旧不能になる。

人間が操作する場合は「ちょっと待てよ」という感覚が働くが、エージェントにそれはない。コードとして明示的に止める仕組みが必要だ。


3. ガードレール6選(コード全公開)

ここが本題。HNのスレッドを読んで即日やったことを全部書く。

ガードレール①:dry-run モードを必須にする

すべての破壊的操作に dry-run モードを実装し、デフォルトを dry-run=True にする

# ❌ Before: 確認なしで直接実行していたコード
def delete_old_records(days: int) -> int:
    """days日より古いレコードを削除"""
    result = db.execute(
        f"DELETE FROM events WHERE created_at < NOW() - INTERVAL '{days} days'"
    )
    return result.rowcount


# ✅ After: dry-run + 影響範囲提示 + 確認ゲート付き
def delete_old_records(days: int, dry_run: bool = True) -> dict:
    """
    days日より古いレコードを削除。
    dry_run=True(デフォルト)では実際に削除しない。
    """
    # まず削除対象の件数だけ確認
    count_result = db.execute(
        "SELECT COUNT(*) FROM events "
        "WHERE created_at < NOW() - INTERVAL :interval",
        {"interval": f"{days} days"}
    ).fetchone()
    affected_rows = count_result[0]

    if dry_run:
        return {
            "dry_run": True,
            "would_delete": affected_rows,
            "message": (
                f"{affected_rows:,}件を削除予定。"
                "実行する場合は dry_run=False を指定してください"
            )
        }

    # dry_run=False の時だけ実際に削除
    result = db.execute(
        "DELETE FROM events "
        "WHERE created_at < NOW() - INTERVAL :interval",
        {"interval": f"{days} days"}
    )
    return {
        "dry_run": False,
        "deleted": result.rowcount
    }

ポイントdry_run=True がデフォルトなので、エージェントが明示的に dry_run=False を指定しない限り実行されない。指示に「本当に削除して」という意図がない限り、エージェントは dry-run を選ぶようになる。

実際のハマりポイント:これを入れた後、エージェントが dry_run=False を渡してくるケースが何度かあった。CLAUDE.md に「削除操作は dry-run の結果を報告してから確認を取ること」を明記することで、エージェントが自動的に確認を挟むようになった。コードの防御だけでなく、指示の防御も必要だった。

ガードレール②:環境識別を絶対に間違えない仕組み

環境変数だけに頼らず、接続文字列から環境を推定して二重チェックする

# ~/.claude/scripts/env_guard.py
# エージェントの破壊的操作前に必ずインポートする

import os
import re

# 本番と判定する文字列リスト(小文字でチェック)
PRODUCTION_INDICATORS = ["prod", "production", "prd", "live"]

# 許可された環境(CLAUDE_AGENT_ENV で設定)
ALLOWED_DESTRUCTIVE_ENV = os.getenv("CLAUDE_AGENT_ENV", "development")


def assert_safe_environment(connection_string: str | None = None) -> None:
    """
    本番DBへの書き込み・削除操作をブロックする。
    接続文字列と環境変数の両方をチェックする。
    """
    # 1. 環境変数チェック
    current_env = os.getenv("APP_ENV", os.getenv("RAILS_ENV", os.getenv("NODE_ENV", "unknown")))
    if any(p in current_env.lower() for p in PRODUCTION_INDICATORS):
        raise PermissionError(
            f"🚫 本番環境での破壊的操作はブロックされています\n"
            f"  APP_ENV={current_env}\n"
            f"  CLAUDE_AGENT_ENV={ALLOWED_DESTRUCTIVE_ENV}\n"
            f"  本番での操作は手動で実行してください"
        )

    # 2. 接続文字列チェック(渡された場合)
    if connection_string:
        conn_lower = connection_string.lower()
        for indicator in PRODUCTION_INDICATORS:
            # ホスト名やDB名に production を示す文字が含まれていたらブロック
            if re.search(rf'([@/])[^@/]*{indicator}[^@/]*', conn_lower):
                raise PermissionError(
                    f"🚫 本番DBへの接続が検出されました\n"
                    f"  接続先: {connection_string[:60]}...\n"
                    f"  ガードレール: env_guard.py で本番DBへの書き込みを禁止しています"
                )

    print(f"✅ 環境チェック通過: {current_env or 'development'}")


# 使い方(破壊的操作の前に呼ぶ)
if __name__ == "__main__":
    db_url = os.getenv("DATABASE_URL", "")
    assert_safe_environment(db_url)
    print("本番環境ではありません。操作を続けます。")

実際に起きた問題.env.production を Claude Code に渡したとき、エージェントが接続情報を正しく読んだ上で「本番DBに接続できている」と認識し、処理を進めようとした。「env = production なのに許可してるの?」という状況だった。コードレベルの防御が必要だと実感した。

ガードレール③:ロールバックポイントを自動作成

破壊的操作の前にスナップショットを自動取得する。Claude Code の hooks に登録するのが一番楽。

#!/bin/bash
# ~/.claude/scripts/before_destructive_op.sh
# Claude Code の PreBash フックから呼ばれる

set -e

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="$HOME/.claude/backups/$TIMESTAMP"
mkdir -p "$BACKUP_DIR"

echo "📸 バックアップ取得開始: $TIMESTAMP"

# PostgreSQL の場合
if [ -n "$DATABASE_URL" ]; then
    echo "  → PostgreSQL バックアップ中..."
    if pg_dump "$DATABASE_URL" > "$BACKUP_DIR/backup.sql" 2>/dev/null; then
        BACKUP_SIZE=$(wc -c < "$BACKUP_DIR/backup.sql")
        echo "  ✅ 完了: $BACKUP_DIR/backup.sql (${BACKUP_SIZE} bytes)"
        echo "$BACKUP_DIR" > /tmp/last_backup_path
    else
        echo "  ❌ バックアップ失敗。操作を中断します。"
        exit 1
    fi
fi

# SQLite の場合
if [ -n "$SQLITE_PATH" ] && [ -f "$SQLITE_PATH" ]; then
    cp "$SQLITE_PATH" "$BACKUP_DIR/db_backup.sqlite"
    echo "  ✅ SQLiteバックアップ完了: $BACKUP_DIR/db_backup.sqlite"
fi

# ローカルファイルの場合(作業ディレクトリのスナップショット)
if [ -d "$(pwd)" ]; then
    tar -czf "$BACKUP_DIR/workdir_snapshot.tar.gz" \
        --exclude="node_modules" \
        --exclude=".git" \
        --exclude="__pycache__" \
        "$(pwd)" 2>/dev/null && \
        echo "  ✅ 作業ディレクトリスナップショット完了" || true
fi

echo "📸 バックアップ完了: $BACKUP_DIR"

これを Claude Code の hooks に登録する:

// ~/.claude/settings.json  hooks セクション
{
  "hooks": {
    "PreBash": [
      {
        "matcher": "DELETE|DROP TABLE|DROP DATABASE|TRUNCATE|rm -rf",
        "command": "bash ~/.claude/scripts/before_destructive_op.sh"
      }
    ]
  }
}

実際のハマりポイント:hooks の matcher は大文字小文字を区別するので、deleteDELETE 両方書く必要があった。あと rm -rf は正規表現でエスケープが必要な場合がある。設定後は必ず rm -rf /tmp/test_dir で動作確認すること。

ガードレール④:エージェントの権限を最小化する

エージェント用 DB ユーザーには必要最小限の権限だけ渡す。DELETE は原則渡さない。

-- エージェント用ユーザーの作成(PostgreSQL)

-- ❌ Before: 管理者権限のユーザーをそのまま渡してた
-- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO claude_agent;

-- ✅ After: 読み取りと限定的な書き込みのみ
CREATE USER claude_agent WITH PASSWORD 'your_secure_password_here';

-- 読み取りは全テーブルOK(参照・分析に必要)
GRANT SELECT ON ALL TABLES IN SCHEMA public TO claude_agent;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO claude_agent;

-- スキーマへのアクセス許可
GRANT USAGE ON SCHEMA public TO claude_agent;

-- 書き込みは特定テーブルのみ(作業ログ・タスクキューなどエージェント専用テーブル)
GRANT INSERT, UPDATE ON agent_task_logs TO claude_agent;
GRANT INSERT, UPDATE ON agent_work_queue TO claude_agent;

-- DELETE は絶対に渡さない
-- DROP, TRUNCATE, ALTER も禁止(REVOKE で明示)
REVOKE DELETE ON ALL TABLES IN SCHEMA public FROM claude_agent;

-- 接続先を開発用DBのみに制限(本番DBには接続自体させない)
-- pg_hba.conf か connection pooler レベルで制御するのが理想

実際の効果:この設定を入れた後、エージェントが誤って削除操作を試みた際に ERROR: permission denied for table events が返ってきた。エラーを見て「あ、止まった」と初めて安心できた。

エラーが出ることで「エージェントが何かやろうとした」ことにも気づけるので、ログに残すと後から確認できる。

ガードレール⑤:実行ログを残す

エージェントが何をしたかを全部 JSONL でログに残す。「後から追える」設計が大事。

# ~/.claude/scripts/agent_audit_logger.py
import json
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Any

LOG_DIR = Path.home() / ".claude" / "agent-audit-logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)

# 危険操作として扱うアクションの定義
DANGEROUS_ACTION_PREFIXES = ("DELETE", "DROP", "TRUNCATE", "UPDATE", "rm", "unlink")


def log_agent_action(
    action: str,
    target: str,
    params: dict[str, Any],
    result: dict[str, Any] | None = None,
    dry_run: bool = True,
) -> None:
    """エージェントの全操作を JSONL で記録する"""
    entry = {
        "timestamp": datetime.now().isoformat(),
        "action": action,
        "target": target,
        "params": params,
        "result": result,
        "dry_run": dry_run,
        "env": os.getenv("APP_ENV", "unknown"),
    }

    log_file = LOG_DIR / f"{datetime.now().strftime('%Y-%m-%d')}.jsonl"
    with open(log_file, "a", encoding="utf-8") as f:
        f.write(json.dumps(entry, ensure_ascii=False) + "\n")

    # 危険な操作はリアルタイムで WARNING ログにも出す
    action_upper = action.upper()
    is_dangerous = any(action_upper.startswith(p.upper()) for p in DANGEROUS_ACTION_PREFIXES)

    if is_dangerous:
        level = logging.INFO if dry_run else logging.WARNING
        logging.log(
            level,
            "⚠️ 危険な操作: %s on %s (dry_run=%s, env=%s)",
            action, target, dry_run, entry["env"]
        )


# 使用例
if __name__ == "__main__":
    # dry-run での削除操作をログに残す
    log_agent_action(
        action="DELETE",
        target="events table",
        params={"days": 30, "condition": "created_at < NOW() - INTERVAL '30 days'"},
        result={"would_delete": 1_240},
        dry_run=True,
    )
    print("ログ記録完了")

実際に役立った場面:深夜にエージェントを走らせた翌朝、ログを確認したら「えっ、こんな操作してたの?」というものが数件あった。dry-run だったので実害はゼロだったが、それでも「なぜその操作を選んだか」を確認してプロンプトを修正した。

ログがなければ「特に問題なかった」で終わっていた。問題が可視化されて初めて対策できる。

ガードレール⑥:CLAUDE.md に明示的なNG操作を書く

コードによる防御に加えて、エージェントへの指示レベルでも防御する。CLAUDE.md に禁止事項を明示するのが一番コストが低い。

<!-- ~/.claude/CLAUDE.md に追記する内容(実物から) -->

## エージェントへの絶対禁止事項

以下の操作は、明示的な確認を取るまで絶対に実行しないこと:

### DB 操作
- `DELETE`, `DROP TABLE`, `TRUNCATE` は必ず dry-run で対象件数を確認してから報告すること
- 本番 DB(DATABASE_URL に "prod", "production" を含む接続先)への書き込みは事前確認必須
- スキーマ変更(ALTER TABLE, DROP COLUMN)は手動レビューが必要なため自動実行禁止

### ファイル操作
- `rm -rf` は禁止。削除対象を一覧化して確認後、個別に削除
- `.env`, `.env.*`, `credentials.*` ファイルへの書き込み・削除は禁止

### 外部サービス
- 本番 API エンドポイントへの POST/PUT/DELETE は手動確認後のみ
- メール送信・Webhook トリガーは事前確認必須

## 推奨フロー(破壊的操作すべてに適用)

1. 「何をするつもりか」を自然言語で説明する
2. dry-run を実行して影響範囲(件数・対象ファイル名)を提示する
3. ユーザーの承認を求める(「実行してよいですか?」)
4. 承認を得てから実行する
5. 実行結果をログに記録する

ポイント:CLAUDE.md の禁止事項はエージェントが最初に読むルールセットになる。コードガードが「後から止める」防御なら、CLAUDE.md は「そもそも試みさせない」予防。両方あると安心感が段違いに違う。


4. Before / After:数値で見る改善効果

6つのガードレールを入れて、実際に変わったことを表にまとめた。

指標 Before After
dry-run なしで実行された操作数 週7〜10件(推定) 0件(全操作が dry-run 経由に)
エージェント操作の後から把握率 ほぼ 0%(ログなし) 100%(JSONL ログで全件確認可能)
バックアップなしで実行された破壊的操作 推定50%以上 0%(hooks で自動バックアップ)
本番DBアクセスの誤実行件数 週1〜2件(推定) 0件(env_guard でブロック)
インシデント後のロールバック可能率 推定30%(バックアップが存在しないケース多) 推定90%以上(自動バックアップ化後)

「週7〜10件」は ガードレールを入れる前の推定値。ログがなかったので正確にはわからないが、CLAUDE.md に禁止事項を入れた後に「実はこれやろうとしてました」とエージェントが報告してくる件数から逆算した。

Before の数字が不確かなのは、そもそも「見えていなかった」から。ガードレールを入れて初めて「こんな頻度でリスクある操作が走ってたんだ」と気づいた。見えないリスクが一番怖い。


5. 今日・今週・今月のアクションリスト

今日(30〜60分でできること)

  1. CLAUDE.md に禁止事項を追加(5分)
    ~/.claude/CLAUDE.md を開き、「DBへの削除操作は dry-run 後に確認」を1行追加。これだけでもエージェントの行動が変わる。

  2. エージェント用 DB ユーザーを作成(15分)
    開発環境で読み取り専用ユーザーを作り、Claude Code が使う DATABASE_URL をそちらに切り替える:

    # 開発用 PostgreSQL での設定例
    psql -c "CREATE USER claude_ro WITH PASSWORD 'your_password';"
    psql -c "GRANT SELECT ON ALL TABLES IN SCHEMA public TO claude_ro;"
    export DATABASE_URL="postgresql://claude_ro:your_password@localhost/your_db"
    
  3. 最初のログファイルを作る(10分)
    agent_audit_logger.py をローカルに保存し、python3 agent_audit_logger.py で動作確認。今日から記録が始まる。

今週(2〜4時間の投資)

  1. hooks に自動バックアップを設定(1時間)
    before_destructive_op.sh を作成し、~/.claude/settings.json の PreBash hooks に登録。rm -rf /tmp/test_delete_dir で動作確認を忘れずに。

  2. env_guard.py を破壊的操作の前に必ず呼ぶ(1時間)
    既存コードの delete 系関数の冒頭に assert_safe_environment() を入れる。本番 DB への誤接続を CI でも検出できるようにすると二重防御になる。

  3. dry-run をデフォルトにするリファクタリング(2時間)
    既存の削除・更新関数に dry_run: bool = True パラメータを追加。最初は面倒に感じるが、1週間後には「なんでこれ最初からやってなかったんだろ」になる。

今月(本番レベルの完成を目指す)

  1. 本番環境のエージェント権限棚卸し
    今エージェントに渡している DB 認証情報・API キー・ファイルパーミッションをすべてリストアップ。最小権限原則に照らして「これ本当に必要?」を一個ずつ確認する。特に「管理者権限のまま渡してる」ものを洗い出す。

  2. インシデント対応手順を1ページで書く
    「エージェントが本番を壊したらどうするか」を想定して手順書を作る。バックアップの保存先・ロールバックコマンド・エスカレーション先。いざというときにパニックにならないために、事前に書いておく。参考フォーマット:Google SRE Incident Management

  3. 月次でログを分析する習慣を作る
    JSONL ログを月1で確認し、「エージェントが何を試みたか」の傾向をつかむ。禁止事項に引っかかった件数が多いパターンは、プロンプトや設計に問題がある。データを見て継続改善する。


6. まとめ:エージェント時代の「守りの設計」

HN の 970 コメントが示していたのは、「誰もが怖いと思っている」ということだった。

AIエージェントは便利だが、人間が「えっ」と止まる場面で止まらない。それは欠陥じゃなくて仕様だ。設計で止める仕組みを入れるのが、使う側の責任になる。

今回紹介した6つのガードレール、まとめると:

  1. dry-run 必須化 → 実行前に影響範囲を数値で確認
  2. 環境チェック → 本番 DB へのアクセスをコードで防ぐ
  3. 自動バックアップ → 破壊的操作の前は必ず取得
  4. 最小権限 → エージェント用ユーザーに DELETE を渡さない
  5. 実行ログ → 全操作を JSONL で記録して可視化
  6. CLAUDE.md 明示禁止 → 指示レベルでも予防する

どれかひとつから今日始めてほしい。CLAUDE.md に1行追加するだけでも違う。


参考リンク


2026-04-30 / 自分の Claude Code 運用経験 + HN スレッドから学んだこと

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?