diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..5dcdf39d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/scripts/hooks/irreversible-ops-guard.sh\"", + "timeout": 15, + "statusMessage": "Checking for irreversible operations..." + } + ] + } + ] + } +} diff --git a/.claude/skills/accessibility-checker/SKILL.md b/.claude/skills/accessibility-checker/SKILL.md index b46df1fa..b84379c5 100644 --- a/.claude/skills/accessibility-checker/SKILL.md +++ b/.claude/skills/accessibility-checker/SKILL.md @@ -96,6 +96,26 @@ Review each component/story, focusing on applicability and evidence collection. Apply minimal, safe code changes per component, then re-check affected items. +> **🛑 Fix は既定の動作ではない(このスキルで最優先のルール)** +> +> このスキルの既定の成果物は **レポート生成("Generate Report" / `workflow.md` の "3. Review")まで**。 +> コードの書き換え(`workflow.md` の "4. Fixes")は以下を満たしたときだけ行う。 +> +> ※ ステップ番号は SKILL.md の Quick Workflow(1〜4)と `workflow.md`(1〜7)で振り方が異なる。 +> 停止位置は番号ではなく**名前**で判断すること。 +> +> - ユーザーが「直して」「修正して」と**明示的に依頼した**場合に限る。 +> 「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..a16d88b8 100644 --- a/.claude/skills/accessibility-checker/references/workflow.md +++ b/.claude/skills/accessibility-checker/references/workflow.md @@ -59,6 +59,12 @@ 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" 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 93d4fac1..18716331 100644 --- a/.claude/skills/release-sparkle-design/SKILL.md +++ b/.claude/skills/release-sparkle-design/SKILL.md @@ -29,6 +29,17 @@ user-invocable: true ## AI アシスタントへの指示 +> **🛑 不可逆操作の扱い(このスキルで最優先のルール)** +> +> 🛑 が付いた項目(PR マージ / タグ push / GitHub Release 作成 / npm publish)は、 +> **ユーザーが名指しで指示したときだけ**実行する。「リリースして」という最初の依頼は、 +> マージ・publish までの事前承認ではない。**リストに並んでいることは実行してよい理由にならない。** +> 🛑 の手前まで進んだら停止し、状況(PR 番号 / CI 状態 / 次のコマンド)を報告して指示を待つ。 +> +> これらは `scripts/hooks/irreversible-ops-guard.sh`(PreToolUse hook)が実際にブロックする。 +> ブロックされたら、ユーザーの指示を得たうえで `SPARKLE_CONFIRM=1` を先頭に付けて再実行する。 +> **hook が無い環境(Claude Code 以外のエージェント)でも、上のルールは同じように適用する。** + ### 実行方針 1. **ユーザーにリリース種別を確認** @@ -43,6 +54,9 @@ user-invocable: true 3. **以下のチェックリストを順に実行する** + - ただし 🛑 が付いた項目に到達したら、そこで停止してユーザーの明示的な指示を待つ + (上の「不可逆操作の扱い」を参照)。停止せずに走り切ってはならない + --- ## リリース手順チェックリスト @@ -61,11 +75,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 +106,56 @@ user-invocable: true ### リリース PR レビュー・マージ +> 🛑 **AI はここで必ず停止する。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 +> 🛑 **マージが済んだからといって、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` で必ず特定** - [ ] **タグはリリースコミットの 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 とコミットメッセージを提示して確認後): + ```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 + # 見出しの誤記や 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 される: +- [ ] 🛑 **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** で発行 diff --git a/.cursor/rules/context.mdc b/.cursor/rules/context.mdc index 64a07c9d..b66aeb53 100644 --- a/.cursor/rules/context.mdc +++ b/.cursor/rules/context.mdc @@ -84,6 +84,21 @@ 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/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 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` + ## 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..d9033f93 100644 --- a/.github/instructions/ai-context.instructions.md +++ b/.github/instructions/ai-context.instructions.md @@ -82,6 +82,21 @@ 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/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 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` + ## 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..9d86405f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,21 @@ 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/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 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` + ## 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..d3b0f8d9 100644 --- a/docs/ai-instructions/context.md +++ b/docs/ai-instructions/context.md @@ -78,6 +78,21 @@ 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/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 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` + ## 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.mjs b/scripts/hooks/irreversible-ops-guard.mjs new file mode 100755 index 00000000..fce08cfc --- /dev/null +++ b/scripts/hooks/irreversible-ops-guard.mjs @@ -0,0 +1,364 @@ +#!/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; +} + +/** + * 承認は **そのコマンドの先頭に書かれた `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, 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 new file mode 100755 index 00000000..44805c36 --- /dev/null +++ b/scripts/hooks/irreversible-ops-guard.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Claude Code PreToolUse hook のエントリポイント。 +# 判定本体は irreversible-ops-guard.mjs (引用符とコマンド置換を正しく扱うため Node で書いてある)。 +# +# node が無い環境では素通しする (exit 0)。ガードが効かなくなるが、 +# 安全のための hook が開発そのものを止めてしまう方が害が大きいため。 +# +# このファイルは Sparkle のリポジトリ間で同一内容を保つ (差分を作らない)。 +# 更新するときは sparkle-design 側を先に直し、他リポジトリへコピーする。 +set -uo pipefail + +if ! command -v node >/dev/null 2>&1; then + exit 0 +fi + +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 new file mode 100755 index 00000000..b6c48024 --- /dev/null +++ b/scripts/hooks/irreversible-ops-guard.test.sh @@ -0,0 +1,152 @@ +#!/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=$? + + # 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 + 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 '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' +# --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' +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' +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 '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"' +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' +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 前に直す"' +# 引用符の中の区切り文字で誤分割しない (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 が自発的に実行しない"' +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' + +# 継承した環境変数ではバイパスできないこと (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 +echo "すべてのケースが期待どおりでした。"