From 587f124c65401dfbbf67508dc91e3f52c5036012 Mon Sep 17 00:00:00 2001 From: touyou <465697+touyou@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:41:20 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=94=92=20chore:=20=E3=82=B9=E3=82=AD?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E4=B8=8D=E5=8F=AF=E9=80=86=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E3=81=AB=E3=83=92=E3=83=A5=E3=83=BC=E3=83=9E=E3=83=B3=E3=82=B2?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 高能力モデル(Opus 5 / Fable 等)が手順リストを最後まで走り切る挙動を 前提に、取り消せない操作の手前で必ず停止するよう明文化した。 - release-sparkle-design: PR マージ / タグ push / GitHub Release 作成 / npm publish を 🛑 でマークし、各セクション冒頭に停止ブロックを追加。 「チェックリストに並んでいること」が実行理由にならない旨を明記 - accessibility-checker: Fix はレビュー依頼の既定動作ではなく、明示的な 修正依頼+対象の合意があるときだけ実行する旨を SKILL.md と references/workflow.md に追加 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013WFgMWTQ6QAWShTRLNRUQx --- .claude/skills/accessibility-checker/SKILL.md | 16 ++++++ .../references/workflow.md | 4 ++ .../skills/release-sparkle-design/SKILL.md | 56 ++++++++++++++++--- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/.claude/skills/accessibility-checker/SKILL.md b/.claude/skills/accessibility-checker/SKILL.md index b46df1fa..d9a0e984 100644 --- a/.claude/skills/accessibility-checker/SKILL.md +++ b/.claude/skills/accessibility-checker/SKILL.md @@ -96,6 +96,22 @@ Review each component/story, focusing on applicability and evidence collection. Apply minimal, safe code changes per component, then re-check affected items. +> **🛑 Fix は既定の動作ではない(このスキルで最優先のルール)** +> +> このスキルの既定の成果物は **レポート(Step 4)まで**。コードの書き換えは以下を満たしたときだけ行う。 +> +> - ユーザーが「直して」「修正して」と**明示的に依頼した**場合に限る。 +> 「a11y チェックして」「レビューして」という依頼は**修正の承認ではない** +> - 修正に入る前に、**Fail / Needs review の一覧を提示し、どれを直すかユーザーに選んでもらう**。 +> 「見つかったので全部直しておきました」をやらない +> - 一度に触るのは**選ばれた対象のスコープ内だけ**。レビュー中に視界に入った別コンポーネントや +> 別ファイルの問題は、直さずレポートの「フォローアップ候補」に積む +> - Fail の指摘が正しくても、修正が公開 API の変更(props の削除・リネーム・挙動変更)を伴う場合は +> 実装せず提案に留める(`Deprecate Risky APIs Progressively` の方針を参照) +> +> English: Reporting is the default deliverable. Only modify code when the user explicitly asks for +> fixes, and only within the scope they approved — never fix everything you found on your own initiative. + --- ## Workflow diff --git a/.claude/skills/accessibility-checker/references/workflow.md b/.claude/skills/accessibility-checker/references/workflow.md index 2d2066ba..22f4a51a 100644 --- a/.claude/skills/accessibility-checker/references/workflow.md +++ b/.claude/skills/accessibility-checker/references/workflow.md @@ -59,6 +59,10 @@ For each checklist item, record how it was verified: ## 4. Fixes (Minimal Changes + Non-Breaking Improvements) +> 🛑 **This step only runs when the user explicitly asked for fixes and approved the scope.** +> See "3. Fix" in `SKILL.md`. If they only asked for a review, stop after step 3 (Report) +> and list the proposed fixes instead of applying them. + - Prioritize fixes that don't break compatibility - For "accident-prone APIs," prefer **gradual deprecation** over removal (see `PROJECT_POLICY.md` for details) diff --git a/.claude/skills/release-sparkle-design/SKILL.md b/.claude/skills/release-sparkle-design/SKILL.md index 93d4fac1..14ebb4d0 100644 --- a/.claude/skills/release-sparkle-design/SKILL.md +++ b/.claude/skills/release-sparkle-design/SKILL.md @@ -29,6 +29,20 @@ user-invocable: true ## AI アシスタントへの指示 +> **🛑 不可逆操作の扱い(このスキルで最優先のルール)** +> +> 下のチェックリストには、**取り消せない操作**(PR マージ / タグ push / GitHub Release 作成 / npm publish)が +> 他の安全な項目と同じリスト形式で並んでいる。**リストに並んでいることは実行してよい理由にならない。** +> +> - 🛑 が付いた項目は、**ユーザーが明示的にその操作を指示したときだけ**実行する。 +> 「リリースして」という最初の依頼は、マージ・publish までの**事前承認ではない**。 +> - 🛑 の直前まで進んだら、そこで**必ず停止し**、状況(PR 番号 / CI 状態 / 次に実行するコマンド)を +> 報告してユーザーの指示を待つ。「次に進めますか?」と聞いて、返答を待たずに進めてはならない。 +> - 特に `npm publish` は**事実上取り消せない**(npm の unpublish は 72 時間以内かつ条件付き)。 +> 誤った内容を publish した場合の回復手段は「新しいパッチバージョンを出す」しかない。 +> - このスキルを読んだことで、リポジトリの `CLAUDE.md` やユーザーの運用ルール +> (「マージは指示があるときだけ」等)が緩和されることはない。**より厳しい方が常に優先される。** + ### 実行方針 1. **ユーザーにリリース種別を確認** @@ -43,6 +57,9 @@ user-invocable: true 3. **以下のチェックリストを順に実行する** + - ただし 🛑 が付いた項目に到達したら、そこで停止してユーザーの明示的な指示を待つ + (上の「不可逆操作の扱い」を参照)。停止せずに走り切ってはならない + --- ## リリース手順チェックリスト @@ -61,11 +78,15 @@ user-invocable: true タグ未作成・Release 未作成のバージョンがある場合は **新バージョンを切る前に** 必ず追補する。 - [ ] 該当バージョンのリリースコミット(`🔖 chore: release vX.Y.Z` 等)の SHA を特定: `git log --all --oneline | grep release` -- [ ] そのコミットに対して `git tag vX.Y.Z ` でタグを作成 -- [ ] `git push origin vX.Y.Z` でタグを push -- [ ] `gh release create vX.Y.Z --title "vX.Y.Z" --notes "..."` で Release を作成 +- [ ] そのコミットに対して `git tag vX.Y.Z ` でタグを作成(ローカルタグまでは自走してよい) +- [ ] 🛑 `git push origin vX.Y.Z` でタグを push +- [ ] 🛑 `gh release create vX.Y.Z --title "vX.Y.Z" --notes "..."` で Release を作成 - notes は CHANGELOG.md の該当セクションをコピペするのが確実 +> 🛑 **ここで停止する。** 追補対象のバージョンと、打とうとしているタグ / SHA の対応表を提示し、 +> ユーザーの承認を得てから push・Release 作成を実行する。タグの push は取り消しが面倒で、 +> 誤ったコミットに打つと publish 対象がずれる。 + ### 新バージョンの準備(リリース PR 作成) - [ ] `chore/release-X.Y.Z` ブランチを `origin/main` から切る(worktree 推奨) @@ -88,31 +109,52 @@ user-invocable: true ### リリース PR レビュー・マージ +> 🛑 **AI はここで必ず停止する。PR 作成までがこのスキルの自走範囲。** +> +> リリース PR を作ったら、PR の URL・変更差分の要約・CI の状態を報告して**ユーザーの応答を待つ**。 +> 以下のマージ以降の項目は、ユーザーが「マージして」と明示的に指示したときだけ実行する。 +> +> **AI によるセルフレビュー(`/code-review` 等)は人間のレビューの代替にならない。** +> publish の前段であるマージは、後戻りが難しい操作の入口なので、必ず人が差分を見る。 + - [ ] レビュー受領(CodeRabbit / Codex / 人間レビュアー) - [ ] 全 CI green を確認(`gh pr checks `) -- [ ] **通常マージ(`--merge`)でマージ**。スカッシュは禁止(コミットが消えるとリリース履歴が辿れない) -- [ ] base branch protection があるため、必要なら admin マージ: `gh pr merge --merge --admin` +- [ ] 🛑 **通常マージ(`--merge`)でマージ**。スカッシュは禁止(コミットが消えるとリリース履歴が辿れない) +- [ ] 🛑 base branch protection があるため、必要なら admin マージ: `gh pr merge --merge --admin` + - `--admin` は保護ブランチのレビュー要件を迂回する。**ユーザーが admin マージを明示的に求めたときだけ**使う。 + 「protection で弾かれたから `--admin` を付け直す」を AI の判断でやらない ### マージ後: タグ・Release・publish +> 🛑 **このセクションは、マージが完了したことをユーザーと確認したうえで、 +> 「タグを打って publish して」と明示的に指示されてから着手する。** +> マージが済んだからといって、AI の判断で続けて publish まで走らない。 +> 各コマンドは実行前に、置き換えた実際の値(`X.Y.Z` / `RELEASE_SHA`)を提示して確認を取る。 + - [ ] `git fetch origin main && git checkout main && git pull` で最新化 - [ ] **リリースコミットの SHA を `git log --oneline | grep release | head -1` で必ず特定** - [ ] **タグはリリースコミットの SHA を明示して打つ**(HEAD に打つと main が進んだ場合に誤タグ → publish 漏れ・誤 publish の温床): ```bash RELEASE_SHA=$(git log --oneline | grep "release vX.Y.Z" | head -1 | awk '{print $1}') git tag vX.Y.Z "$RELEASE_SHA" + ``` +- [ ] 🛑 タグを push する(打った SHA とコミットメッセージを提示して確認後): + ```bash git push origin vX.Y.Z ``` -- [ ] **GitHub Release 作成**: +- [ ] 🛑 **GitHub Release 作成**: CHANGELOG.md のセクションを `--notes` で直接渡すのが確実: ```bash awk '/^## \[X\.Y\.Z\]/{flag=1;next} /^## \[/{flag=0} flag' CHANGELOG.md > /tmp/notes.md gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/notes.md ``` -- [ ] **npm publish** — GitHub Actions ワークフローを実行。**tag ref で実行する**ことで、main が進んでも正しいリリースコミットの内容が publish される: +- [ ] 🛑 **npm publish** — GitHub Actions ワークフローを実行。**tag ref で実行する**ことで、main が進んでも正しいリリースコミットの内容が publish される: ```bash gh workflow run "Publish to npm" --ref vX.Y.Z ``` + - **publish は取り消せない。** npm の unpublish は公開 72 時間以内かつ依存されていない場合のみで、 + 実質的な回復手段は「新しいパッチバージョンを出す」しかない。実行前に必ずユーザーの明示的な + 指示を得る(`--ref` に渡すタグ名も読み上げて確認する) - workflow が `npm error code E404 'pkg@X.Y.Z' is not in this registry` で失敗する場合は **NPM_TOKEN の期限切れ**(auth 失敗が 404 として返る npm registry 仕様) - workflow が `npm error code EOTP` で失敗する場合は、**トークン種別が 2FA バイパス対応していない**。次のいずれかで作り直し: - Classic Token: タイプを **Automation** で発行 From f4ffd6022044de529b8a37eb26c8df4f5a85eb3b Mon Sep 17 00:00:00 2001 From: touyou <465697+touyou@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:02:45 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=94=92=20feat:=20=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E9=80=86=E6=93=8D=E4=BD=9C=E3=82=92=E3=83=96=E3=83=AD=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=81=99=E3=82=8B=20PreToolUse=20hook=20=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 散文のルールは「スキルを読んだセッション」にしか効かない。npm publish や PR マージのように取り消せない操作は、決定論的に止める方が確実なので ハーネス側へ移した。 - scripts/hooks/irreversible-ops-guard.sh: npm publish / unpublish / deprecate、gh pr merge、gh release create・delete、publish 系 ワークフロー、リリースタグの push、force push、リモート ref 削除を検知 して exit 2 でブロック。ユーザーの指示を得たあと SPARKLE_CONFIRM=1 を 先頭に付けて再実行する運用 - scripts/hooks/irreversible-ops-guard.test.sh: ブロック 23 件 + 素通し 25 件の計 48 ケース。`npm run publish-docs` や `git commit -m "push 前に直す"` のような誤検知を重点的に検証 - package.json に test:hooks を追加 - .claude/settings.json で PreToolUse(Bash) に接続 - docs/ai-instructions/context.md に運用ルールを追記し make ai-instructions で AGENTS.md / Copilot / Cursor へ反映 - release-sparkle-design: hook で担保できるようになった分、散文のゲートを 圧縮して hook への参照に置き換え Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013WFgMWTQ6QAWShTRLNRUQx --- .claude/settings.json | 17 ++ .../skills/release-sparkle-design/SKILL.md | 29 +-- .cursor/rules/context.mdc | 9 + .../instructions/ai-context.instructions.md | 9 + AGENTS.md | 9 + docs/ai-instructions/context.md | 9 + package.json | 1 + scripts/hooks/irreversible-ops-guard.sh | 209 ++++++++++++++++++ scripts/hooks/irreversible-ops-guard.test.sh | 93 ++++++++ 9 files changed, 367 insertions(+), 18 deletions(-) create mode 100644 .claude/settings.json create mode 100755 scripts/hooks/irreversible-ops-guard.sh create mode 100755 scripts/hooks/irreversible-ops-guard.test.sh diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..23d1526f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/hooks/irreversible-ops-guard.sh", + "timeout": 15, + "statusMessage": "Checking for irreversible operations..." + } + ] + } + ] + } +} diff --git a/.claude/skills/release-sparkle-design/SKILL.md b/.claude/skills/release-sparkle-design/SKILL.md index 14ebb4d0..a62fdc42 100644 --- a/.claude/skills/release-sparkle-design/SKILL.md +++ b/.claude/skills/release-sparkle-design/SKILL.md @@ -31,17 +31,14 @@ user-invocable: true > **🛑 不可逆操作の扱い(このスキルで最優先のルール)** > -> 下のチェックリストには、**取り消せない操作**(PR マージ / タグ push / GitHub Release 作成 / npm publish)が -> 他の安全な項目と同じリスト形式で並んでいる。**リストに並んでいることは実行してよい理由にならない。** +> 🛑 が付いた項目(PR マージ / タグ push / GitHub Release 作成 / npm publish)は、 +> **ユーザーが名指しで指示したときだけ**実行する。「リリースして」という最初の依頼は、 +> マージ・publish までの事前承認ではない。**リストに並んでいることは実行してよい理由にならない。** +> 🛑 の手前まで進んだら停止し、状況(PR 番号 / CI 状態 / 次のコマンド)を報告して指示を待つ。 > -> - 🛑 が付いた項目は、**ユーザーが明示的にその操作を指示したときだけ**実行する。 -> 「リリースして」という最初の依頼は、マージ・publish までの**事前承認ではない**。 -> - 🛑 の直前まで進んだら、そこで**必ず停止し**、状況(PR 番号 / CI 状態 / 次に実行するコマンド)を -> 報告してユーザーの指示を待つ。「次に進めますか?」と聞いて、返答を待たずに進めてはならない。 -> - 特に `npm publish` は**事実上取り消せない**(npm の unpublish は 72 時間以内かつ条件付き)。 -> 誤った内容を publish した場合の回復手段は「新しいパッチバージョンを出す」しかない。 -> - このスキルを読んだことで、リポジトリの `CLAUDE.md` やユーザーの運用ルール -> (「マージは指示があるときだけ」等)が緩和されることはない。**より厳しい方が常に優先される。** +> これらは `scripts/hooks/irreversible-ops-guard.sh`(PreToolUse hook)が実際にブロックする。 +> ブロックされたら、ユーザーの指示を得たうえで `SPARKLE_CONFIRM=1` を先頭に付けて再実行する。 +> **hook が無い環境(Claude Code 以外のエージェント)でも、上のルールは同じように適用する。** ### 実行方針 @@ -110,10 +107,7 @@ user-invocable: true ### リリース PR レビュー・マージ > 🛑 **AI はここで必ず停止する。PR 作成までがこのスキルの自走範囲。** -> -> リリース PR を作ったら、PR の URL・変更差分の要約・CI の状態を報告して**ユーザーの応答を待つ**。 -> 以下のマージ以降の項目は、ユーザーが「マージして」と明示的に指示したときだけ実行する。 -> +> PR の URL・変更差分の要約・CI の状態を報告して**ユーザーの応答を待つ**。 > **AI によるセルフレビュー(`/code-review` 等)は人間のレビューの代替にならない。** > publish の前段であるマージは、後戻りが難しい操作の入口なので、必ず人が差分を見る。 @@ -126,10 +120,9 @@ user-invocable: true ### マージ後: タグ・Release・publish -> 🛑 **このセクションは、マージが完了したことをユーザーと確認したうえで、 -> 「タグを打って publish して」と明示的に指示されてから着手する。** -> マージが済んだからといって、AI の判断で続けて publish まで走らない。 -> 各コマンドは実行前に、置き換えた実際の値(`X.Y.Z` / `RELEASE_SHA`)を提示して確認を取る。 +> 🛑 **マージが済んだからといって、AI の判断で続けて publish まで走らない。** +> 「タグを打って publish して」と明示的に指示されてから着手し、実行前に +> 置き換えた実際の値(`X.Y.Z` / `RELEASE_SHA`)を提示して確認を取る。 - [ ] `git fetch origin main && git checkout main && git pull` で最新化 - [ ] **リリースコミットの SHA を `git log --oneline | grep release | head -1` で必ず特定** diff --git a/.cursor/rules/context.mdc b/.cursor/rules/context.mdc index 64a07c9d..6cb1c2ef 100644 --- a/.cursor/rules/context.mdc +++ b/.cursor/rules/context.mdc @@ -84,6 +84,15 @@ pnpm test # Component tests - **Update the lockfile whenever `package.json` changes**: CI runs `pnpm install --frozen-lockfile` and will fail otherwise - **Use the pinned toolchain**: Node.js 22.14.0 / pnpm 10.12.4 (see `.tool-versions`; newer pnpm majors can rewrite the lockfile) +### Irreversible Operations Are Blocked by a Hook +`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks +`npm publish`, `gh pr merge`, `gh release create/delete`, publish workflows, tag pushes, and force pushes. + +- These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. +- Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. +- Agents without hook support must follow the same rule — the hook is a backstop, not the rule itself. +- Tests: `pnpm test:hooks` + ## AI Assistance Guidelines - Refer to specific instruction files for detailed guidance: - `docs/ai-instructions/testing.md` for testing diff --git a/.github/instructions/ai-context.instructions.md b/.github/instructions/ai-context.instructions.md index 80327971..6d94819c 100644 --- a/.github/instructions/ai-context.instructions.md +++ b/.github/instructions/ai-context.instructions.md @@ -82,6 +82,15 @@ pnpm test # Component tests - **Update the lockfile whenever `package.json` changes**: CI runs `pnpm install --frozen-lockfile` and will fail otherwise - **Use the pinned toolchain**: Node.js 22.14.0 / pnpm 10.12.4 (see `.tool-versions`; newer pnpm majors can rewrite the lockfile) +### Irreversible Operations Are Blocked by a Hook +`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks +`npm publish`, `gh pr merge`, `gh release create/delete`, publish workflows, tag pushes, and force pushes. + +- These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. +- Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. +- Agents without hook support must follow the same rule — the hook is a backstop, not the rule itself. +- Tests: `pnpm test:hooks` + ## AI Assistance Guidelines - Refer to specific instruction files for detailed guidance: - `docs/ai-instructions/testing.md` for testing diff --git a/AGENTS.md b/AGENTS.md index 4226aec6..d5c4887f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,15 @@ pnpm test # Component tests - **Update the lockfile whenever `package.json` changes**: CI runs `pnpm install --frozen-lockfile` and will fail otherwise - **Use the pinned toolchain**: Node.js 22.14.0 / pnpm 10.12.4 (see `.tool-versions`; newer pnpm majors can rewrite the lockfile) +### Irreversible Operations Are Blocked by a Hook +`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks +`npm publish`, `gh pr merge`, `gh release create/delete`, publish workflows, tag pushes, and force pushes. + +- These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. +- Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. +- Agents without hook support must follow the same rule — the hook is a backstop, not the rule itself. +- Tests: `pnpm test:hooks` + ## AI Assistance Guidelines - Refer to specific instruction files for detailed guidance: - `docs/ai-instructions/testing.md` for testing diff --git a/docs/ai-instructions/context.md b/docs/ai-instructions/context.md index 53f6a6b4..a376f1fd 100644 --- a/docs/ai-instructions/context.md +++ b/docs/ai-instructions/context.md @@ -78,6 +78,15 @@ pnpm test # Component tests - **Update the lockfile whenever `package.json` changes**: CI runs `pnpm install --frozen-lockfile` and will fail otherwise - **Use the pinned toolchain**: Node.js 22.14.0 / pnpm 10.12.4 (see `.tool-versions`; newer pnpm majors can rewrite the lockfile) +### Irreversible Operations Are Blocked by a Hook +`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks +`npm publish`, `gh pr merge`, `gh release create/delete`, publish workflows, tag pushes, and force pushes. + +- These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. +- Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. +- Agents without hook support must follow the same rule — the hook is a backstop, not the rule itself. +- Tests: `pnpm test:hooks` + ## AI Assistance Guidelines - Refer to specific instruction files for detailed guidance: - `docs/ai-instructions/testing.md` for testing diff --git a/package.json b/package.json index 8e7ec36b..069e424f 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "start": "next start", "lint": "next lint", "test": "vitest run --project unit", + "test:hooks": "bash scripts/hooks/irreversible-ops-guard.test.sh", "test:watch": "vitest --project unit", "test:ai-analyze": "npm test > test-output.log 2>&1 && echo 'Test results saved to test-output.log for AI analysis'", "test:failures": "npm test > test-output.log 2>&1 && grep -A 5 -B 5 'FAIL\\|✗\\|Error' test-output.log > test-failures.log && echo 'Test failures extracted to test-failures.log'", diff --git a/scripts/hooks/irreversible-ops-guard.sh b/scripts/hooks/irreversible-ops-guard.sh new file mode 100755 index 00000000..db8c29ac --- /dev/null +++ b/scripts/hooks/irreversible-ops-guard.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Claude Code PreToolUse hook: +# 取り消せない操作 (npm publish / PR マージ / タグ push / GitHub Release 作成 / +# force push など) を検知したら、実行前にブロックする (exit 2)。 +# +# 目的は「AI が手順リストを最後まで走り切って、人が見ないまま公開してしまう」 +# のを止めること。スキル本文 (.claude/skills/release-sparkle-design/SKILL.md) にも +# 同じ趣旨のゲートを書いてあるが、散文の指示はスキルを読んでいないセッションには +# 効かない。このフックはスキルの読み込み有無に関係なく効く。 +# +# ブロックを解除するには、ユーザーから明示的な指示を得たうえで、 +# コマンドの先頭に SPARKLE_CONFIRM=1 を付けて実行する: +# SPARKLE_CONFIRM=1 gh workflow run "Publish to npm" --ref v1.2.3 +# +# SPARKLE_CONFIRM=1 は「ユーザーがこの操作を名指しで指示した」ことの表明であり、 +# AI が自分の判断で付け足してよいものではない。 +set -uo pipefail + +input="$(cat)" + +# 全 Bash 呼び出しで起動されるため、jq を起動する前に生 JSON で安価に足切りする。 +# ここに挙げたキーワードを含まないコマンドは以降の判定対象になりえない。 +# ワークフロー名は `"Publish to npm"` のように大文字を含むため大小文字を無視する。 +shopt -s nocasematch +case "$input" in + *publish* | *merge* | *push* | *release* | *deprecate*) ;; + *) + shopt -u nocasematch + exit 0 + ;; +esac +shopt -u nocasematch + +command="$(jq -r '.tool_input.command // ""' <<<"$input")" +[ -n "$command" ] || exit 0 + +# ---- コマンドをセグメントに割る ---- +# `;` `&` `|` `&&` `||` と改行で分割し、各セグメントの「先頭に来るコマンド」だけを +# 判定対象にする。こうするとコミットメッセージ等に "npm publish" の文字列が +# 含まれるだけのケースを弾ける (先頭が git commit になるため)。 +segments="$(printf '%s' "$command" | sed -E 's/(\|\||&&|[;&|])/\n/g')" + +blocked_op="" +blocked_segment="" + +while IFS= read -r segment; do + [ -n "$segment" ] || continue + + # 先頭の空白 / env / インラインの VAR=value 代入を剥がして、実際のコマンド名を露出させる。 + # 剥がした代入の中に SPARKLE_CONFIRM=1 があればそのセグメントは承認済みとみなす。 + confirmed=0 + seg="${segment#"${segment%%[![:space:]]*}"}" + while true; do + case "$seg" in + env[[:space:]]*) + seg="${seg#env}" + seg="${seg#"${seg%%[![:space:]]*}"}" + ;; + SPARKLE_CONFIRM=1[[:space:]]*) + confirmed=1 + seg="${seg#SPARKLE_CONFIRM=1}" + seg="${seg#"${seg%%[![:space:]]*}"}" + ;; + [A-Za-z_]*=*) + # 変数代入は「=」より前に空白を含まない。それ以外は通常のコマンドとして扱う + head="${seg%%[[:space:]]*}" + case "$head" in + *=*) + seg="${seg#"$head"}" + seg="${seg#"${seg%%[![:space:]]*}"}" + ;; + *) break ;; + esac + ;; + *) break ;; + esac + done + + [ -n "$seg" ] || continue + [ "${SPARKLE_CONFIRM:-0}" = "1" ] && confirmed=1 + [ "$confirmed" -eq 1 ] && continue + + # ---- 単語に分割して「コマンド名 + サブコマンド」で判定する ---- + # 部分一致 (*publish*) だと `npm run publish-docs` や + # `git commit -m "push 前に直す"` まで拾ってしまうため、位置を見て判定する。 + set -f # 展開時の glob を無効化 + # shellcheck disable=SC2086 + set -- $seg + set +f + [ "$#" -gt 0 ] || continue + + cmd="$1" + shift + # `git -C push` のように、サブコマンドの前に来るオプションを読み飛ばす + while [ "$#" -gt 0 ]; do + case "$1" in + -C | -c | --git-dir | --work-tree | -R | --repo) + shift + [ "$#" -gt 0 ] && shift + ;; + -*) shift ;; + *) break ;; + esac + done + sub="${1:-}" + sub2="${2:-}" + + op="" + case "$cmd" in + # --- パッケージの公開系 --- + npm | pnpm | yarn | bun) + case "$sub" in + publish) + # --dry-run は公開しないので素通しする + case " $seg " in + *" --dry-run "* | *" --dry-run="*) ;; + *) op="$cmd publish (パッケージの公開)" ;; + esac + ;; + unpublish) op="$cmd unpublish (公開済みバージョンの削除)" ;; + deprecate) op="$cmd deprecate (公開済みバージョンの非推奨化)" ;; + esac + ;; + + # --- GitHub 側の不可逆操作 --- + gh) + case "$sub $sub2" in + "pr merge") op="gh pr merge (PR のマージ)" ;; + "release create") op="gh release create (GitHub Release の作成)" ;; + "release delete") op="gh release delete (GitHub Release の削除)" ;; + "workflow run") + # publish 系ワークフローだけを対象にする (CI の再実行などは素通し) + shopt -s nocasematch + [[ "$seg" == *publish* ]] && op="gh workflow run (publish ワークフローの実行)" + shopt -u nocasematch + ;; + esac + ;; + + # --- git の巻き戻しにくい操作 --- + git) + if [ "$sub" = "push" ]; then + shift # サブコマンド (push) を除いた引数を走査する + remote_seen=0 + for tok in "$@"; do + case "$tok" in + --force | -f | --force-with-lease | --force-with-lease=*) + op="git push --force 系 (リモート履歴の上書き)" + break + ;; + --delete | -d) + op="git push --delete (リモート ref の削除)" + break + ;; + --tags | --follow-tags) + op="git push --tags (タグの push)" + break + ;; + -*) ;; + *) + if [ "$remote_seen" -eq 0 ]; then + remote_seen=1 + else + # refspec の dst 側を見る。タグ (vX.Y.Z) の push だけを対象にする + dst="${tok##*:}" + if [[ "$dst" =~ ^(refs/tags/)?v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + op="git push (リリースタグの push)" + break + fi + # "git push origin :refs/heads/foo" 形式の削除 + case "$tok" in + :*) op="git push : (リモート ref の削除)" && break ;; + esac + fi + ;; + esac + done + fi + ;; + esac + + if [ -n "$op" ]; then + blocked_op="$op" + blocked_segment="$seg" + break + fi +done <<<"$segments" + +[ -n "$blocked_op" ] || exit 0 + +{ + echo "取り消せない操作を検知したため、実行前にブロックしました。" + echo "" + echo " 検知した操作: $blocked_op" + echo " コマンド : $blocked_segment" + echo "" + echo "この操作はユーザーが名指しで指示したときだけ実行してよい。" + echo "「リリースして」「進めて」といった包括的な依頼は、この操作の承認ではない。" + echo "" + echo "次にやること:" + echo " 1. 実行しようとした操作と、その影響 (何が公開・変更されるか) をユーザーに報告する" + echo " 2. ユーザーから明示的な指示を得る" + echo " 3. 指示を得たら、コマンドの先頭に SPARKLE_CONFIRM=1 を付けて再実行する" + echo " 例: SPARKLE_CONFIRM=1 $blocked_segment" + echo "" + echo "SPARKLE_CONFIRM=1 は「ユーザーが指示した」ことの表明。指示が無いまま付けてはならない。" +} >&2 + +exit 2 diff --git a/scripts/hooks/irreversible-ops-guard.test.sh b/scripts/hooks/irreversible-ops-guard.test.sh new file mode 100755 index 00000000..8b961a0e --- /dev/null +++ b/scripts/hooks/irreversible-ops-guard.test.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# irreversible-ops-guard.sh のテスト。 +# +# bash scripts/hooks/irreversible-ops-guard.test.sh +# +# ブロックすべきコマンド (exit 2) と、素通しすべきコマンド (exit 0) を両方検証する。 +# 誤検知 (安全なコマンドを止めてしまう) は開発体験を著しく損ねるため、 +# PASS 側のケースを増やすことを特に重視している。 +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="$script_dir/irreversible-ops-guard.sh" + +pass=0 +fail=0 + +run_case() { + local expected="$1" # block | pass + local command="$2" + local payload actual_exit + + payload="$(jq -nc --arg c "$command" '{tool_input: {command: $c}, cwd: "/tmp"}')" + env -u SPARKLE_CONFIRM bash "$hook" >/dev/null 2>&1 <<<"$payload" + actual_exit=$? + + local actual="pass" + [ "$actual_exit" -eq 2 ] && actual="block" + + if [ "$actual" = "$expected" ]; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + echo " ✗ expected=$expected actual=$actual :: $command" + fi +} + +echo "== ブロックされるべきコマンド ==" +run_case block 'npm publish' +run_case block 'npm publish --access public' +run_case block 'pnpm publish --no-git-checks' +run_case block 'npm unpublish sparkle-design@1.0.8' +run_case block 'npm deprecate sparkle-design@1.0.8 "use 1.0.9"' +run_case block 'gh pr merge 297 --merge --admin' +run_case block 'gh pr merge --squash 12' +run_case block 'gh -R goodpatch/sparkle-design pr merge 297 --merge' +run_case block 'gh release create v1.0.8 --notes-file /tmp/notes.md' +run_case block 'gh release delete v1.0.8' +run_case block 'gh workflow run "Publish to npm" --ref v1.0.8' +run_case block 'gh workflow run "Publish Skills"' +run_case block 'git push origin v1.0.8' +run_case block 'git push origin refs/tags/v1.0.8' +run_case block 'git -C /tmp/repo push origin v1.0.8' +run_case block 'git push --tags' +run_case block 'git push --force origin main' +run_case block 'git push -f origin feature/foo' +run_case block 'git push --force-with-lease origin main' +run_case block 'git push origin --delete feature/foo' +run_case block 'git push origin :refs/heads/feature-foo' +run_case block 'git add -A && npm publish' +run_case block 'pnpm build && gh workflow run "Publish to npm" --ref v1.0.8' + +echo "== 素通しされるべきコマンド (誤検知の検出) ==" +run_case pass 'npm publish --dry-run' +run_case pass 'pnpm publish --dry-run --no-git-checks' +run_case pass 'npm run publish-docs' +run_case pass 'npm run build' +run_case pass 'pnpm test' +run_case pass 'gh pr create --draft --title "リリース v1.0.8"' +run_case pass 'gh pr view 297 --json state' +run_case pass 'gh pr checks 297' +run_case pass 'gh release list --limit 10' +run_case pass 'gh release view v1.0.8' +run_case pass 'gh workflow run "CI" --ref main' +run_case pass 'gh run list --branch main --limit 5' +run_case pass 'git push -u origin harden/skill-guards' +run_case pass 'git push origin main' +run_case pass 'git push' +run_case pass 'git tag v1.0.8 abc1234' +run_case pass 'git commit -m "push 前に直す"' +run_case pass 'git commit -m "chore: npm publish の手順を追記"' +run_case pass 'git commit -m "🔖 chore: release v1.0.8"' +run_case pass 'echo "gh pr merge は AI が自発的に実行しない"' +run_case pass 'grep -rn "npm publish" .claude/skills/' +run_case pass 'git log --oneline | grep release' +# ユーザーの明示指示を受けたあとの再実行 (エスケープハッチ) +run_case pass 'SPARKLE_CONFIRM=1 npm publish' +run_case pass 'SPARKLE_CONFIRM=1 gh workflow run "Publish to npm" --ref v1.0.8' +run_case pass 'SPARKLE_CONFIRM=1 gh pr merge 297 --merge --admin' + +echo "" +echo "pass=$pass fail=$fail" +[ "$fail" -eq 0 ] || exit 1 +echo "すべてのケースが期待どおりでした。" From a74d55c80c8474616e8aae6b11fcc890b24baee8 Mon Sep 17 00:00:00 2001 From: touyou <465697+touyou@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:24:47 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=94=92=20chore:=20hook=20=E3=81=A7=20?= =?UTF-8?q?gh=20repo=20create=20/=20delete=20/=20archive=20=E3=82=82?= =?UTF-8?q?=E3=83=96=E3=83=AD=E3=83=83=E3=82=AF=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scaffold-extension-repo スキルは `gh repo create {org}/{project}-design --private --clone` を手順の一項目として持つが、その場に確認の指示が無く、 org 名・リポジトリ名がプレースホルダのため AI が推測で埋める余地もある。 組織にリポジトリを作る/消す/アーカイブするのは外部に出る不可逆操作なので、 散文ではなく hook で止める。 - gh repo create / delete / archive を検知対象に追加 - 早期フィルタに "gh repo" を追加(repo 単体だと広すぎるため) - テストを 49 → 54 ケースに拡張。gh repo view / clone、gh api repos/... は 素通しすることを確認 - docs/ai-instructions/context.md を更新し make ai-instructions で反映 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013WFgMWTQ6QAWShTRLNRUQx --- .cursor/rules/context.mdc | 3 ++- .github/instructions/ai-context.instructions.md | 3 ++- AGENTS.md | 3 ++- docs/ai-instructions/context.md | 3 ++- scripts/hooks/irreversible-ops-guard.sh | 5 ++++- scripts/hooks/irreversible-ops-guard.test.sh | 6 ++++++ 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.cursor/rules/context.mdc b/.cursor/rules/context.mdc index 6cb1c2ef..55a1fb50 100644 --- a/.cursor/rules/context.mdc +++ b/.cursor/rules/context.mdc @@ -86,7 +86,8 @@ pnpm test # Component tests ### Irreversible Operations Are Blocked by a Hook `scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks -`npm publish`, `gh pr merge`, `gh release create/delete`, publish workflows, tag pushes, and force pushes. +`npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, +publish workflows, tag pushes, and force pushes. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. - Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. diff --git a/.github/instructions/ai-context.instructions.md b/.github/instructions/ai-context.instructions.md index 6d94819c..4933af0a 100644 --- a/.github/instructions/ai-context.instructions.md +++ b/.github/instructions/ai-context.instructions.md @@ -84,7 +84,8 @@ pnpm test # Component tests ### Irreversible Operations Are Blocked by a Hook `scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks -`npm publish`, `gh pr merge`, `gh release create/delete`, publish workflows, tag pushes, and force pushes. +`npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, +publish workflows, tag pushes, and force pushes. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. - Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. diff --git a/AGENTS.md b/AGENTS.md index d5c4887f..083c99ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,8 @@ pnpm test # Component tests ### Irreversible Operations Are Blocked by a Hook `scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks -`npm publish`, `gh pr merge`, `gh release create/delete`, publish workflows, tag pushes, and force pushes. +`npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, +publish workflows, tag pushes, and force pushes. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. - Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. diff --git a/docs/ai-instructions/context.md b/docs/ai-instructions/context.md index a376f1fd..edc52ae0 100644 --- a/docs/ai-instructions/context.md +++ b/docs/ai-instructions/context.md @@ -80,7 +80,8 @@ pnpm test # Component tests ### Irreversible Operations Are Blocked by a Hook `scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks -`npm publish`, `gh pr merge`, `gh release create/delete`, publish workflows, tag pushes, and force pushes. +`npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, +publish workflows, tag pushes, and force pushes. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. - Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. diff --git a/scripts/hooks/irreversible-ops-guard.sh b/scripts/hooks/irreversible-ops-guard.sh index db8c29ac..17cc836d 100755 --- a/scripts/hooks/irreversible-ops-guard.sh +++ b/scripts/hooks/irreversible-ops-guard.sh @@ -23,7 +23,7 @@ input="$(cat)" # ワークフロー名は `"Publish to npm"` のように大文字を含むため大小文字を無視する。 shopt -s nocasematch case "$input" in - *publish* | *merge* | *push* | *release* | *deprecate*) ;; + *publish* | *merge* | *push* | *release* | *deprecate* | *"gh repo"*) ;; *) shopt -u nocasematch exit 0 @@ -128,6 +128,9 @@ while IFS= read -r segment; do "pr merge") op="gh pr merge (PR のマージ)" ;; "release create") op="gh release create (GitHub Release の作成)" ;; "release delete") op="gh release delete (GitHub Release の削除)" ;; + "repo create") op="gh repo create (GitHub リポジトリの作成)" ;; + "repo delete") op="gh repo delete (GitHub リポジトリの削除)" ;; + "repo archive") op="gh repo archive (GitHub リポジトリのアーカイブ)" ;; "workflow run") # publish 系ワークフローだけを対象にする (CI の再実行などは素通し) shopt -s nocasematch diff --git a/scripts/hooks/irreversible-ops-guard.test.sh b/scripts/hooks/irreversible-ops-guard.test.sh index 8b961a0e..4a3282d0 100755 --- a/scripts/hooks/irreversible-ops-guard.test.sh +++ b/scripts/hooks/irreversible-ops-guard.test.sh @@ -45,6 +45,9 @@ run_case block 'gh pr merge --squash 12' run_case block 'gh -R goodpatch/sparkle-design pr merge 297 --merge' run_case block 'gh release create v1.0.8 --notes-file /tmp/notes.md' run_case block 'gh release delete v1.0.8' +run_case block 'gh repo create goodpatch/acme-design --private --clone' +run_case block 'gh repo delete goodpatch/acme-design' +run_case block 'gh repo archive goodpatch/acme-design' run_case block 'gh workflow run "Publish to npm" --ref v1.0.8' run_case block 'gh workflow run "Publish Skills"' run_case block 'git push origin v1.0.8' @@ -69,6 +72,9 @@ run_case pass 'gh pr create --draft --title "リリース v1.0.8"' run_case pass 'gh pr view 297 --json state' run_case pass 'gh pr checks 297' run_case pass 'gh release list --limit 10' +run_case pass 'gh repo view goodpatch/sparkle-design --json permissions' +run_case pass 'gh repo clone goodpatch/prj-template /tmp/x -- --depth 1' +run_case pass 'gh api repos/goodpatch/prj-template --jq .permissions' run_case pass 'gh release view v1.0.8' run_case pass 'gh workflow run "CI" --ref main' run_case pass 'gh run list --branch main --limit 5' From ed7071ef7e90b6f1bf19a0cc0d6f70d7319a8a19 Mon Sep 17 00:00:00 2001 From: touyou <465697+touyou@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:26:27 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=94=92=20chore:=20hook=20=E3=81=AE?= =?UTF-8?q?=E3=83=98=E3=83=83=E3=83=80=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=82=92=E3=83=AA=E3=83=9D=E3=82=B8=E3=83=88=E3=83=AA=E9=9D=9E?= =?UTF-8?q?=E4=BE=9D=E5=AD=98=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit このスクリプトは sparkle-design / sparkle-design-internal / sparkle-guide の 3 リポジトリで同一内容を保つ運用にする。コピー時に差分が出ないよう、 特定リポジトリにしか無いスキル名への参照をやめて汎用表現にした。 同一に保つ旨と更新手順もコメントに明記する。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013WFgMWTQ6QAWShTRLNRUQx --- scripts/hooks/irreversible-ops-guard.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/hooks/irreversible-ops-guard.sh b/scripts/hooks/irreversible-ops-guard.sh index 17cc836d..a3ecf24f 100755 --- a/scripts/hooks/irreversible-ops-guard.sh +++ b/scripts/hooks/irreversible-ops-guard.sh @@ -4,9 +4,12 @@ # force push など) を検知したら、実行前にブロックする (exit 2)。 # # 目的は「AI が手順リストを最後まで走り切って、人が見ないまま公開してしまう」 -# のを止めること。スキル本文 (.claude/skills/release-sparkle-design/SKILL.md) にも -# 同じ趣旨のゲートを書いてあるが、散文の指示はスキルを読んでいないセッションには -# 効かない。このフックはスキルの読み込み有無に関係なく効く。 +# のを止めること。リリース手順のスキルにも同じ趣旨のゲートを書いてあるが、 +# 散文の指示はそのスキルを読んでいないセッションには効かない。 +# このフックはスキルの読み込み有無に関係なく効く。 +# +# このファイルは Sparkle のリポジトリ間で同一内容を保つ (差分を作らない)。 +# 更新するときは sparkle-design 側を先に直し、他リポジトリへコピーする。 # # ブロックを解除するには、ユーザーから明示的な指示を得たうえで、 # コマンドの先頭に SPARKLE_CONFIRM=1 を付けて実行する: From 112c382bb2e5bc4389ce9c71b2dca3cba12391df Mon Sep 17 00:00:00 2001 From: touyou <465697+touyou@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:33:12 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=E5=80=A4=E3=82=92?= =?UTF-8?q?=E5=8F=96=E3=82=8B=E3=82=AA=E3=83=97=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E3=82=92=E6=8C=9F=E3=82=80=E3=81=A8=20hook=20=E3=81=AE?= =?UTF-8?q?=E6=A4=9C=E7=9F=A5=E3=82=92=E3=81=99=E3=82=8A=E6=8A=9C=E3=81=91?= =?UTF-8?q?=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit セルフレビューで発見。`pnpm -F publish` のように、サブコマンドの前に 「値を取るオプション」を挟むと、値の方をサブコマンドと誤認して素通ししていた。 修正前: pnpm -F @goodpatch/sparkle-design publish → exit 0(すり抜け) npm -w packages/ui publish → exit 0(すり抜け) - 値を取るオプション(-w / --workspace / -F / --filter / --dir / --prefix / --registry / --hostname)を値ごと読み飛ばすように修正 - 回帰テストを追加(54 → 63 ケース)。`pnpm -F ui run publish-docs` などが 引き続き素通しされることも確認 - 未知の値付きオプションではすり抜けうる限界をヘッダコメントに明記 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013WFgMWTQ6QAWShTRLNRUQx --- scripts/hooks/irreversible-ops-guard.sh | 15 +++++++++++++-- scripts/hooks/irreversible-ops-guard.test.sh | 10 ++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/hooks/irreversible-ops-guard.sh b/scripts/hooks/irreversible-ops-guard.sh index a3ecf24f..242b49b7 100755 --- a/scripts/hooks/irreversible-ops-guard.sh +++ b/scripts/hooks/irreversible-ops-guard.sh @@ -17,6 +17,11 @@ # # SPARKLE_CONFIRM=1 は「ユーザーがこの操作を名指しで指示した」ことの表明であり、 # AI が自分の判断で付け足してよいものではない。 +# +# 限界: これはサンドボックスではなく「うっかり最後まで走り切る」のを止める仕掛け。 +# 下の「値を取るオプション」に載っていない未知のオプションをサブコマンドの前に +# 挟まれると検知をすり抜ける。想定する相手は敵対者ではなく、手順どおりに動く +# エージェント自身なので、実在するコマンド形をカバーすることを優先している。 set -uo pipefail input="$(cat)" @@ -94,13 +99,19 @@ while IFS= read -r segment; do cmd="$1" shift - # `git -C push` のように、サブコマンドの前に来るオプションを読み飛ばす + # `git -C push` / `pnpm -F publish` のように、サブコマンドの前に来る + # オプションを読み飛ばす。**値を取るオプションは値ごと**読み飛ばさないと、 + # 値の方をサブコマンドと誤認して検知をすり抜ける (pnpm -F pkg publish など)。 while [ "$#" -gt 0 ]; do case "$1" in - -C | -c | --git-dir | --work-tree | -R | --repo) + # 値を取るオプション (git / gh / パッケージマネージャ) + -C | -c | --git-dir | --work-tree | --namespace | --exec-path | \ + -R | --repo | --hostname | \ + -w | --workspace | -F | --filter | --dir | --prefix | --registry) shift [ "$#" -gt 0 ] && shift ;; + # `--filter=ui` のように = で値を含む形と、値を取らないオプション -*) shift ;; *) break ;; esac diff --git a/scripts/hooks/irreversible-ops-guard.test.sh b/scripts/hooks/irreversible-ops-guard.test.sh index 4a3282d0..12ee0a43 100755 --- a/scripts/hooks/irreversible-ops-guard.test.sh +++ b/scripts/hooks/irreversible-ops-guard.test.sh @@ -38,6 +38,13 @@ echo "== ブロックされるべきコマンド ==" run_case block 'npm publish' run_case block 'npm publish --access public' run_case block 'pnpm publish --no-git-checks' +# 値を取るオプションを挟んでも検知できること (値をサブコマンドと誤認しない) +run_case block 'pnpm -F @goodpatch/sparkle-design publish' +run_case block 'pnpm --filter ui publish' +run_case block 'pnpm --filter=ui publish' +run_case block 'npm -w packages/ui publish' +run_case block 'npm --workspace pkg publish' +run_case block 'npm --silent publish' run_case block 'npm unpublish sparkle-design@1.0.8' run_case block 'npm deprecate sparkle-design@1.0.8 "use 1.0.9"' run_case block 'gh pr merge 297 --merge --admin' @@ -66,6 +73,9 @@ echo "== 素通しされるべきコマンド (誤検知の検出) ==" run_case pass 'npm publish --dry-run' run_case pass 'pnpm publish --dry-run --no-git-checks' run_case pass 'npm run publish-docs' +run_case pass 'pnpm -F ui run publish-docs' +run_case pass 'npm -w packages/ui run build' +run_case pass 'pnpm --filter ui test' run_case pass 'npm run build' run_case pass 'pnpm test' run_case pass 'gh pr create --draft --title "リリース v1.0.8"' From 81791b34bc34d1a50d117b7235484790305b7286 Mon Sep 17 00:00:00 2001 From: touyou <465697+touyou@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:50:17 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=94=92=20fix:=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE=20bypass=20?= =?UTF-8?q?=E3=82=92=E5=A1=9E=E3=81=8E=E3=80=81=E5=88=A4=E5=AE=9A=E3=82=92?= =?UTF-8?q?=20Node=20=E3=83=91=E3=83=BC=E3=82=B5=E3=81=AB=E7=BD=AE?= =?UTF-8?q?=E3=81=8D=E6=8F=9B=E3=81=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex / Copilot のレビューで見つかった P1 の bypass を修正する。bash の 文字列分割では引用符とコマンド置換を正しく扱えないため、判定本体を Node(irreversible-ops-guard.mjs)に移した。.sh は node を呼ぶだけの エントリポイントになり、jq 依存も解消した。 塞いだ bypass: - `npm publish --dry-run=false` — 値付きの --dry-run を一律で免除していた。 有効な指定(--dry-run / =true / =1)だけを素通しする - `result=$(npm publish)` — コマンド置換の中身を見ていなかった。 ネストした置換・ダブルクォート内の置換も追う - `git push origin +main:main` — refspec 先頭の + は force と同義 - `env -i npm publish` / `env -u X npm publish` — env のオプションを 剥がしていなかった - `bash -c "npm publish"` — シェル経由の中身を再帰的に判定する(深さ上限あり) 誤検知の修正: - `git commit -m "docs: x; npm publish requires approval"` のように 引用符の中に区切り文字がある場合に誤分割していた その他: - node が無い環境では素通し(exit 0)。ガードのために開発を止めない - settings.json は bash 経由の呼び出しに変更(実行ビットに依存しない) - テストを 63 → 82 ケースに拡張。上記すべてに回帰テストを追加 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013WFgMWTQ6QAWShTRLNRUQx --- .claude/settings.json | 2 +- .cursor/rules/context.mdc | 3 +- .../instructions/ai-context.instructions.md | 3 +- AGENTS.md | 3 +- docs/ai-instructions/context.md | 3 +- scripts/hooks/irreversible-ops-guard.mjs | 359 ++++++++++++++++++ scripts/hooks/irreversible-ops-guard.sh | 227 +---------- scripts/hooks/irreversible-ops-guard.test.sh | 26 ++ 8 files changed, 403 insertions(+), 223 deletions(-) create mode 100755 scripts/hooks/irreversible-ops-guard.mjs diff --git a/.claude/settings.json b/.claude/settings.json index 23d1526f..5dcdf39d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/hooks/irreversible-ops-guard.sh", + "command": "bash \"$CLAUDE_PROJECT_DIR/scripts/hooks/irreversible-ops-guard.sh\"", "timeout": 15, "statusMessage": "Checking for irreversible operations..." } diff --git a/.cursor/rules/context.mdc b/.cursor/rules/context.mdc index 55a1fb50..100f26a7 100644 --- a/.cursor/rules/context.mdc +++ b/.cursor/rules/context.mdc @@ -87,7 +87,8 @@ pnpm test # Component tests ### Irreversible Operations Are Blocked by a Hook `scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks `npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, -publish workflows, tag pushes, and force pushes. +publish workflows, tag pushes, and force pushes. It also looks inside command substitutions +(`$(...)`) and `bash -c "..."`, so those are not a way around it. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. - Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. diff --git a/.github/instructions/ai-context.instructions.md b/.github/instructions/ai-context.instructions.md index 4933af0a..28266c9f 100644 --- a/.github/instructions/ai-context.instructions.md +++ b/.github/instructions/ai-context.instructions.md @@ -85,7 +85,8 @@ pnpm test # Component tests ### Irreversible Operations Are Blocked by a Hook `scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks `npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, -publish workflows, tag pushes, and force pushes. +publish workflows, tag pushes, and force pushes. It also looks inside command substitutions +(`$(...)`) and `bash -c "..."`, so those are not a way around it. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. - Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. diff --git a/AGENTS.md b/AGENTS.md index 083c99ef..d2079d24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,8 @@ pnpm test # Component tests ### Irreversible Operations Are Blocked by a Hook `scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks `npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, -publish workflows, tag pushes, and force pushes. +publish workflows, tag pushes, and force pushes. It also looks inside command substitutions +(`$(...)`) and `bash -c "..."`, so those are not a way around it. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. - Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. diff --git a/docs/ai-instructions/context.md b/docs/ai-instructions/context.md index edc52ae0..870c439b 100644 --- a/docs/ai-instructions/context.md +++ b/docs/ai-instructions/context.md @@ -81,7 +81,8 @@ pnpm test # Component tests ### Irreversible Operations Are Blocked by a Hook `scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks `npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, -publish workflows, tag pushes, and force pushes. +publish workflows, tag pushes, and force pushes. It also looks inside command substitutions +(`$(...)`) and `bash -c "..."`, so those are not a way around it. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. - Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. diff --git a/scripts/hooks/irreversible-ops-guard.mjs b/scripts/hooks/irreversible-ops-guard.mjs new file mode 100755 index 00000000..bc7c8a1a --- /dev/null +++ b/scripts/hooks/irreversible-ops-guard.mjs @@ -0,0 +1,359 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook の本体。 +// 取り消せない操作 (npm publish / PR マージ / タグ push / GitHub Release 作成 / +// リポジトリ作成 / force push など) を検知したら、実行前にブロックする (exit 2)。 +// +// 目的は「AI が手順リストを最後まで走り切って、人が見ないまま公開してしまう」 +// のを止めること。リリース手順のスキルにも同じ趣旨のゲートを書いてあるが、 +// 散文の指示はそのスキルを読んでいないセッションには効かない。 +// このフックはスキルの読み込み有無に関係なく効く。 +// +// このファイルは Sparkle のリポジトリ間で同一内容を保つ (差分を作らない)。 +// 更新するときは sparkle-design 側を先に直し、他リポジトリへコピーする。 +// +// ブロックを解除するには、ユーザーから明示的な指示を得たうえで、 +// コマンドの先頭に SPARKLE_CONFIRM=1 を付けて実行する: +// SPARKLE_CONFIRM=1 gh workflow run "Publish to npm" --ref v1.2.3 +// +// SPARKLE_CONFIRM=1 は「ユーザーがこの操作を名指しで指示した」ことの表明であり、 +// AI が自分の判断で付け足してよいものではない。 +// +// シェルではなく Node で書いてあるのは、引用符とコマンド置換を正しく扱うため。 +// bash の文字列分割だと `git commit -m "...; npm publish ..."` を誤検知したり、 +// 逆に `result=$(npm publish)` を取りこぼしたりする。 + +import { pathToFileURL } from "node:url"; + +/** 値を取るオプション。値ごと読み飛ばさないと値をサブコマンドと誤認する。 */ +const OPTIONS_WITH_VALUE = new Set([ + // git + "-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", + // gh + "-R", "--repo", "--hostname", + // パッケージマネージャ + "-w", "--workspace", "-F", "--filter", "--dir", "--prefix", "--registry", +]); + +/** `env` 自身のオプションのうち値を取るもの。 */ +const ENV_OPTIONS_WITH_VALUE = new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]); + +/** + * コマンド文字列を「実際に実行される単位」へ割る。 + * 引用符の外にある `;` `&` `|` `&&` `||` 改行で分割し、 + * コマンド置換 `$( )` / バッククォートの中身も独立した単位として取り出す。 + */ +export function splitSegments(command) { + const segments = []; + const substitutionStack = []; // コマンド置換に入る直前の引用状態を積む + let current = ""; + let quote = null; + + const flush = () => { + if (current.trim() !== "") segments.push(current); + current = ""; + }; + + for (let i = 0; i < command.length; i++) { + const c = command[i]; + + // シングルクォート内は展開されないので完全にリテラル。 + // 引用符そのものは落とさずに残す (単語のグルーピングは tokenize 側で解く)。 + if (quote === "'") { + if (c === "'") quote = null; + current += c; + continue; + } + + if (c === "\\") { + current += command[++i] ?? ""; + continue; + } + + // コマンド置換はダブルクォートの中でも実行される。 + // 中に入る間は引用状態を退避し、`)` で元に戻す。 + if (c === "$" && command[i + 1] === "(") { + flush(); + substitutionStack.push(quote); + quote = null; + i++; + continue; + } + if (c === ")") { + if (substitutionStack.length > 0) { + flush(); + quote = substitutionStack.pop(); + continue; + } + if (quote === null) { + flush(); // サブシェルの終わり + continue; + } + current += c; + continue; + } + // バッククォートはネストしないので、開始・終了とも区切りとして扱えば足りる + if (c === "`") { + flush(); + continue; + } + + if (quote === '"') { + if (c === '"') quote = null; + current += c; + continue; + } + + if (c === "'" || c === '"') { + quote = c; + current += c; + continue; + } + if (c === "(" || c === "\n") { + flush(); + continue; + } + if (c === ";" || c === "&" || c === "|") { + if ((c === "&" && command[i + 1] === "&") || (c === "|" && command[i + 1] === "|")) i++; + flush(); + continue; + } + current += c; + } + flush(); + return segments; +} + +/** セグメントを単語へ割る。引用符は取り除く (`"Publish to npm"` → `Publish to npm`)。 */ +export function tokenize(segment) { + const tokens = []; + let current = ""; + let quoted = false; + let quote = null; + + const flush = () => { + if (current !== "" || quoted) tokens.push(current); + current = ""; + quoted = false; + }; + + for (let i = 0; i < segment.length; i++) { + const c = segment[i]; + if (quote) { + if (c === quote) { + quote = null; + continue; + } + if (c === "\\" && quote === '"') { + current += segment[++i] ?? ""; + continue; + } + current += c; + continue; + } + if (c === "'" || c === '"') { + quote = c; + quoted = true; + continue; + } + if (c === "\\") { + current += segment[++i] ?? ""; + continue; + } + if (/\s/.test(c)) { + flush(); + continue; + } + current += c; + } + flush(); + return tokens; +} + +/** `--dry-run` が「有効」として指定されているか。`--dry-run=false` は publish するので無効扱い。 */ +function hasEnabledDryRun(tokens) { + return tokens.some( + (t) => t === "--dry-run" || t === "--dry-run=true" || t === "--dry-run=1" + ); +} + +/** + * 1 セグメントを判定する。 + * @returns {{op: string} | {confirmed: true} | null} + */ +export function inspectSegment(segment) { + const tokens = tokenize(segment); + let index = 0; + let confirmed = false; + + // 先頭の env / インラインの VAR=value 代入を剥がす + for (; index < tokens.length; ) { + const token = tokens[index]; + if (token === "env") { + index++; + while (index < tokens.length && tokens[index].startsWith("-")) { + const option = tokens[index]; + index++; + if (ENV_OPTIONS_WITH_VALUE.has(option) && !option.includes("=")) index++; + } + continue; + } + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { + if (token === "SPARKLE_CONFIRM=1") confirmed = true; + index++; + continue; + } + break; + } + + if (confirmed) return { confirmed: true }; + + const cmd = tokens[index]; + if (!cmd) return null; + index++; + + // `bash -c "npm publish"` のように、シェル経由で実行される中身も判定する。 + // 下のオプション読み飛ばしより先に見ないと、`-c` が値ごと捨てられてしまう。 + if (["bash", "sh", "zsh", "dash", "ksh"].includes(cmd)) { + const args = tokens.slice(index); + const cIndex = args.indexOf("-c"); + const script = cIndex >= 0 ? args[cIndex + 1] : undefined; + if (script) return { nested: script }; + return null; + } + + // サブコマンドの前に来るオプションを読み飛ばす (値を取るものは値ごと) + while (index < tokens.length) { + const token = tokens[index]; + if (!token.startsWith("-")) break; + index++; + if (OPTIONS_WITH_VALUE.has(token)) index++; + } + + const rest = tokens.slice(index); + const sub = rest[0] ?? ""; + const sub2 = rest[1] ?? ""; + + + if (["npm", "pnpm", "yarn", "bun"].includes(cmd)) { + if (sub === "publish") { + if (hasEnabledDryRun(tokens)) return null; + return { op: `${cmd} publish (パッケージの公開)` }; + } + if (sub === "unpublish") return { op: `${cmd} unpublish (公開済みバージョンの削除)` }; + if (sub === "deprecate") return { op: `${cmd} deprecate (公開済みバージョンの非推奨化)` }; + return null; + } + + if (cmd === "gh") { + const pair = `${sub} ${sub2}`; + if (pair === "pr merge") return { op: "gh pr merge (PR のマージ)" }; + if (pair === "release create") return { op: "gh release create (GitHub Release の作成)" }; + if (pair === "release delete") return { op: "gh release delete (GitHub Release の削除)" }; + if (pair === "repo create") return { op: "gh repo create (GitHub リポジトリの作成)" }; + if (pair === "repo delete") return { op: "gh repo delete (GitHub リポジトリの削除)" }; + if (pair === "repo archive") return { op: "gh repo archive (GitHub リポジトリのアーカイブ)" }; + if (pair === "workflow run") { + // publish 系ワークフローだけを対象にする (CI の再実行などは素通し) + if (rest.some((t) => /publish/i.test(t))) { + return { op: "gh workflow run (publish ワークフローの実行)" }; + } + } + return null; + } + + if (cmd === "git" && sub === "push") { + let remoteSeen = false; + for (const token of rest.slice(1)) { + if (token === "--force" || token === "-f" || token.startsWith("--force-with-lease")) { + return { op: "git push --force 系 (リモート履歴の上書き)" }; + } + if (token === "--delete" || token === "-d") { + return { op: "git push --delete (リモート ref の削除)" }; + } + if (token === "--tags" || token === "--follow-tags") { + return { op: "git push --tags (タグの push)" }; + } + if (token.startsWith("-")) continue; + if (!remoteSeen) { + remoteSeen = true; + continue; + } + // refspec 先頭の `+` は「拒否される更新も強制する」= force と同じ + if (token.startsWith("+")) { + return { op: "git push + (強制更新の refspec)" }; + } + if (token.startsWith(":")) { + return { op: "git push : (リモート ref の削除)" }; + } + const dst = token.includes(":") ? token.slice(token.lastIndexOf(":") + 1) : token; + if (/^(refs\/tags\/)?v\d+\.\d+\.\d+/.test(dst)) { + return { op: "git push (リリースタグの push)" }; + } + } + } + + return null; +} + +/** @returns {{op: string, segment: string} | null} */ +export function inspectCommand(command, env = process.env, depth = 0) { + if (env.SPARKLE_CONFIRM === "1") return null; + if (depth > 3) return null; // `bash -c` の入れ子が病的に深い入力への保険 + for (const segment of splitSegments(command)) { + const result = inspectSegment(segment); + if (!result) continue; + if ("confirmed" in result) continue; + if ("nested" in result) { + const nested = inspectCommand(result.nested, env, depth + 1); + if (nested) return nested; + continue; + } + return { op: result.op, segment: segment.trim() }; + } + return null; +} + +function main() { + let raw = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => (raw += chunk)); + process.stdin.on("end", () => { + let command = ""; + try { + command = JSON.parse(raw)?.tool_input?.command ?? ""; + } catch { + // 解釈できない入力では素通しする (フックが開発を止めない方に倒す) + process.exit(0); + } + if (!command) process.exit(0); + + const hit = inspectCommand(command); + if (!hit) process.exit(0); + + process.stderr.write( + [ + "取り消せない操作を検知したため、実行前にブロックしました。", + "", + ` 検知した操作: ${hit.op}`, + ` コマンド : ${hit.segment}`, + "", + "この操作はユーザーが名指しで指示したときだけ実行してよい。", + "「リリースして」「進めて」といった包括的な依頼は、この操作の承認ではない。", + "", + "次にやること:", + " 1. 実行しようとした操作と、その影響 (何が公開・変更されるか) をユーザーに報告する", + " 2. ユーザーから明示的な指示を得る", + " 3. 指示を得たら、コマンドの先頭に SPARKLE_CONFIRM=1 を付けて再実行する", + ` 例: SPARKLE_CONFIRM=1 ${hit.segment}`, + "", + "SPARKLE_CONFIRM=1 は「ユーザーが指示した」ことの表明。指示が無いまま付けてはならない。", + "", + ].join("\n") + ); + process.exit(2); + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/hooks/irreversible-ops-guard.sh b/scripts/hooks/irreversible-ops-guard.sh index 242b49b7..44805c36 100755 --- a/scripts/hooks/irreversible-ops-guard.sh +++ b/scripts/hooks/irreversible-ops-guard.sh @@ -1,226 +1,17 @@ #!/usr/bin/env bash -# Claude Code PreToolUse hook: -# 取り消せない操作 (npm publish / PR マージ / タグ push / GitHub Release 作成 / -# force push など) を検知したら、実行前にブロックする (exit 2)。 +# Claude Code PreToolUse hook のエントリポイント。 +# 判定本体は irreversible-ops-guard.mjs (引用符とコマンド置換を正しく扱うため Node で書いてある)。 # -# 目的は「AI が手順リストを最後まで走り切って、人が見ないまま公開してしまう」 -# のを止めること。リリース手順のスキルにも同じ趣旨のゲートを書いてあるが、 -# 散文の指示はそのスキルを読んでいないセッションには効かない。 -# このフックはスキルの読み込み有無に関係なく効く。 +# node が無い環境では素通しする (exit 0)。ガードが効かなくなるが、 +# 安全のための hook が開発そのものを止めてしまう方が害が大きいため。 # # このファイルは Sparkle のリポジトリ間で同一内容を保つ (差分を作らない)。 # 更新するときは sparkle-design 側を先に直し、他リポジトリへコピーする。 -# -# ブロックを解除するには、ユーザーから明示的な指示を得たうえで、 -# コマンドの先頭に SPARKLE_CONFIRM=1 を付けて実行する: -# SPARKLE_CONFIRM=1 gh workflow run "Publish to npm" --ref v1.2.3 -# -# SPARKLE_CONFIRM=1 は「ユーザーがこの操作を名指しで指示した」ことの表明であり、 -# AI が自分の判断で付け足してよいものではない。 -# -# 限界: これはサンドボックスではなく「うっかり最後まで走り切る」のを止める仕掛け。 -# 下の「値を取るオプション」に載っていない未知のオプションをサブコマンドの前に -# 挟まれると検知をすり抜ける。想定する相手は敵対者ではなく、手順どおりに動く -# エージェント自身なので、実在するコマンド形をカバーすることを優先している。 set -uo pipefail -input="$(cat)" - -# 全 Bash 呼び出しで起動されるため、jq を起動する前に生 JSON で安価に足切りする。 -# ここに挙げたキーワードを含まないコマンドは以降の判定対象になりえない。 -# ワークフロー名は `"Publish to npm"` のように大文字を含むため大小文字を無視する。 -shopt -s nocasematch -case "$input" in - *publish* | *merge* | *push* | *release* | *deprecate* | *"gh repo"*) ;; - *) - shopt -u nocasematch - exit 0 - ;; -esac -shopt -u nocasematch - -command="$(jq -r '.tool_input.command // ""' <<<"$input")" -[ -n "$command" ] || exit 0 - -# ---- コマンドをセグメントに割る ---- -# `;` `&` `|` `&&` `||` と改行で分割し、各セグメントの「先頭に来るコマンド」だけを -# 判定対象にする。こうするとコミットメッセージ等に "npm publish" の文字列が -# 含まれるだけのケースを弾ける (先頭が git commit になるため)。 -segments="$(printf '%s' "$command" | sed -E 's/(\|\||&&|[;&|])/\n/g')" - -blocked_op="" -blocked_segment="" - -while IFS= read -r segment; do - [ -n "$segment" ] || continue - - # 先頭の空白 / env / インラインの VAR=value 代入を剥がして、実際のコマンド名を露出させる。 - # 剥がした代入の中に SPARKLE_CONFIRM=1 があればそのセグメントは承認済みとみなす。 - confirmed=0 - seg="${segment#"${segment%%[![:space:]]*}"}" - while true; do - case "$seg" in - env[[:space:]]*) - seg="${seg#env}" - seg="${seg#"${seg%%[![:space:]]*}"}" - ;; - SPARKLE_CONFIRM=1[[:space:]]*) - confirmed=1 - seg="${seg#SPARKLE_CONFIRM=1}" - seg="${seg#"${seg%%[![:space:]]*}"}" - ;; - [A-Za-z_]*=*) - # 変数代入は「=」より前に空白を含まない。それ以外は通常のコマンドとして扱う - head="${seg%%[[:space:]]*}" - case "$head" in - *=*) - seg="${seg#"$head"}" - seg="${seg#"${seg%%[![:space:]]*}"}" - ;; - *) break ;; - esac - ;; - *) break ;; - esac - done - - [ -n "$seg" ] || continue - [ "${SPARKLE_CONFIRM:-0}" = "1" ] && confirmed=1 - [ "$confirmed" -eq 1 ] && continue - - # ---- 単語に分割して「コマンド名 + サブコマンド」で判定する ---- - # 部分一致 (*publish*) だと `npm run publish-docs` や - # `git commit -m "push 前に直す"` まで拾ってしまうため、位置を見て判定する。 - set -f # 展開時の glob を無効化 - # shellcheck disable=SC2086 - set -- $seg - set +f - [ "$#" -gt 0 ] || continue - - cmd="$1" - shift - # `git -C push` / `pnpm -F publish` のように、サブコマンドの前に来る - # オプションを読み飛ばす。**値を取るオプションは値ごと**読み飛ばさないと、 - # 値の方をサブコマンドと誤認して検知をすり抜ける (pnpm -F pkg publish など)。 - while [ "$#" -gt 0 ]; do - case "$1" in - # 値を取るオプション (git / gh / パッケージマネージャ) - -C | -c | --git-dir | --work-tree | --namespace | --exec-path | \ - -R | --repo | --hostname | \ - -w | --workspace | -F | --filter | --dir | --prefix | --registry) - shift - [ "$#" -gt 0 ] && shift - ;; - # `--filter=ui` のように = で値を含む形と、値を取らないオプション - -*) shift ;; - *) break ;; - esac - done - sub="${1:-}" - sub2="${2:-}" - - op="" - case "$cmd" in - # --- パッケージの公開系 --- - npm | pnpm | yarn | bun) - case "$sub" in - publish) - # --dry-run は公開しないので素通しする - case " $seg " in - *" --dry-run "* | *" --dry-run="*) ;; - *) op="$cmd publish (パッケージの公開)" ;; - esac - ;; - unpublish) op="$cmd unpublish (公開済みバージョンの削除)" ;; - deprecate) op="$cmd deprecate (公開済みバージョンの非推奨化)" ;; - esac - ;; - - # --- GitHub 側の不可逆操作 --- - gh) - case "$sub $sub2" in - "pr merge") op="gh pr merge (PR のマージ)" ;; - "release create") op="gh release create (GitHub Release の作成)" ;; - "release delete") op="gh release delete (GitHub Release の削除)" ;; - "repo create") op="gh repo create (GitHub リポジトリの作成)" ;; - "repo delete") op="gh repo delete (GitHub リポジトリの削除)" ;; - "repo archive") op="gh repo archive (GitHub リポジトリのアーカイブ)" ;; - "workflow run") - # publish 系ワークフローだけを対象にする (CI の再実行などは素通し) - shopt -s nocasematch - [[ "$seg" == *publish* ]] && op="gh workflow run (publish ワークフローの実行)" - shopt -u nocasematch - ;; - esac - ;; - - # --- git の巻き戻しにくい操作 --- - git) - if [ "$sub" = "push" ]; then - shift # サブコマンド (push) を除いた引数を走査する - remote_seen=0 - for tok in "$@"; do - case "$tok" in - --force | -f | --force-with-lease | --force-with-lease=*) - op="git push --force 系 (リモート履歴の上書き)" - break - ;; - --delete | -d) - op="git push --delete (リモート ref の削除)" - break - ;; - --tags | --follow-tags) - op="git push --tags (タグの push)" - break - ;; - -*) ;; - *) - if [ "$remote_seen" -eq 0 ]; then - remote_seen=1 - else - # refspec の dst 側を見る。タグ (vX.Y.Z) の push だけを対象にする - dst="${tok##*:}" - if [[ "$dst" =~ ^(refs/tags/)?v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - op="git push (リリースタグの push)" - break - fi - # "git push origin :refs/heads/foo" 形式の削除 - case "$tok" in - :*) op="git push : (リモート ref の削除)" && break ;; - esac - fi - ;; - esac - done - fi - ;; - esac - - if [ -n "$op" ]; then - blocked_op="$op" - blocked_segment="$seg" - break - fi -done <<<"$segments" - -[ -n "$blocked_op" ] || exit 0 - -{ - echo "取り消せない操作を検知したため、実行前にブロックしました。" - echo "" - echo " 検知した操作: $blocked_op" - echo " コマンド : $blocked_segment" - echo "" - echo "この操作はユーザーが名指しで指示したときだけ実行してよい。" - echo "「リリースして」「進めて」といった包括的な依頼は、この操作の承認ではない。" - echo "" - echo "次にやること:" - echo " 1. 実行しようとした操作と、その影響 (何が公開・変更されるか) をユーザーに報告する" - echo " 2. ユーザーから明示的な指示を得る" - echo " 3. 指示を得たら、コマンドの先頭に SPARKLE_CONFIRM=1 を付けて再実行する" - echo " 例: SPARKLE_CONFIRM=1 $blocked_segment" - echo "" - echo "SPARKLE_CONFIRM=1 は「ユーザーが指示した」ことの表明。指示が無いまま付けてはならない。" -} >&2 +if ! command -v node >/dev/null 2>&1; then + exit 0 +fi -exit 2 +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$script_dir/irreversible-ops-guard.mjs" diff --git a/scripts/hooks/irreversible-ops-guard.test.sh b/scripts/hooks/irreversible-ops-guard.test.sh index 12ee0a43..c64378fb 100755 --- a/scripts/hooks/irreversible-ops-guard.test.sh +++ b/scripts/hooks/irreversible-ops-guard.test.sh @@ -45,6 +45,22 @@ run_case block 'pnpm --filter=ui publish' run_case block 'npm -w packages/ui publish' run_case block 'npm --workspace pkg publish' run_case block 'npm --silent publish' +# --dry-run=false は実際に publish されるので素通ししない (Codex レビュー指摘) +run_case block 'npm publish --dry-run=false' +run_case block 'pnpm publish --dry-run=0' +# コマンド置換の中の実行を取りこぼさない (Codex レビュー指摘) +run_case block 'result=$(npm publish)' +run_case block 'URL=$(gh release create v1.0.8 --notes x)' +run_case block 'echo "published to $(npm publish)"' +# env のオプション付き呼び出しでバイパスできない (Copilot レビュー指摘) +run_case block 'env -i npm publish' +run_case block 'env -u SPARKLE_CONFIRM npm publish' +# シェル経由の実行も中身を見る +run_case block 'bash -c "npm publish"' +run_case block 'sh -c "gh pr merge 297 --merge"' +# refspec 先頭の + は force と同じ (Codex レビュー指摘) +run_case block 'git push origin +main:main' +run_case block 'git push origin +refs/heads/main:refs/heads/main' run_case block 'npm unpublish sparkle-design@1.0.8' run_case block 'npm deprecate sparkle-design@1.0.8 "use 1.0.9"' run_case block 'gh pr merge 297 --merge --admin' @@ -93,6 +109,16 @@ run_case pass 'git push origin main' run_case pass 'git push' run_case pass 'git tag v1.0.8 abc1234' run_case pass 'git commit -m "push 前に直す"' +# 引用符の中の区切り文字で誤分割しない (Codex レビュー指摘) +run_case pass 'git commit -m "docs: explain x; npm publish requires approval"' +run_case pass 'git commit -m "a && npm publish && b"' +run_case pass 'echo "gh pr merge は AI が押さない | npm publish も同様"' +# --dry-run の有効な指定は素通し +run_case pass 'npm publish --dry-run=true' +run_case pass 'npm publish --dry-run=1' +run_case pass 'git push origin main:main' +run_case pass 'bash -c "pnpm build && pnpm test"' +run_case pass 'bash scripts/hooks/irreversible-ops-guard.test.sh' run_case pass 'git commit -m "chore: npm publish の手順を追記"' run_case pass 'git commit -m "🔖 chore: release v1.0.8"' run_case pass 'echo "gh pr merge は AI が自発的に実行しない"' From 689f8d9857fddec59ed7fc193b124646328ae95e Mon Sep 17 00:00:00 2001 From: touyou <465697+touyou@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:55:40 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=94=92=20fix:=20CodeRabbit=20?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=AE=E5=8F=8D=E6=98=A0=EF=BC=88=E6=89=BF?= =?UTF-8?q?=E8=AA=8D=E3=82=B9=E3=82=B3=E3=83=BC=E3=83=97=E3=83=BB=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E5=8E=B3=E6=A0=BC=E5=8C=96=E3=83=BB=E6=89=8B?= =?UTF-8?q?=E9=A0=86=E3=81=AE=E5=AE=89=E5=85=A8=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **承認は 1 コマンド単位に限定**。継承したプロセス環境の SPARKLE_CONFIRM=1 を信頼していたため、`export SPARKLE_CONFIRM=1` を一度 実行するとセッション中ずっとガードが外れる状態だった。コマンド先頭に 書かれた指定だけを承認扱いにし、回帰テストを追加 - テストの exit code 判定を厳格化。0 / 2 以外(hook の実行時エラー)を pass 扱いにしていたため、動いていなくても緑になりえた - accessibility-checker: SKILL.md と workflow.md でステップ番号の振り方が 違い、「Step 4 まで」が真逆に読めた。停止位置を番号でなく名前で示す - release-sparkle-design: リリース SHA を `grep | head -1` で決めていたのを 候補が 1 件であることの検証 + 目視確認に変更。CHANGELOG 抽出が空なら Release 作成を中止するチェックも追加 - context.md: ブロック対象の説明が実装より狭かった(unpublish / deprecate / リモート ref 削除が抜け)ので実装に合わせて列挙 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013WFgMWTQ6QAWShTRLNRUQx --- .claude/skills/accessibility-checker/SKILL.md | 6 +++++- .../references/workflow.md | 6 ++++-- .../skills/release-sparkle-design/SKILL.md | 10 +++++++++- .cursor/rules/context.mdc | 14 +++++++++----- .../instructions/ai-context.instructions.md | 14 +++++++++----- AGENTS.md | 14 +++++++++----- docs/ai-instructions/context.md | 14 +++++++++----- scripts/hooks/irreversible-ops-guard.mjs | 13 +++++++++---- scripts/hooks/irreversible-ops-guard.test.sh | 19 ++++++++++++++++++- 9 files changed, 81 insertions(+), 29 deletions(-) diff --git a/.claude/skills/accessibility-checker/SKILL.md b/.claude/skills/accessibility-checker/SKILL.md index d9a0e984..b84379c5 100644 --- a/.claude/skills/accessibility-checker/SKILL.md +++ b/.claude/skills/accessibility-checker/SKILL.md @@ -98,7 +98,11 @@ Apply minimal, safe code changes per component, then re-check affected items. > **🛑 Fix は既定の動作ではない(このスキルで最優先のルール)** > -> このスキルの既定の成果物は **レポート(Step 4)まで**。コードの書き換えは以下を満たしたときだけ行う。 +> このスキルの既定の成果物は **レポート生成("Generate Report" / `workflow.md` の "3. Review")まで**。 +> コードの書き換え(`workflow.md` の "4. Fixes")は以下を満たしたときだけ行う。 +> +> ※ ステップ番号は SKILL.md の Quick Workflow(1〜4)と `workflow.md`(1〜7)で振り方が異なる。 +> 停止位置は番号ではなく**名前**で判断すること。 > > - ユーザーが「直して」「修正して」と**明示的に依頼した**場合に限る。 > 「a11y チェックして」「レビューして」という依頼は**修正の承認ではない** diff --git a/.claude/skills/accessibility-checker/references/workflow.md b/.claude/skills/accessibility-checker/references/workflow.md index 22f4a51a..a16d88b8 100644 --- a/.claude/skills/accessibility-checker/references/workflow.md +++ b/.claude/skills/accessibility-checker/references/workflow.md @@ -60,8 +60,10 @@ For each checklist item, record how it was verified: ## 4. Fixes (Minimal Changes + Non-Breaking Improvements) > 🛑 **This step only runs when the user explicitly asked for fixes and approved the scope.** -> See "3. Fix" in `SKILL.md`. If they only asked for a review, stop after step 3 (Report) -> and list the proposed fixes instead of applying them. +> See "3. Fix" under *Intended Usage* in `SKILL.md`. If they only asked for a review, +> stop after **"3. Review (Recording Evidence)"** above — report the proposed fixes +> instead of applying them. (Step numbers differ between `SKILL.md`'s Quick Workflow +> and this file, so always identify the stopping point by name, not by number.) - Prioritize fixes that don't break compatibility - For "accident-prone APIs," prefer **gradual deprecation** over removal (see `PROJECT_POLICY.md` for details) diff --git a/.claude/skills/release-sparkle-design/SKILL.md b/.claude/skills/release-sparkle-design/SKILL.md index a62fdc42..18716331 100644 --- a/.claude/skills/release-sparkle-design/SKILL.md +++ b/.claude/skills/release-sparkle-design/SKILL.md @@ -128,7 +128,12 @@ user-invocable: true - [ ] **リリースコミットの SHA を `git log --oneline | grep release | head -1` で必ず特定** - [ ] **タグはリリースコミットの SHA を明示して打つ**(HEAD に打つと main が進んだ場合に誤タグ → publish 漏れ・誤 publish の温床): ```bash - RELEASE_SHA=$(git log --oneline | grep "release vX.Y.Z" | head -1 | awk '{print $1}') + # 候補が 1 件であることを確認してからタグを打つ(別バージョンを掴むと誤 publish になる) + CANDIDATES=$(git log --format='%H %s' | grep -F "release vX.Y.Z") + echo "$CANDIDATES" + [ "$(echo "$CANDIDATES" | wc -l)" -eq 1 ] || { echo "候補が 1 件ではない。手で SHA を特定すること" >&2; exit 1; } + RELEASE_SHA=$(echo "$CANDIDATES" | awk '{print $1}') + git show --no-patch --oneline "$RELEASE_SHA" # 対象コミットを目視確認する git tag vX.Y.Z "$RELEASE_SHA" ``` - [ ] 🛑 タグを push する(打った SHA とコミットメッセージを提示して確認後): @@ -139,6 +144,9 @@ user-invocable: true CHANGELOG.md のセクションを `--notes` で直接渡すのが確実: ```bash awk '/^## \[X\.Y\.Z\]/{flag=1;next} /^## \[/{flag=0} flag' CHANGELOG.md > /tmp/notes.md + # 見出しの誤記や CHANGELOG 未反映だと空になる。空の Release を作らない + [ -s /tmp/notes.md ] || { echo "CHANGELOG から vX.Y.Z のセクションを抽出できなかった" >&2; exit 1; } + cat /tmp/notes.md # 内容を目視確認してから作成する gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/notes.md ``` - [ ] 🛑 **npm publish** — GitHub Actions ワークフローを実行。**tag ref で実行する**ことで、main が進んでも正しいリリースコミットの内容が publish される: diff --git a/.cursor/rules/context.mdc b/.cursor/rules/context.mdc index 100f26a7..b66aeb53 100644 --- a/.cursor/rules/context.mdc +++ b/.cursor/rules/context.mdc @@ -85,13 +85,17 @@ pnpm test # Component tests - **Use the pinned toolchain**: Node.js 22.14.0 / pnpm 10.12.4 (see `.tool-versions`; newer pnpm majors can rewrite the lockfile) ### Irreversible Operations Are Blocked by a Hook -`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks -`npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, -publish workflows, tag pushes, and force pushes. It also looks inside command substitutions -(`$(...)`) and `bash -c "..."`, so those are not a way around it. +`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks: + +- `npm/pnpm/yarn/bun publish` (an enabled `--dry-run` passes), `unpublish`, `deprecate` +- `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, publish workflows +- Release tag pushes, `--tags`, force pushes (including a `+` refspec), and remote ref deletion (`--delete` / `:ref`) + +It also looks inside command substitutions (`$(...)`) and `bash -c "..."`, so those are not a way around it. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. -- Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. +- Once instructed, re-run with `SPARKLE_CONFIRM=1` **in front of that command**. Adding that prefix without an instruction defeats the guard. +- An inherited/exported `SPARKLE_CONFIRM=1` is deliberately ignored — approval is per command, not per session. - Agents without hook support must follow the same rule — the hook is a backstop, not the rule itself. - Tests: `pnpm test:hooks` diff --git a/.github/instructions/ai-context.instructions.md b/.github/instructions/ai-context.instructions.md index 28266c9f..d9033f93 100644 --- a/.github/instructions/ai-context.instructions.md +++ b/.github/instructions/ai-context.instructions.md @@ -83,13 +83,17 @@ pnpm test # Component tests - **Use the pinned toolchain**: Node.js 22.14.0 / pnpm 10.12.4 (see `.tool-versions`; newer pnpm majors can rewrite the lockfile) ### Irreversible Operations Are Blocked by a Hook -`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks -`npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, -publish workflows, tag pushes, and force pushes. It also looks inside command substitutions -(`$(...)`) and `bash -c "..."`, so those are not a way around it. +`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks: + +- `npm/pnpm/yarn/bun publish` (an enabled `--dry-run` passes), `unpublish`, `deprecate` +- `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, publish workflows +- Release tag pushes, `--tags`, force pushes (including a `+` refspec), and remote ref deletion (`--delete` / `:ref`) + +It also looks inside command substitutions (`$(...)`) and `bash -c "..."`, so those are not a way around it. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. -- Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. +- Once instructed, re-run with `SPARKLE_CONFIRM=1` **in front of that command**. Adding that prefix without an instruction defeats the guard. +- An inherited/exported `SPARKLE_CONFIRM=1` is deliberately ignored — approval is per command, not per session. - Agents without hook support must follow the same rule — the hook is a backstop, not the rule itself. - Tests: `pnpm test:hooks` diff --git a/AGENTS.md b/AGENTS.md index d2079d24..9d86405f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,13 +97,17 @@ pnpm test # Component tests - **Use the pinned toolchain**: Node.js 22.14.0 / pnpm 10.12.4 (see `.tool-versions`; newer pnpm majors can rewrite the lockfile) ### Irreversible Operations Are Blocked by a Hook -`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks -`npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, -publish workflows, tag pushes, and force pushes. It also looks inside command substitutions -(`$(...)`) and `bash -c "..."`, so those are not a way around it. +`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks: + +- `npm/pnpm/yarn/bun publish` (an enabled `--dry-run` passes), `unpublish`, `deprecate` +- `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, publish workflows +- Release tag pushes, `--tags`, force pushes (including a `+` refspec), and remote ref deletion (`--delete` / `:ref`) + +It also looks inside command substitutions (`$(...)`) and `bash -c "..."`, so those are not a way around it. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. -- Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. +- Once instructed, re-run with `SPARKLE_CONFIRM=1` **in front of that command**. Adding that prefix without an instruction defeats the guard. +- An inherited/exported `SPARKLE_CONFIRM=1` is deliberately ignored — approval is per command, not per session. - Agents without hook support must follow the same rule — the hook is a backstop, not the rule itself. - Tests: `pnpm test:hooks` diff --git a/docs/ai-instructions/context.md b/docs/ai-instructions/context.md index 870c439b..d3b0f8d9 100644 --- a/docs/ai-instructions/context.md +++ b/docs/ai-instructions/context.md @@ -79,13 +79,17 @@ pnpm test # Component tests - **Use the pinned toolchain**: Node.js 22.14.0 / pnpm 10.12.4 (see `.tool-versions`; newer pnpm majors can rewrite the lockfile) ### Irreversible Operations Are Blocked by a Hook -`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks -`npm publish`, `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, -publish workflows, tag pushes, and force pushes. It also looks inside command substitutions -(`$(...)`) and `bash -c "..."`, so those are not a way around it. +`scripts/hooks/irreversible-ops-guard.sh` (a PreToolUse hook wired in `.claude/settings.json`) blocks: + +- `npm/pnpm/yarn/bun publish` (an enabled `--dry-run` passes), `unpublish`, `deprecate` +- `gh pr merge`, `gh release create/delete`, `gh repo create/delete/archive`, publish workflows +- Release tag pushes, `--tags`, force pushes (including a `+` refspec), and remote ref deletion (`--delete` / `:ref`) + +It also looks inside command substitutions (`$(...)`) and `bash -c "..."`, so those are not a way around it. - These run **only when the user names the operation**. A broad "release it" / "go ahead" is not approval. -- Once instructed, re-run with `SPARKLE_CONFIRM=1` in front. Adding that prefix without an instruction defeats the guard. +- Once instructed, re-run with `SPARKLE_CONFIRM=1` **in front of that command**. Adding that prefix without an instruction defeats the guard. +- An inherited/exported `SPARKLE_CONFIRM=1` is deliberately ignored — approval is per command, not per session. - Agents without hook support must follow the same rule — the hook is a backstop, not the rule itself. - Tests: `pnpm test:hooks` diff --git a/scripts/hooks/irreversible-ops-guard.mjs b/scripts/hooks/irreversible-ops-guard.mjs index bc7c8a1a..fce08cfc 100755 --- a/scripts/hooks/irreversible-ops-guard.mjs +++ b/scripts/hooks/irreversible-ops-guard.mjs @@ -295,16 +295,21 @@ export function inspectSegment(segment) { return null; } -/** @returns {{op: string, segment: string} | null} */ -export function inspectCommand(command, env = process.env, depth = 0) { - if (env.SPARKLE_CONFIRM === "1") return null; +/** + * 承認は **そのコマンドの先頭に書かれた `SPARKLE_CONFIRM=1`** だけを見る。 + * 継承されたプロセス環境変数は意図的に無視する。`export SPARKLE_CONFIRM=1` を + * 一度実行するとセッション中ずっとガードが外れてしまい、 + * 「1 操作ごとにユーザーの指示を得る」という設計が崩れるため。 + * @returns {{op: string, segment: string} | null} + */ +export function inspectCommand(command, depth = 0) { if (depth > 3) return null; // `bash -c` の入れ子が病的に深い入力への保険 for (const segment of splitSegments(command)) { const result = inspectSegment(segment); if (!result) continue; if ("confirmed" in result) continue; if ("nested" in result) { - const nested = inspectCommand(result.nested, env, depth + 1); + const nested = inspectCommand(result.nested, depth + 1); if (nested) return nested; continue; } diff --git a/scripts/hooks/irreversible-ops-guard.test.sh b/scripts/hooks/irreversible-ops-guard.test.sh index c64378fb..b6c48024 100755 --- a/scripts/hooks/irreversible-ops-guard.test.sh +++ b/scripts/hooks/irreversible-ops-guard.test.sh @@ -23,7 +23,10 @@ run_case() { env -u SPARKLE_CONFIRM bash "$hook" >/dev/null 2>&1 <<<"$payload" actual_exit=$? - local actual="pass" + # exit 0 / 2 以外は hook 側の実行時エラー。pass 扱いにすると + # 「動いていないのに緑」になるので、明示的に不一致として扱う。 + local actual="error(exit=$actual_exit)" + [ "$actual_exit" -eq 0 ] && actual="pass" [ "$actual_exit" -eq 2 ] && actual="block" if [ "$actual" = "$expected" ]; then @@ -129,6 +132,20 @@ run_case pass 'SPARKLE_CONFIRM=1 npm publish' run_case pass 'SPARKLE_CONFIRM=1 gh workflow run "Publish to npm" --ref v1.0.8' run_case pass 'SPARKLE_CONFIRM=1 gh pr merge 297 --merge --admin' +# 継承した環境変数ではバイパスできないこと (CodeRabbit レビュー指摘)。 +# `export SPARKLE_CONFIRM=1` が一度でも走るとセッション中ずっとガードが +# 外れる、という状態にしないための回帰テスト。 +echo "== 環境変数の継承ではバイパスできないこと ==" +inherited_exit=0 +SPARKLE_CONFIRM=1 bash "$hook" >/dev/null 2>&1 \ + <<<"$(jq -nc '{tool_input: {command: "npm publish"}, cwd: "/tmp"}')" || inherited_exit=$? +if [ "$inherited_exit" -eq 2 ]; then + pass=$((pass + 1)) +else + fail=$((fail + 1)) + echo " ✗ 継承した SPARKLE_CONFIRM=1 でブロックが外れた (exit=$inherited_exit)" +fi + echo "" echo "pass=$pass fail=$fail" [ "$fail" -eq 0 ] || exit 1