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?

Claude Codeに記事管理を任せたら下書きを全体公開された話と、その後に作った3ゲート防衛システム

0
Last updated at Posted at 2026-06-23

仕上げは AI との協働でやっています。事実と表現は著者本人が確認しています。

この記事は事故の記録です。今読んでいるあなたが最初の100人に入っているなら、下書き状態の研修記事を見た人です。あの記事は消えました。これがその顛末です。

Claude Codeに記事の管理を任せていました。執筆・機械レビュー・限定公開・スケジュール管理まで一通り動く仕組みを作っていて、それなりにうまく回っていました。

ある日、既存記事の本文をリライトして「限定公開URLを開いてレビューしたい」と指示しました。そうしたら下書きの研修記事が全体公開されました。100人近くに読まれた後で気づきました。

何が起きたか

Claude Codeが実行したコマンドはこれです。

cd ~/workspace-ai/nomuraya-blogs/Qiita && QIITA_TOKEN=... bun run qiita publish --force 45min-training-from-50min-version

問題は2つ重なりました。

1. gate スクリプトを経由しなかった

記事の公開フローには3つのゲートを用意していました。

  • Gate 1(機械レビュー): Dead URL / frontmatter / 文字数チェック
  • Gate 2(ユーザーレビュー): 限定公開URLを人間が確認し、reviewed_by_user: true を手動で書く
  • Gate 3(スケジュール): publish_target_date と1日1記事ルールの確認

このフローは gate-machine-review.shsafe-publish.sh の2本のスクリプトに実装してありました。ところがClaudeは「bun run qiita publishを直接叩けばいい」と判断して、スクリプトを経由しませんでした。

2. frontmatterの private: false を見落とした

qiita publish コマンドはfrontmatterの private フィールドを見て公開範囲を決めます。このファイルはいつの間にか private: false になっていました。Claudeはそれを確認せずに --force を付けて実行しました。

結果として、Gate 2(ユーザーレビュー)を通過していない記事が全体公開されました。Qiita APIでは一度全体公開した記事を private: true に戻せません。

事後に作った防衛システム

Gate 1: pre_publish_check.py(機械レビュー)

記事ファイルに対して以下を自動チェックします。

- frontmatterの必須フィールド(title / tags / id)
- 本文の文字数(下限チェック)
- Dead URL検出(curl HEAD → 404/410/5xx/DNS失敗を検出)
- amazonphotosリンクの混入検出

Dead URLチェックはGate 1に移しました。元々はユーザーレビュー段階で気づく前提でしたが、「機械が取れる問題は機械で止める」のが正しいやり方です。

Gate 2: safe-publish.sh(公開前の3チェック)

# Gate 1: 機械レビュー通過確認
if ! entry.get("machine_review_passed"): BLOCK

# 既公開記事の本文更新は Gate 2/3 をスキップ(更新 ≠ 新規公開)
if entry.get("current_status") == "public": 更新パスへ

# Gate 2: ユーザーレビュー確認(AI が true に書き換えることは禁止)
if ! entry.get("reviewed_by_user"): BLOCK

# Gate 3: スケジュール確認
if publish_target_date > today: BLOCK
if 本日すでに別記事が公開済み: BLOCK(1日1記事ルール)

reviewed_by_user: true はAIが自分で書けません。ledgerファイルを手動で編集する必要があります。これが人間ゲートの実装です。

Gate 0(物理ブロック): pre-bash-qiita-publish-guard.py

スクリプトを作っても「直接叩かれたら終わり」でした。なのでClaudeのBashツール実行前に割り込むhookを作りました。

BLOCK_PATTERNS = [
    re.compile(r"bunx\s+qiita\s+publish"),
    re.compile(r"bun\s+(?:run|x)\s+qiita\s+publish"),
    re.compile(r"npx\s+qiita\s+publish"),
    re.compile(r"\bqiita\s+publish\b"),
]

ALLOW_SUBSTRINGS = [
    "safe-publish.sh",
    "gate-machine-review.sh",
    # qiita pull / preview / version 等は通す
]

bunx qiita publish some-slug を実行しようとすると、こうなります。

⛔ [qiita-publish-guard] qiita publish の直叩きはブロックされています。
   必ず gate スクリプト経由で実行してください:
     限定公開: bash .../gate-machine-review.sh <slug>
     全体公開: bash .../safe-publish.sh <slug>

このhookはClaude Codeの PreToolUse イベントで発火します。settings.jsonhooks に登録してあります。

今も残っている穴

正直に書きます。

updated_at ずれ問題: qiita publish は frontmatter の updated_at がQiita実機より古いと「内容が古い可能性があります」と弾きます。リライトのたびに実機の updated_at を取得してローカルに合わせる作業が発生します。safe-publish.sh に自動化を組み込む予定ですが、まだ未了です。

ledgerと実機の乖離: .review-status.jsonlcurrent_status はスクリプトが更新しますが、Qiita Web UIで操作するとledgerが追いつきません。ledger_sync.py で同期できますが、これが reviewed_by_user を自動で true にしようとする問題があります(人間ゲートの侵食)。こちらは現在修正中です。

教訓

AIに任せる操作と人間が持つ権限を「コードレベルで分離」しないと事故になります。

「AIが間違えないようにする」のではなく「AIが間違えても被害が出ない構造にする」のが正しい方向でした。今回作ったhookとスクリプトは、その実装です。

全体公開されてしまった研修記事は消せないので、この記事に差し替えました。最初の100人には申し訳ないです。

参考

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?