diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index 24c08cb9..41fd06e5 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -56,6 +56,10 @@ failed_marker_check_enabled = true # 前回失敗 marker 検出 → resume promo # "jj-message-required" — jj new / jj split を -m / --message なしでブロック # (混合 commit 事故 + jj split editor hang を構造的に防止、 # 順位 144 / PR #171 T3-#8 採用) +# "secret-detection" — AWS / OpenAI / GitHub / Anthropic 等の hardcoded secret を +# Bash command + Edit/Write content 双方で検出してブロック +# (~/.claude/rules/common/security.md § Secret Management 移管、 +# 順位 146 / PR #200 follow-up 採用、Tier 1 default-on) # プリセット名以外の文字列は正規表現としてカスタムパターン扱い blocked_patterns = [ "default", @@ -69,6 +73,7 @@ blocked_patterns = [ "exe-help-block", "electron", "jj-message-required", + "secret-detection", ] # 追加の保護ファイル (デフォルトリストに追加) diff --git a/docs/todo-summary.md b/docs/todo-summary.md index 2e403397..047bcb30 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -50,7 +50,6 @@ | 140 | 💎 Tier 3 | **順位 135「codified placeholder policy」を正式 ADR に昇格 (PR #169 T3-#2 採用)** | todo8.md | S | なし (順位 135 entry を retire し、ADR-NNN (採番未確定、land 時に確定): ADR Numbering Strategy として永続化。PR #111/#132/#169 の 3+ PR で適用実証済 — PR #169 で「ADR-038 → 041 → NNN」3 段振り直し dogfood が land、ephemeral todo entry 限りでは派生プロジェクトへの transferability 不足、`feedback_no_unenforced_rules.md` 例外 = 既存実践 (3 PR で実証) の明文化 + 後続 entry が同 policy を参照する際の永続 reference 確保) | | 143 | 🔧 Tier 2 | **複言語 fixture helper 標準化 (hooks-post-tool-linter-tests) (PR #171 T2-#4 採用) ★ Bundle 171** | todo8.md | S | なし (PR #151/#171 の 2 PR 横断で multi-byte fixture 手動組み立てコストが Frequency Medium で観測、Japanese / emoji / combining chars helper 3 関数を標準化して新規 string-processing 関数追加時の boundary test コスト削減 + silent regression early detection、順位 142 + 144 と同 PR で land 推奨) | | 145 | 🔧 Tier 2 | **preset matrix test 追加 — default fallback vs config-selectable の 2 軸 classification 検証 (PR #172 T2-#1 採用)** | todo8.md | M | なし (PR #172 Phase 3 で `jj-message-required` が opt-in preset であることを前提とせず test を書き rewrite が必要になった経緯、preset architecture の implicit assumption (always-enabled vs config-selectable) を classification 表として test レベルで codify、新 preset 追加時に matrix 更新を強制する mechanical enforcement で design misalignment を構造的検出、target は main.rs (feedback report の lib.rs 記載は誤り)) | -| 146 | 🚀 Tier 1 | **Secret detection PreToolUse hook 追加 — AWS/OpenAI/GitHub token 等の hardcoded secret 検出 (PR #172 仕組み化方針切替由来、`security.md` § Secret Management 移管) ★ Bundle 既存ルール仕組み化** | todo9.md | M | なし (`~/.claude/rules/common/security.md` § Secret Management 記述のみで機械強制なし、AWS Access Key / OpenAI sk- / GitHub ghp_/gho_/ghs_ / Anthropic sk-ant- 等 6+ 種 pattern を `preset_secret_detection` で regex 検出 + 即 block、順位 144 hook 化 template 踏襲、security-critical かつ漏洩観測前の preventive 層として Tier 1、rule docs § Secret Management を hook block message に集約で縮小) | | 147 | 🔧 Tier 2 | **File length lint (800 行 max) 追加 — `coding-style.md` § File Organization 移管 (PR #172 仕組み化方針切替由来) ★ Bundle 既存ルール仕組み化** | todo9.md | S | なし (`~/.claude/rules/common/coding-style.md` § File Organization の 800 行 max を `hooks-post-tool-comment-lint-rust` に追加、順位 48 関数長と同 touch-trigger ratchet pattern で grandfather 適用、Rust 限定 MVP、順位 57 truncate contract 整合、rule docs から具体閾値削除で縮小) | | 148 | 🔧 Tier 2 | **Test coverage 80% CI gate 追加 — `testing.md` § Minimum Test Coverage 80% 移管 (PR #172 仕組み化方針切替由来) ★ Bundle 既存ルール仕組み化** | todo9.md | S-M | なし (`~/.claude/rules/common/testing.md` § 80% coverage ガイドラインを実行時 gate に変換、`cargo llvm-cov --fail-under-lines 80` を push-runner-config.toml [quality_gate] に integration 推奨、現状未測定のため段階導入計画必要、rule docs § 80% coverage を実行時 gate 参照に縮小) | | 149 | 🔧 Tier 2 | **Long-running subprocess pipe truncate hook 拡張 — `development-workflow.md` § subprocess pipe truncate 禁止 移管 (PR #172 仕組み化方針切替由来) ★ Bundle 既存ルール仕組み化** | todo9.md | S | なし (既存 `exe-help-block` preset を `cli-*.exe ... \| (head\|tail\|awk)` 等の副作用ある subprocess 出力 truncate にも拡張 or 新 `subprocess-pipe-truncate-block` preset 追加、PR #109 SIGPIPE 事故 root cause の構造化、順位 44 (gh-token-efficiency) との scope 境界整理必要、development-workflow.md § 該当 section 縮小) | @@ -80,6 +79,10 @@ | 194 | 💎 Tier 3 | **`development-workflow.md` 「1. Plan First」に「task 着手前に grep で既存 section 確認」step 追記 (PR #196 T3-5 採用)** | todo10.md | XS | なし (PR #123 + #196 で「既実装 section の重複計画」事象を Frequency Medium で観測、`~/.claude/rules/common/development-workflow.md` "1. Plan First" に Codification 重複確認 step を 1-2 行追記、`grep -rn` 手順 + 由来 cite (PR #123, #196)、派生プロジェクト (techbook-ledger / auto-review-fix-vc) に global rule として自動波及、`feedback_global_config_backup` 適用必須) | | 197 | 🔧 Tier 2 | **`hooks-session-start` orphan age 計算に proptest 追加 (PR #199 post-merge-feedback T2-1 採用)** | todo10.md | M | なし (Bundle W の PastTime newtype + proptest pattern を `src/hooks-session-start/src/main.rs:236` の `saturating_sub` orphan age 計算に展開、clock rewind で age=0 → orphan reaper sleep → ADR-030 L2 recovery 沈黙の failure chain を構造的に防ぐ、Severity High + Frequency Medium、proptest 依存追加のみ) | | 198 | 💎 Tier 3 | **ADR-NNN (採番未確定、land 時に確定): Timestamp invariant safety — 時刻計算 silent failure class の codify (PR #199 post-merge-feedback T3-2 採用)** | todo10.md | M | なし (PR #96 Finding D + PR #199 Bundle W で同型 bug class 2 件観測 = Frequency Medium、PastTime newtype + proptest が実証した型層防御原則を ADR で永続化、派生プロジェクト (techbook-ledger / auto-review-fix-vc) への transferability 確保、順位 135 placeholder policy 適用、CLAUDE.md ADR list 追記) | +| 199 | 🔧 Tier 2 | **multi-byte 文字を含む string window test の標準 coverage requirement 化 (PR #200 post-merge-feedback T2-1 採用)** | todo10.md | S | なし (PR #199 byte 計算混乱 + PR #200 priority_inversion char window bug の 2 観測 = Frequency Medium、`is_resolved_detects_marker_across_multibyte_gap` style を testing.md に標準化、新 validator 追加時に CJK 40 文字 (= 120 bytes) gap 含む multi-byte test を必須化、MVP は docs/checklist、3-5 validator land 後に lint rule 化を再評価) | +| 200 | 💎 Tier 3 | **`~/.claude/rules/rust/patterns.md` に「String Indexing with Multi-byte Characters」section 追加 (PR #200 post-merge-feedback T3-1 採用)** | todo10.md | XS | なし (PR #199 parse_age_secs + PR #200 priority_inversion の 2 観測 = Frequency Medium、`char_indices().nth(N)` canonical reference を global rules に追加して将来の lint rule 著者が同型 byte/char 混同 bug を再生産しないよう構造的予防、派生プロジェクト (techbook-ledger / auto-review-fix-vc) へも自動波及、`feedback_global_config_backup` 適用必須) | +| 201 | 💎 Tier 3 | **ADR-007 に「Regex は loop / repeated call 内で `LazyLock` 必須」guideline 追記 (PR #200 post-merge-feedback T3-2 採用)** | todo10.md | XS | なし (PR #200 priority_inversion で per-row `Regex::new()` 再 compile を `LazyLock` 化した F-2 fix を ADR-007 § 正規表現層 に guideline として追記、`TIER_REGEX` / `RANK_REGEX` を参照実装として cite、1000+ 行 table での累積コスト顕在化を予防) | +| 202 | 💎 Tier 3 | **`~/.claude/rules/common/testing.md` に「multi-path test fixture isolation」section 追記 (PR #200 post-merge-feedback T3-3 採用)** | todo10.md | XS | なし (PR #200 F-3 fix で実証した「Path A を exercise する fixture には Path B トリガー条件を明示除外」 pattern を sentinel section 直下に追加、silent path-shift fragility を構造的に防ぐ、sentinel pattern (mutation 不在 assert) と相補的な test robustness 手法、派生プロジェクトへ global rules 経由で自動波及、`feedback_global_config_backup` 適用必須) | **戦略**: Tier 1 を 2〜3 セッションで片付け → Tier 2 で ADR-032 の前提 + rate-limit + convergence cost 削減を進める → Tier 3 で ADR-032 を land + ドキュメント整備。Tier 4-5 は cleanup / 外部展開で daily efficiency への直接効果は小さい。 diff --git a/docs/todo10.md b/docs/todo10.md index 5d1d3caf..ab69caf7 100644 --- a/docs/todo10.md +++ b/docs/todo10.md @@ -499,6 +499,162 @@ fn companion_helpers_share_default_branch_signature() { --- +### multi-byte 文字を含む string window test の標準 coverage requirement 化 (PR #200 post-merge-feedback T2-1 採用) + +> **動機**: PR #200 で `priority_inversion::has_resolved_marker_after` の window 計算が **byte 演算** で、日本語 1 文字 = 3 bytes のため「80 文字」のつもりが実質 ~27 文字に縮退する Major bug が発生 (CR が指摘、char-based に修正済)。PR #199 でも `parse_age_secs` 周辺で byte/char 混乱があり、Frequency Medium (2 観測) で systemic。char-based fix と regression test (`is_resolved_detects_marker_across_multibyte_gap`) は PR #200 で完了済だが、**将来の新規 validator が同パターンで実装されたとき multi-byte test が無指定で欠落するリスク** を構造的に塞ぐ。 +> +> **本タスクの位置づけ**: PR #200 post-merge-feedback Tier 2 #1 採用 (Severity High / Frequency Medium / Effort S / Adoption Risk None、2026-06-09 ユーザー承認)。`is_resolved_detects_marker_across_multibyte_gap` スタイルを **coverage requirement** として位置付け、新規 validator 追加 PR で同パターンの test を必須化する。 +> +> **参照**: `.claude/feedback-reports/200.md` Tier 2 #1、`src/cli-docs-lint/src/priority_inversion.rs:469-473` (char-based window fix)、`is_resolved_detects_marker_across_multibyte_gap` test (regression)、PR #199 PastTime newtype + proptest (parse_age_secs 周辺の byte 演算)。 +> +> **実行優先度**: 🔧 **Tier 2** — 工数 Small。Coverage requirement 化のみで実装作業は新 validator 追加時の test 追記 (チェックリスト + テストテンプレート)。 + +#### 設計決定 (案) + +- **配置**: `~/.claude/rules/common/testing.md` (multi-path test fixture 拡張と同じ section) または `src/cli-docs-lint/README.md` (validator 追加 checklist) +- **要求項目**: + - 文字列 window 演算 (`str::find` + byte offset / `[start..end]` slice) を行う validator は、**30 bytes 超 multi-byte 文字を含む regression test を 1 件以上保持** する + - 推奨 fixture: CJK 40 文字 (= 120 bytes) gap + 末尾に marker + - assertion で window 内検出を verify +- **enforcement layer**: + - 案 A: docs (manual checklist、reviewer に頼る) + - 案 B: custom-lint-rules.toml で `str::find` + `[..]` slice 使用 file に対応 multi-byte test の存在を grep ベースで弱検出 (FP リスク高、要検討) +- **MVP**: 案 A (docs/checklist) で開始、3-5 validator land 後に案 B 化を再評価 + +#### 作業計画 + +- [ ] `~/.claude/rules/common/testing.md` の sentinel pattern section 末尾に「multi-byte string window test 必須」を追記 +- [ ] `src/cli-docs-lint/README.md` (or 該当 doc) に validator 追加 checklist として記載 +- [ ] PR #200 の `is_resolved_detects_marker_across_multibyte_gap` を参照テンプレートとして cite +- [ ] 派生プロジェクト deploy 計画 (techbook-ledger / auto-review-fix-vc) を別 task として todo 登録 +- [ ] 本 todo10.md エントリを削除 + +#### 完了基準 + +- testing.md に「multi-byte string window test 必須」requirement が追記され、参照テンプレートとして PR #200 test が cite される +- 派生プロジェクトでも同 rule が global 配下から自動波及 + +#### 詰まっている箇所 + +- 案 B (mechanical enforcement) は FP リスクが見えるため MVP では docs のみで開始。dogfood で test 漏れ実例が観測されたら案 B を再検討。 + +--- + +### `~/.claude/rules/rust/patterns.md` に「String Indexing with Multi-byte Characters」section 追加 (PR #200 post-merge-feedback T3-1 採用) + +> **動機**: PR #200 で `priority_inversion::has_resolved_marker_after` の byte/char 混同 Major bug を fix した際、`char_indices().nth(N)` パターンが Rust の canonical solution として有効と判明。同パターンは現在 `~/.claude/rules/rust/` に未記述で、将来の lint rule 著者が同型 bug を再生産するリスクあり。PR #199 (parse_age_secs 周辺) + PR #200 (priority_inversion) で 2 観測 = Frequency Medium。 +> +> **本タスクの位置づけ**: PR #200 post-merge-feedback Tier 3 #1 採用 (Severity Low / Frequency Medium / Effort XS / Adoption Risk None、2026-06-09 ユーザー承認)。global `~/.claude/rules/rust/patterns.md` への section 追加で、派生プロジェクト (techbook-ledger / auto-review-fix-vc) へも自動波及。 +> +> **参照**: `.claude/feedback-reports/200.md` Tier 3 #1、`src/cli-docs-lint/src/priority_inversion.rs:178-184` (char_indices() pattern)、PR #199 (parse_age_secs byte/char 観測)。 +> +> **実行優先度**: 💎 **Tier 3** — 工数 XS。`~/.claude/rules/rust/patterns.md` に 1 section (10-20 行) 追加のみ。 + +#### 設計決定 (案) + +- **配置**: `~/.claude/rules/rust/patterns.md` の Newtype Pattern section 近傍に新 section「String Indexing with Multi-byte Characters」を追加 +- **記述内容**: + - **BAD**: `&haystack[start..start + N]` で N が byte offset の場合 → multi-byte で off-by-N bytes + - **GOOD**: `haystack[start..].char_indices().nth(N).map(|(i, _)| start + i).unwrap_or(haystack.len())` で N 文字目の byte offset を取得 + - **由来**: PR #200 priority_inversion `has_resolved_marker_after` (cite 必須) + - **関連**: rust/security.md § Input Validation の「Parse, don't validate」原則と相補 + +#### 作業計画 + +- [ ] `~/.claude/rules/rust/patterns.md` を Read で確認 (現状の section 構成) +- [ ] 新 section「String Indexing with Multi-byte Characters」を Newtype Pattern 近傍に追加 +- [ ] BAD/GOOD code sample + PR #200 引用 + 関連参照を記述 +- [ ] `feedback_global_config_backup` を適用して snapshot 取得 +- [ ] 本 todo10.md エントリを削除 + +#### 完了基準 + +- `~/.claude/rules/rust/patterns.md` に新 section が追加され、char_indices().nth() pattern が canonical reference として記述される +- PR #200 の修正箇所 (src/cli-docs-lint/src/priority_inversion.rs:178-184) が cite される + +#### 詰まっている箇所 + +なし。Effort XS、global rules への docs 追記のみ。 + +--- + +### ADR-007 に「Regex は loop 内で `LazyLock` 必須」guideline 追記 (PR #200 post-merge-feedback T3-2 採用) + +> **動機**: PR #200 で `priority_inversion::parse_tier` / `extract_referenced_ranks` が per-row `Regex::new()` 再 compile していた問題を `LazyLock` で module 初期化時の 1 回 compile に修正 (F-2)。同パターンの guideline は ADR-007 (custom linter regex/AST 層の線引き) に未記述で、将来の custom lint rule 著者が同型 bug を再生産するリスクあり。小規模 table では無害だが 1000+ 行 table では顕著な遅延。 +> +> **本タスクの位置づけ**: PR #200 post-merge-feedback Tier 3 #2 採用 (Severity Medium / Frequency Low / Effort XS / Adoption Risk None、2026-06-09 ユーザー承認)。ADR-007 への guideline 追記で、本リポジトリの lint runner サポートと整合。 +> +> **参照**: `.claude/feedback-reports/200.md` Tier 3 #2、`src/cli-docs-lint/src/priority_inversion.rs:29-34` (TIER_REGEX / RANK_REGEX の LazyLock 定義)、ADR-007 (custom-linter-layer-boundary)。 +> +> **実行優先度**: 💎 **Tier 3** — 工数 XS。ADR-007 に 1 guideline (5-10 行) 追記のみ。 + +#### 設計決定 (案) + +- **配置**: `docs/adr/adr-007-custom-linter-layer-boundary.md` の「正規表現層」section に新 guideline 「Regex は loop / repeated call 内では `LazyLock` 必須」を追記 +- **記述内容**: + - **原則**: `Regex::new()` は重い処理 (regex compilation)。loop 内 / per-row call で繰り返すと累積コストが顕在化 + - **GOOD**: `static MY_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"...").unwrap());` + - **由来**: PR #200 priority_inversion の `TIER_REGEX` / `RANK_REGEX` (cite 必須) + - **関連**: `~/.claude/rules/rust/coding-style.md` § Iterators Over Loops と相補 + +#### 作業計画 + +- [ ] `docs/adr/adr-007-custom-linter-layer-boundary.md` を Read で確認 (現状の section 構成) +- [ ] 「正規表現層」section に新 guideline を追記 +- [ ] LazyLock 利用例 + PR #200 引用を記述 +- [ ] 本 todo10.md エントリを削除 + +#### 完了基準 + +- ADR-007 に「Regex は loop / repeated call 内で LazyLock 必須」guideline が追記される +- PR #200 の TIER_REGEX / RANK_REGEX が参照実装として cite される + +#### 詰まっている箇所 + +なし。Effort XS、ADR への docs 追記のみ。 + +--- + +### `~/.claude/rules/common/testing.md` に「multi-path test fixture isolation」section 追記 (PR #200 post-merge-feedback T3-3 採用) + +> **動機**: PR #200 pre-push reviewer non-blocking finding F-3 で、test fixture が **意図せず複数 path をカバー** していると、将来 fixture 変更時に test 経路が silent shift する fragility が指摘された。修正は fixture を resolved-marker 非含有に変更し missing-rank 経路を厳密に exercise する形にした。この設計手法は sentinel pattern (`feedback_test_dry_antipattern` 起源、testing.md 既記述) と独立な「Path A を exercise する場合は Path B トリガー条件を意図的に除外」 pattern として汎用化できる。 +> +> **本タスクの位置づけ**: PR #200 post-merge-feedback Tier 3 #3 採用 (Severity Medium / Frequency Low / Effort XS / Adoption Risk None、2026-06-09 ユーザー承認)。sentinel section 直下に「multi-path test fixture isolation」変種として追加することで、test robustness パターンを補完。 +> +> **参照**: `.claude/feedback-reports/200.md` Tier 3 #3、`src/cli-docs-lint/src/priority_inversion.rs:633-637` (F-3 fix のテストコメント、fixture 設計意図)、PR #200 pre-push reviewer F-3 finding。 +> +> **実行優先度**: 💎 **Tier 3** — 工数 XS。`~/.claude/rules/common/testing.md` の sentinel section に 1 sub-section (10-15 行) 追記のみ。 + +#### 設計決定 (案) + +- **配置**: `~/.claude/rules/common/testing.md` の sentinel 事前投入 section 直下 +- **記述内容**: + - **原則**: 複数 path をカバーしうる fixture では、Path A を exercise する意図なら Path B トリガー条件を fixture から **明示除外** する。silent shift (= 将来 fixture 変更で test 経路が無告知に変わる) を防ぐ + - **BAD**: missing-rank 経路を exercise する test で fixture に resolved-marker (`(retire 済)`) を含める → 別経路でも skip するため意図 path が test されない + - **GOOD**: missing-rank 経路には resolved-marker 非含有 fixture (`順位 19 land 後推奨`) を使う → 純粋に missing-rank skip のみが exercise される + - **由来**: PR #200 F-3 fix (`is_rank_resolved` test fixture redesign) + - **関連**: sentinel 事前投入 (mutation 不在 assert) と相補的 — sentinel は「mutation が起こらないことを観測可能化」、本パターンは「意図 path を path-shift から保護」 + +#### 作業計画 + +- [ ] `~/.claude/rules/common/testing.md` を Read で確認 (sentinel section の現状) +- [ ] sentinel section 直下に新 sub-section「multi-path test fixture isolation」を追加 +- [ ] BAD/GOOD example + PR #200 F-3 cite を記述 +- [ ] `feedback_global_config_backup` を適用して snapshot 取得 +- [ ] 本 todo10.md エントリを削除 + +#### 完了基準 + +- testing.md に新 sub-section が追加され、PR #200 F-3 fix が参照例として cite される +- sentinel pattern と相補的な独立パターンとして区別が明示される +- 派生プロジェクトでも同 rule が global 配下から自動波及 + +#### 詰まっている箇所 + +なし。Effort XS、global rules への docs 追記のみ。 + +--- + ## 既知課題 (記録のみ、本セッションで未対応) (現時点で本ファイルへの既知課題は無し。docs/todo9.md 末尾を参照。) diff --git a/docs/todo9.md b/docs/todo9.md index 6c554b39..74d59a29 100644 --- a/docs/todo9.md +++ b/docs/todo9.md @@ -18,56 +18,6 @@ --- -### Secret detection PreToolUse hook 追加 — AWS/OpenAI/GitHub token 等の hardcoded secret 検出 (PR #172 仕組み化方針切替由来、`security.md` § Secret Management 移管) - -> **動機**: `~/.claude/rules/common/security.md` § Secret Management の「NEVER hardcode secrets in source code」は現在 rule docs 記載のみで機械強制なし。session 毎に security.md を読み込まないと AI が rule を解釈しない構造的脆弱性が残る。PreToolUse hook で Edit/Write 時に AWS key / OpenAI key / GitHub token 等の regex 検出を行い、即 block + feedback を返すことで漏洩を構造的に防止する (ユーザー判断 2026-05-25 = PreToolUse hook 方式採用)。 -> -> **本タスクの位置づけ**: 既存ルール仕組み化バンドルの第 1 件。順位 144 (`jj-message-required`) と同型実装パターン。`feedback_pipeline_over_rules.md` 適用 = パイプライン側機械的修正で Claude 判断介入を排除。 -> -> **参照**: `~/.claude/rules/common/security.md` § Secret Management、`src/hooks-pre-tool-validate/src/main.rs` (`preset_jj_message_required` を template に追加)、`.claude/hooks-config.toml`、PR #172 (順位 144 hook 化 dogfood) -> -> **実行優先度**: 🚀 **Tier 1** — Effort M。security-critical かつ漏洩観測前の preventive 層。 - -#### 設計決定 (案) - -- **配置**: `src/hooks-pre-tool-validate/src/main.rs` に新 preset `secret-detection` 追加 -- **検出対象 regex** (高頻度 secret pattern): - - AWS Access Key: `AKIA[0-9A-Z]{16}` - - AWS Secret Key: `aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}` - - OpenAI API Key: `sk-[A-Za-z0-9]{20,}` (現 sk-proj 系を含む形式) - - GitHub Personal Access Token: `ghp_[A-Za-z0-9]{36}` / `github_pat_[A-Za-z0-9_]{20,}` - - GitHub OAuth Token: `gho_[A-Za-z0-9]{36}` / `ghs_[A-Za-z0-9]{36}` - - Anthropic API Key: `sk-ant-[A-Za-z0-9_-]{20,}` - - 汎用高エントロピー string (要 false positive 評価): `[A-Za-z0-9+/]{40,}={0,2}` (base64-like) は対象外とする (汎用過ぎる) -- **exception field 不使用**: secret pattern に正当な使用例はない (test fixture は dummy で十分) -- **block message**: 「機密情報が検出されました。環境変数 / secret manager に移管してください」+ 検出 pattern type -- **hooks-config.toml**: `blocked_patterns` に `"secret-detection"` 追加 (opt-in 設計だが Tier 1 のため default 推奨) - -#### 作業計画 (順位 144 と同 phase 構造) - -- [ ] Phase 1: `preset_secret_detection()` 関数を実装 (6-8 種の BlockedPattern を vec で返す) -- [ ] Phase 2: `build_blocked_patterns` の `resolve_preset_or_custom` dispatch に登録 + `.claude/hooks-config.toml` の `blocked_patterns` に追加 + コメント section 説明追加 -- [ ] Phase 3: test 拡充 — block ケース (6+ 種類の secret pattern) × allow ケース (regular code) × non-regression -- [ ] Phase 4: `pnpm build:hooks-pre-tool-validate` で exe deploy + dogfood (dummy AWS key 等で block 動作確認) -- [ ] Phase 5: `pnpm push` + `pnpm create-pr` -- [ ] post-merge: 派生プロジェクト deploy + `~/.claude/rules/common/security.md` § Secret Management の hook 化記述追加 (rule docs 縮小は別 follow-up) -- [ ] 本エントリ削除 + todo-summary.md 行削除 - -#### 完了基準 - -- 6+ 種類の高頻度 secret pattern が Edit/Write 時に block される -- regular code (variable name "key" / "secret" の使用、test fixture の dummy "AKIATEST...") は通過 -- 既存 preset と non-regression -- `cargo test -p hooks-pre-tool-validate` pass -- security.md § Secret Management から具体 pattern 列挙を hook block message に移管 (docs 縮小) - -#### 詰まっている箇所 - -- false positive リスク: API key 形式の文字列が test fixture / 説明文に登場する可能性。test fixture は paths filter 除外で対応 (順位 150 magic number lint と同 pattern) -- pattern 漏れ: 検出対象 6-8 種類は主要のみ。Anthropic API key 形式変更 / 新 service token 追加時は手動更新が必要 (feedback loop) - ---- - ### File length lint (800 行 max) 追加 — `coding-style.md` § File Organization 移管 (PR #172 仕組み化方針切替由来) > **動機**: `~/.claude/rules/common/coding-style.md` § File Organization の「200-400 lines typical, 800 max per file」ガイドラインは現在 rule docs 記載のみで、機械強制されていない。順位 48 (関数長 50 行) は `hooks-post-tool-comment-lint-rust` で touch-trigger ratchet 方式により既に機械強制済の前例があり、ファイルサイズも同 pattern で実装可能。session 毎の rule load コスト削減 + 800 行突破時の編集時即 block を実現する。 diff --git a/src/hooks-pre-tool-validate/src/main.rs b/src/hooks-pre-tool-validate/src/main.rs index 32348e25..d8cf4129 100644 --- a/src/hooks-pre-tool-validate/src/main.rs +++ b/src/hooks-pre-tool-validate/src/main.rs @@ -475,6 +475,69 @@ jj split -m "" # commit 分離 }] } +/// プリセット: secret-detection (AWS / OpenAI / GitHub / Anthropic 等の hardcoded secret 検出) +/// +/// 順位 146 (PR #200 follow-up、`~/.claude/rules/common/security.md` § Secret Management 移管): +/// 「NEVER hardcode secrets in source code」を機械強制する mechanical enforcement 層。 +/// session 毎の rule load コスト排除 + 漏洩観測前の preventive 層として Tier 1 採用。 +/// memory `feedback_pipeline_over_rules.md` 適用 = パイプライン側機械的修正で +/// Claude 判断介入を排除、session 毎の rule load コスト不要。 +/// +/// 設計判断 (順位 146、PR #200 follow-up): +/// - Bash command + Edit/Write の new_string/content の両方をスキャン (handle_write_edit_tool で呼び出し) +/// - false positive 軽減: AWS Secret Key は env-var-assignment 形式 (`aws_secret_access_key = "..."`) に限定 +/// - OpenAI `sk-` 系は Anthropic の `sk-ant-` を `exception` field で除外 (Rust regex は negative lookahead 非対応) +/// - 漏洩の非対称性 (= 1 度漏れたら手遅れ) のため `default_preset_names()` に含め、config 不在環境でも default-on +fn preset_secret_detection() -> Vec { + vec![ + BlockedPattern { + pattern: Regex::new(r"\bAKIA[0-9A-Z]{16}\b").unwrap(), + exception: None, + message: SECRET_DETECTION_MSG, + }, + BlockedPattern { + pattern: Regex::new( + r#"(?i)aws_secret_access_key\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}["']?"#, + ) + .unwrap(), + exception: None, + message: SECRET_DETECTION_MSG, + }, + BlockedPattern { + pattern: Regex::new(r"\bsk-[A-Za-z0-9_-]{40,}\b").unwrap(), + exception: Some(Regex::new(r"\bsk-ant-").unwrap()), + message: SECRET_DETECTION_MSG, + }, + BlockedPattern { + pattern: Regex::new(r"\b(ghp|github_pat)_[A-Za-z0-9_]{20,}\b").unwrap(), + exception: None, + message: SECRET_DETECTION_MSG, + }, + BlockedPattern { + pattern: Regex::new(r"\b(gho|ghs|ghu|ghr)_[A-Za-z0-9]{36}\b").unwrap(), + exception: None, + message: SECRET_DETECTION_MSG, + }, + BlockedPattern { + pattern: Regex::new(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b").unwrap(), + exception: None, + message: SECRET_DETECTION_MSG, + }, + ] +} + +const SECRET_DETECTION_MSG: &str = r#"**機密情報 (secret) が検出されました** + +ハードコードされた API key / token / credential を検出しました。漏洩は重大なセキュリティ事故に繋がり、git history から完全除去するには force push が必要になります。 + +**対応方法:** +- 環境変数に移管: Rust なら `std::env::var("API_KEY")`、Node.js なら `process.env.API_KEY` +- Secret manager (1Password / Doppler / AWS Secrets Manager / GitHub Actions Secrets 等) を使用 +- `.env` ファイル + `.gitignore` で local-only 管理 (本番は別途) +- test fixture でも、regex に match する形式 (16 chars 以上の AKIA... 等) は避け、`AKIATEST` 等の明らかに無効な短い形を使う + +設計判断 (順位 146、PR #200 follow-up): `~/.claude/rules/common/security.md` § Secret Management の機械強制層。"#; + fn default_preset_names() -> Vec { vec![ "default".to_string(), @@ -483,6 +546,7 @@ fn default_preset_names() -> Vec { "jj-main-guard".to_string(), "jj-push-guard".to_string(), "electron".to_string(), + "secret-detection".to_string(), ] } @@ -496,6 +560,7 @@ fn resolve_preset_or_custom(name: &str) -> Vec { "gh-pr-create-guard" => preset_gh_pr_create_guard(), "gh-pr-merge-guard" => preset_gh_pr_merge_guard(), "jj-message-required" => preset_jj_message_required(), + "secret-detection" => preset_secret_detection(), "polling-anti-pattern" => preset_polling_anti_pattern(), "exe-help-block" => preset_exe_help_block(), "electron" => preset_electron(), @@ -888,6 +953,30 @@ fn collect_text_for_keywords(tool_input: &ToolInput) -> String { parts.join("\n") } +/// Edit/Write 時の secret scan 対象テキスト (new_string + content のみ、old_string は除外)。 +/// 順位 146 (PR #200 follow-up): old_string は「既存ファイル内の文字列 = 削除対象 or 置換元」 +/// であり、ここを scan すると「secret を削除する Edit」までも block してしまうため除外する。 +fn collect_text_for_secret_scan(tool_input: &ToolInput) -> String { + let mut parts = Vec::new(); + if let Some(new_s) = &tool_input.new_string { + parts.push(new_s.as_str()); + } + if let Some(content) = &tool_input.content { + parts.push(content.as_str()); + } + parts.join("\n") +} + +fn is_secret_detection_enabled(config: &Config) -> bool { + let preset_names: Vec = config + .pre_tool_validate + .as_ref() + .and_then(|c| c.blocked_patterns.as_ref()) + .cloned() + .unwrap_or_else(default_preset_names); + preset_names.iter().any(|n| n == "secret-detection") +} + fn read_hook_input() -> Result { let mut input = String::new(); if let Err(e) = io::stdin().read_to_string(&mut input) { @@ -913,48 +1002,81 @@ fn handle_bash_tool(config: &Config, tool_input: &ToolInput) -> ExitCode { ExitCode::SUCCESS } -fn handle_write_edit_tool(config: &Config, tool_input: &ToolInput) -> ExitCode { - let file_path = tool_input +fn resolve_edit_file_path(tool_input: &ToolInput) -> String { + tool_input .file_path .clone() .filter(|s| !s.is_empty()) .or_else(|| tool_input.path.clone()) - .unwrap_or_default(); + .unwrap_or_default() +} +fn check_protected_file(config: &Config, file_path: &str) -> Option { let extra_protected = config .pre_tool_validate .as_ref() .and_then(|c| c.extra_protected_files.as_ref()) .cloned() .unwrap_or_default(); + if file_path.is_empty() || !is_protected_config(file_path, &extra_protected) { + return None; + } + let msg = format!( + "**保護されたファイルの編集がブロックされました**\n\n\ + `{}` は保護対象ファイル(設定ファイル/機密ファイル)のため、編集が禁止されています。\n\n\ + リンター設定の場合: 設定を変更するのではなく **コード側を修正** してください。\n\ + 機密ファイルの場合: 秘密情報の漏洩を防ぐため、編集できません。\n\n\ + 変更が本当に必要な場合は、ユーザーに確認を取ってください。", + file_path.rsplit(['/', '\\']).next().unwrap_or(file_path) + ); + let _ = io::stderr().write_all(msg.as_bytes()); + Some(ExitCode::from(2)) +} - if !file_path.is_empty() && is_protected_config(&file_path, &extra_protected) { - let msg = format!( - "**保護されたファイルの編集がブロックされました**\n\n\ - `{}` は保護対象ファイル(設定ファイル/機密ファイル)のため、編集が禁止されています。\n\n\ - リンター設定の場合: 設定を変更するのではなく **コード側を修正** してください。\n\ - 機密ファイルの場合: 秘密情報の漏洩を防ぐため、編集できません。\n\n\ - 変更が本当に必要な場合は、ユーザーに確認を取ってください。", - file_path.rsplit(['/', '\\']).next().unwrap_or(&file_path) - ); - let _ = io::stderr().write_all(msg.as_bytes()); - return ExitCode::from(2); +fn check_secret_in_content(config: &Config, tool_input: &ToolInput) -> Option { + if !is_secret_detection_enabled(config) { + return None; } + let scan_text = collect_text_for_secret_scan(tool_input); + if scan_text.is_empty() { + return None; + } + let secret_patterns = preset_secret_detection(); + let message = validate_command(&scan_text, &secret_patterns)?; + let _ = io::stderr().write_all(message.as_bytes()); + Some(ExitCode::from(2)) +} - if let Some(staleness_config) = config +fn check_todo_staleness_for_edit( + config: &Config, + tool_input: &ToolInput, + file_path: &str, +) -> Option { + let staleness_config = config .pre_tool_validate .as_ref() - .and_then(|c| c.todo_staleness.as_ref()) - { - let text = collect_text_for_keywords(tool_input); - if let Some(result) = check_todo_staleness(&file_path, &text, staleness_config) { - let _ = io::stderr().write_all(result.message.as_bytes()); - if result.stale { - return ExitCode::from(2); - } - } + .and_then(|c| c.todo_staleness.as_ref())?; + let text = collect_text_for_keywords(tool_input); + let result = check_todo_staleness(file_path, &text, staleness_config)?; + let _ = io::stderr().write_all(result.message.as_bytes()); + if result.stale { + Some(ExitCode::from(2)) + } else { + None } +} +fn handle_write_edit_tool(config: &Config, tool_input: &ToolInput) -> ExitCode { + let file_path = resolve_edit_file_path(tool_input); + if let Some(code) = check_protected_file(config, &file_path) { + return code; + } + if let Some(code) = check_secret_in_content(config, tool_input) { + return code; + } + if let Some(code) = check_todo_staleness_for_edit(config, tool_input, &file_path) { + return code; + } ExitCode::SUCCESS } @@ -1160,7 +1282,198 @@ mod tests { ); } + const SECRET_DETECT: &[&str] = &["secret-detection"]; + + #[test] + fn secret_detection_blocks_aws_access_key() { + assert!(is_blocked_with( + "let aws = \"AKIAIOSFODNN7EXAMPLE\";", + SECRET_DETECT + )); + } + + #[test] + fn secret_detection_blocks_aws_secret_assignment() { + assert!(is_blocked_with( + r#"aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY""#, + SECRET_DETECT + )); + } + + #[test] + fn secret_detection_blocks_openai_api_key() { + assert!(is_blocked_with( + "const key = \"sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX_-\";", + SECRET_DETECT + )); + } + + #[test] + fn secret_detection_blocks_github_pat_classic() { + assert!(is_blocked_with( + "let token = \"ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ\";", + SECRET_DETECT + )); + } + + #[test] + fn secret_detection_blocks_github_pat_finegrained() { + assert!(is_blocked_with( + "let token = \"github_pat_11AAAAAAA0abcdefghijK\";", + SECRET_DETECT + )); + } + + #[test] + fn secret_detection_blocks_github_oauth_token() { + assert!(is_blocked_with( + "let token = \"gho_abcdefghijklmnopqrstuvwxyz0123456789\";", + SECRET_DETECT + )); + } + + #[test] + fn secret_detection_blocks_github_server_token() { + assert!(is_blocked_with( + "let token = \"ghs_abcdefghijklmnopqrstuvwxyz0123456789\";", + SECRET_DETECT + )); + } + + #[test] + fn secret_detection_blocks_anthropic_api_key() { + assert!(is_blocked_with( + "let key = \"sk-ant-api03-AAAAAAAA_BBBBBBBB_CCCCCCCC\";", + SECRET_DETECT + )); + } + + #[test] + fn secret_detection_blocks_in_bash_command_via_echo() { + assert!(is_blocked_with( + "echo \"AKIAIOSFODNN7EXAMPLE\" > .env", + SECRET_DETECT + )); + } + + #[test] + fn secret_detection_allows_short_test_fixture_value_below_threshold() { + assert!(!is_blocked_with("let key = \"AKIATEST\";", SECRET_DETECT)); + } + + #[test] + fn secret_detection_allows_short_sk_prefix_below_threshold() { + assert!(!is_blocked_with("let x = \"sk-test\";", SECRET_DETECT)); + } + + #[test] + fn secret_detection_allows_short_ghp_prefix_below_threshold() { + assert!(!is_blocked_with("let x = \"ghp_short\";", SECRET_DETECT)); + } + + #[test] + fn secret_detection_allows_variable_name_secret_or_key() { + assert!(!is_blocked_with( + "let api_key = config.api_key;", + SECRET_DETECT + )); + assert!(!is_blocked_with("self.secret = None;", SECRET_DETECT)); + } + + #[test] + fn secret_detection_allows_env_var_reference() { + assert!(!is_blocked_with( + "std::env::var(\"AWS_SECRET_ACCESS_KEY\")", + SECRET_DETECT + )); + assert!(!is_blocked_with("process.env.GITHUB_TOKEN", SECRET_DETECT)); + } + + #[test] + fn secret_detection_aws_secret_pattern_requires_assignment_form_for_fp_reduction() { + assert!(!is_blocked_with( + "let blob = \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\";", + SECRET_DETECT + )); + } + + #[test] + fn secret_detection_in_default_fallback_is_default_on_security_critical() { + let patterns = build_blocked_patterns(&Config::default()); + assert!( + validate_command("let k = \"AKIAIOSFODNN7EXAMPLE\";", &patterns).is_some(), + "default fallback should include secret-detection (Tier 1 security-critical default-on, 漏洩の非対称性のため)" + ); + } + + #[test] + fn is_secret_detection_enabled_returns_true_when_listed_in_blocked_patterns() { + let config = Config { + pre_tool_validate: Some(PreToolValidateConfig { + blocked_patterns: Some(vec!["secret-detection".to_string()]), + extra_protected_files: None, + todo_staleness: None, + }), + }; + assert!(is_secret_detection_enabled(&config)); + } + #[test] + fn is_secret_detection_enabled_returns_false_when_excluded_from_blocked_patterns() { + let config = Config { + pre_tool_validate: Some(PreToolValidateConfig { + blocked_patterns: Some(vec!["default".to_string(), "git".to_string()]), + extra_protected_files: None, + todo_staleness: None, + }), + }; + assert!(!is_secret_detection_enabled(&config)); + } + + #[test] + fn is_secret_detection_enabled_returns_true_for_default_config_default_on() { + assert!(is_secret_detection_enabled(&Config::default())); + } + + #[test] + fn collect_text_for_secret_scan_excludes_old_string_to_allow_secret_removal() { + let tool_input = ToolInput { + command: None, + file_path: Some("foo.rs".to_string()), + path: None, + old_string: Some("AKIAIOSFODNN7EXAMPLE".to_string()), + new_string: Some("AKIATEST".to_string()), + content: None, + }; + let scanned = collect_text_for_secret_scan(&tool_input); + assert!(!scanned.contains("AKIAIOSFODNN7EXAMPLE")); + assert!(scanned.contains("AKIATEST")); + } + + #[test] + fn collect_text_for_secret_scan_includes_both_new_string_and_content() { + let tool_input = ToolInput { + command: None, + file_path: Some("foo.rs".to_string()), + path: None, + old_string: None, + new_string: Some("new-text".to_string()), + content: Some("full-content".to_string()), + }; + let scanned = collect_text_for_secret_scan(&tool_input); + assert!(scanned.contains("new-text")); + assert!(scanned.contains("full-content")); + } + + #[test] + fn secret_detection_does_not_affect_other_presets_non_regression() { + assert!(is_blocked_with("git push", &["git", "secret-detection"])); + assert!(is_blocked_with( + "rm -rf /tmp", + &["default", "secret-detection"] + )); + assert!(!is_blocked_with("git status", &["default", "secret-detection"])); + } #[test] fn blocks_git_at_start() {