From c021583bdc036b174ab83c45dd9191ad0740b57e Mon Sep 17 00:00:00 2001 From: owa <20108042+owayo@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:42:12 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore(deps):=20thiserror=E3=81=AE=E3=83=91?= =?UTF-8?q?=E3=83=83=E3=83=81=E3=83=90=E3=83=BC=E3=82=B8=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依存関係の更新ツールで公開後の経過期間と脆弱性情報を確認し、 thiserrorの要求バージョンを2.0.20から2.0.21へ更新する。 メジャー更新とRustツールチェーンの変更はない。 Cargo.lockは既に2.0.21を解決しているため変更しない。 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e60e099..b2784bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ chrono = "0.4.45" logroller = "0.1.12" dirs = "7.0.0" regex = "1.13.1" -thiserror = "2.0.20" +thiserror = "2.0.21" anyhow = "1.0.104" [dependencies.tree-sitter] From c2ef11ca3002652d9c40628ea627038a8ea04023 Mon Sep 17 00:00:00 2001 From: owa <20108042+owayo@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:42:19 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E5=87=BA=E5=8A=9B=E5=9C=A7=E7=B8=AE?= =?UTF-8?q?=E3=81=A7=E3=82=BD=E3=83=BC=E3=82=B9=E5=86=85=E3=81=AE=E7=B5=B6?= =?UTF-8?q?=E5=AF=BE=E3=83=91=E3=82=B9=E3=82=92=E4=BF=9D=E6=8C=81=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 診断ヘッダー用の共通パス短縮がソース抜粋にも適用され、 cp /work/project/input /work/project/outputをcp input outputへ 書き換えていた。formatterの追加・削除行と文脈行でも同じ問題を再現した。 行番号付きソース抜粋と差分行をパスの抽出・置換の両方から除外し、 診断情報だけを短縮する。unified diffの文脈判定は正規化とパス短縮で 共有し、異なる判定経路によるコードの破壊を防ぐ。 ソース抜粋、unified diff、共通接頭辞の算出範囲、移行処理の進捗表示を 検証する4件のテストを追加した。修正前はパス保持の2件が失敗し、 修正後は出力正規化の243件すべてが成功する。 英語・日本語のREADMEとエージェント向け規約を更新する。 Hooks仕様の確認に基づき、FileChangedの再発火によるループを避けるため 保存後のformatterはPostToolUseへ登録することを統合ドキュメントに補足する。 --- AGENTS.md | 2 + README.ja.md | 2 +- README.md | 2 +- docs/integrations.ja.md | 2 + docs/integrations.md | 2 + src/domain/normalize.rs | 151 ++++++++++++++++++++++++++++------------ 6 files changed, 116 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 105a260..02b8970 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,8 @@ Instructions for AI coding agents (Claude Code, Cursor, Windsurf, Codex, Grok, G ソース抜粋では空白可視化のインデントだけを圧縮し、コード中の記号列を保持する。`[tool] ` が付いた先頭行もソース・差分として判定し、空白やリテラルを書き換えない。 + パスの共通接頭辞を短縮するのは診断情報だけとする。行番号付きソース抜粋、追加・削除行、`Diff in ...` / `@@ ...` に続く文脈行では、絶対パスを元のまま保持する。接頭辞の算出からもこれらの行を除外する。差分の文脈判定は正規化とパス短縮で共有し、一方だけがコードを書き換える状態を作らない。 + 8. **Fail-Closed Security**: Block on parse errors, empty input, or missing/blank/incorrectly typed required agent fields. Required-field validation is deliberately scoped to the fields claw-hooks actually reads for a decision (event name, tool name/input, `tool_input.command` for Bash, the patch body for `apply_patch`, `stop_hook_active` for Stop). The scope is **per (tool, event) pair**, not per tool: a shell tool's `command` is the input to the dangerous-command check and is always required, but a file-editing tool's path is only read by the post-save hooks, so it is required on `PostToolUse` and not on `PreToolUse`. No filter looks at file input before execution — the built-in and custom filters are gated on `is_shell_tool()`, and the extension hook returns false immediately for any event other than `AfterFileEdit` — so requiring `file_path` on `PreToolUse` meant rejecting a write over a field that would never be consulted, and on Claude a `PreToolUse` rejection is an immediate hard block. Metadata that no filter consults (`cwd`, `model`, `transcript_path`, `turn_id`, `permission_mode`, `tool_use_id`, `tool_response`, …) is not required, because the agent specs present those as the fields you *usually* receive rather than a strict schema (the Codex docs' own `SessionEnd` example payload omits `model`), and requiring them would let a single omitted field fail every hook call closed and take the whole tool offline. Out-of-scope pass-through events are not validated at all — validating an event whose decision is discarded has no security upside and can produce a harmful spurious block (a Codex `UserPromptSubmit` deny rejects the user's prompt; a Codex `PostToolUse` deny *replaces the real tool output* with the hook message, so `PostToolUse` is only strictly validated for file-editing tools). Non-empty unknown/unsupported event names are still passed through as allow to keep claw-hooks within its intentional scope. **Only the events claw-hooks actually gates before execution fail closed**: `blocks_would_loop_or_be_ignored` is an allowlist keyed on the event name, and everything outside it (`PreToolUse` on Claude/Codex/Antigravity/Grok, `PermissionRequest` on Codex, `beforeShellExecution`/`preToolUse` on Cursor, `pre_run_command` on Windsurf) resolves to a neutral allow. Two independent reasons drive this. First, **Stop-family events**: on Claude/Codex a Stop `decision:"block"`, on Antigravity `decision:"continue"`, and on Cursor a `followup_message` all mean "do not stop, here is a new prompt" rather than "reject", so returning one for a malformed payload creates a self-sustaining loop (fail → continue → Stop re-fires → same failure) that no loop guard can break, since `stop_hook_active` / `loop_count` / the cross-process env flag all require a successful parse first. Stop is not a pre-execution gate — allowing the stop creates no new side effects, whereas auto-continuing induces further tool calls. Second, **events claw-hooks never inspects**: denying them has zero security upside and real cost — a Claude/Codex `UserPromptSubmit` deny erases the user's prompt, a Codex `PostToolUse` deny replaces the real tool output with the hook message, a Cursor `beforeReadFile` carries the full file body (so a large file trivially exceeds `MAX_INPUT_BYTES` and the read would be blocked by a tool that has no opinion on reads), and every Windsurf post-hook and every Grok event except `PreToolUse` cannot block at all, so a deny only injects a spurious error. The neutral response is the event-specific stop-allow shape + exit 0 (Antigravity uses `{"decision":"stop"}` for Stop and `{}` elsewhere, since only its Stop output requires `decision`; the other agents use `{}`). **When the event name cannot be determined at all, the guard stays fail-closed and blocks** — a truncated or oversized `PreToolUse` still denies. That guard needs the event name, so it must keep working when the payload cannot be parsed as JSON at all — otherwise the two cases where it is needed most (a truncated write, and an oversized payload, which `read_stdin_bounded` necessarily cuts at `MAX_INPUT_BYTES + 1` and therefore leaves as invalid JSON) fall through to a block and produce exactly the self-sustaining loop described above. `scan_event_name_in_malformed_input` therefore rescans the raw text for the event-name key when `serde_json` fails, and the oversized path passes the truncated input along instead of discarding it. The scan is deliberately narrow: it matches the quoted key exactly, takes the earliest match, reads only the first 4 KiB (agents place the event name near the front of the payload, while a giant `tool_input.command` body sits after it), and requires a closing quote — anything else returns `None` and the caller stays fail-closed. Misjudging in the permissive direction only ever allows a *stop*; `PreToolUse` payloads keep blocking on both truncation and oversize. Relatedly, the fail-closed writer picks its stream by exit code rather than by format: a Stop parse error resolves to the event-specific stop-allow response + exit 0, which is a decision document and belongs on stdout, whereas a genuine exit-2 block on Claude/Windsurf still writes its plain-text reason to stderr where those agents read it. **Configuration and startup failures are fail-closed too**: a TOML syntax error or failed validation used to propagate out of `main` as `exit 1` with empty stdout, which Codex/Antigravity read as "hook failed, ignore its decision" — one typo in `config.toml` silently disabled all command blocking. `HookService::emit_config_error` now reads the payload just far enough to identify the event and returns the agent's native deny shape (or the event-specific stop-allow response for Stop, per above), writing the diagnostic to stderr with a pointer to `claw-hooks check`. Logger initialization failure is non-fatal (warn, continue without logging) for the same reason, `HookService::emit_runtime_error` covers residual internal errors from `run`, and stdin I/O failure goes through the normal fail-closed path instead of `?`. Pathologically deep command nesting or oversized command input is also treated as a block candidate, avoiding recursion-based stack overflow that would otherwise crash and fail open. The pathological-input guard counts brace nesting (`{`/`}`) in addition to parentheses, and the AST walk enforces an explicit traversal-depth limit, so deeply nested command groups (`{ ...;}`) cannot overflow the stack and fail open even under small thread stacks (Windows/containers). Wrapper-chain expansion (`sudo sudo … rm`) is iterative with an explicit recursion bound rather than self-recursive, so a long wrapper chain that slips under the size limit cannot overflow the stack; chains exceeding the bound fail closed. The stdin reader is bounded (4 MiB) so an oversized or runaway hook payload is treated as fail-closed rather than allocating until the process is OOM-killed (which would otherwise drop the safeguard entirely). Stdin is read as raw bytes and decoded with lossy UTF-8 conversion (not `read_to_string`), so a stray invalid-UTF-8 byte cannot abort the process with `exit 1` and an empty stdout — a state Codex/Antigravity interpret as a failed hook (decision ignored = fail-open). Invalid bytes become U+FFFD and the input still flows through the normal parse/detect path: a now-unparseable payload fails closed (block), while a still-parseable command keeps its dangerous-command detection. Hook subprocess stdout/stderr pipes are drained to EOF but retain at most 4 MiB per stream, preventing a noisy formatter/linter from OOM-killing the hook before output normalization and agent-facing truncation. The allowlist in `blocks_would_loop_or_be_ignored` must stay in sync with the event mapping table: `parse_codex_input` maps the legacy alias `BeforeTool` onto the same `HookEvent::BeforeCommand` as `PreToolUse`, but the allowlist named only the latter, so an event declared to be a pre-execution gate resolved to a neutral allow whenever its payload failed to parse — a divergence between the two tables is silently a fail-open, which is why `BeforeTool` now appears in both (and in the branch that picks the recommended `hookSpecificOutput` deny shape). Finally, the **diagnostic text for a configuration or runtime error is withheld when stderr is the decision channel**. `emit_config_error` writes the config file's absolute path and the TOML parse error — which quotes the offending line — to stderr for the user's benefit, but Claude and Windsurf read stderr as the block reason on a non-zero exit, so both were handed straight to the agent; the diagnostic is now emitted only when `fail_closed_uses_stderr` is false, and the agent-facing text stays the fixed sentence that points at `claw-hooks check`. 9. **Process Group Isolation (Unix)**: Hook subprocesses are placed in their own process group via `setpgid`, so timeouts kill the whole tree (`killpg`) — preventing grandchild leaks like `sh -c 'sleep ...'` after the shell is signaled. If the direct child exits but a background grandchild still keeps stdout/stderr pipes open (for example `sh -c 'sleep 60 &'`), the pipe wait is also treated as a timeout and the process group is killed. After a normal child exit, the reader-thread join uses a short post-exit drain grace rather than reusing the original timeout deadline, so a command that completes right at the deadline still has its buffered output collected instead of being mis-reported as a timeout (which would otherwise return a spurious block to the agent for `report=true` stop hooks); a grandchild that keeps the pipe open past the grace still triggers the process-group kill. 10. **Debug Log Safety**: Debug logs store hook event summaries (event/tool/session/size) instead of raw hook input or raw parsed `ToolInput`, so tool command text, file contents, unknown event payloads, and agent messages are not persisted to disk. Per-agent parsed-input debug lines log byte counts and booleans (`command_bytes`, `file_path_bytes`, `has_cwd`) rather than raw command/file-path/cwd values, and the formatted hook output is logged as a decision + size summary; the full output body (which may include lint diagnostics quoting source lines) is available only via `--trace`, which writes to stderr and is not persisted. Extension-hook logs record the executable basename with arg-count / inline-template booleans and the resolved file path byte length instead of the expanded command line — executable directories, the actual file path and rendered command (which can contain user-directory hierarchies or source-quoting lint snippets) are kept off disk. Extension-hook failure logs and timeout messages use the same sanitized command label — but that label is **log-only**: the string handed to `run_with_timeout` is embedded verbatim in the timeout body, which reaches the agent as `additionalContext`, so it carries only the configured program name (matching the stop-hook path). Subagent notification logs record `type_bytes=` rather than the agent name, in both the filter and the per-agent parsed-input lines: subagent names routinely contain internal project and team names, and `log_sanitizer` already suppresses the same field (its unit test pins that the name never appears in a log summary), so leaving it in an adjacent `debug!` defeated the rule. The hook-input summarizer recognises every agent's field naming — Grok's camelCase (`hookEventName` / `toolName` / `sessionId`) and Antigravity's `toolCall.name` (it has no event-name field at all) — because without them those two agents logged nothing but `event= tool=- session_id=-`, which defeats the only purpose this log still has. Stop-hook queue/execution/completion logs likewise use only the executable basename and argument count; command arguments and executable directories are never persisted. Stop-hook output logging records only the byte size of stdout/stderr per hook; the full diagnostic body is still relayed to the agent via the block `reason`/`followup_message`, but it is never persisted to disk. diff --git a/README.ja.md b/README.ja.md index 88bcc2b..ccf871a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -48,7 +48,7 @@ claw-hooks は、Claude Code・Cursor・Windsurf・Antigravity CLI・Codex CLI - **Stop時プロジェクト全体Lint**: プロジェクト構成ファイル(`Cargo.toml`、`tsconfig.json` 等)を自動検出して lint/typecheck を実行。失敗はエージェントに返却(Windsurf と Grok CLI はベストエフォート) - **フックタイムアウト**: フックごとに設定可能(デフォルト 60 秒)。Unix ではプロセスグループ全体を SIGKILL するため、`sh -c '...'` 経由の孫プロセスも残らず停止 - **出力長制限**: エージェントのコンテキスト溢れを防ぐマルチバイト安全な切り詰め(デフォルト 1000 文字) -- **出力圧縮**: 装飾文字の連続(`.`、`=`、`-`、`─`、`━`、`^`、`·`、`→`、`_`)、`\r` で上書きされる進捗バー、cargo の繰り返し `Compiling`/`Blocking` ログ、共通絶対パスのプレフィックス、rustc/ruff/biome のマルチライン span 下線や枠線、Biome の空白可視化マーカーや重複行番号ペアを圧縮。成功時の `All checks passed!` や `1 file already formatted` など、何も変更していない formatter/linter の定型通知は省略し、ファイル変更・失敗の出力は保持。no-op 判定は**正規化後**の文字列で行うため、成功メッセージと毎回同じ設定警告を同時に出すツール(`ruff check --select D…` は毎回 stderr にルールセット非互換警告を出す)でも no-op と判定され、編集のたびに `All checks passed!` だけが返る事象を防ぐ。biome の `Checked N file(s) in <時間>. No fixes applied.` 集計行と、締めの `check ━` / `× Some errors were emitted while running checks.` は診断が併記されているときのみ除去し、出力全体がそれだけの場合は保持。ANSI 除去は `ESC` + 中間バイト形式(terminfo の `sgr0`、例: `\E(B\E[m`)と生の `SO`/`SI` にも対応(未対応だと色付き `cargo fmt --check` の差分行の先頭に文字が残り、上記の圧縮が一切効かなくなる) ソース抜粋と unified diff では、フォーマッター名が付いた先頭行も含めてコードの空白・文字列内の記号列を保持し、Biome のインデント装飾は引き続き圧縮する。 +- **出力圧縮**: 装飾文字の連続(`.`、`=`、`-`、`─`、`━`、`^`、`·`、`→`、`_`)、`\r` で上書きされる進捗バー、cargo の繰り返し `Compiling`/`Blocking` ログ、共通絶対パスのプレフィックス、rustc/ruff/biome のマルチライン span 下線や枠線、Biome の空白可視化マーカーや重複行番号ペアを圧縮。成功時の `All checks passed!` や `1 file already formatted` など、何も変更していない formatter/linter の定型通知は省略し、ファイル変更・失敗の出力は保持。no-op 判定は**正規化後**の文字列で行うため、成功メッセージと毎回同じ設定警告を同時に出すツール(`ruff check --select D…` は毎回 stderr にルールセット非互換警告を出す)でも no-op と判定され、編集のたびに `All checks passed!` だけが返る事象を防ぐ。biome の `Checked N file(s) in <時間>. No fixes applied.` 集計行と、締めの `check ━` / `× Some errors were emitted while running checks.` は診断が併記されているときのみ除去し、出力全体がそれだけの場合は保持。ANSI 除去は `ESC` + 中間バイト形式(terminfo の `sgr0`、例: `\E(B\E[m`)と生の `SO`/`SI` にも対応(未対応だと色付き `cargo fmt --check` の差分行の先頭に文字が残り、上記の圧縮が一切効かなくなる)。ソース抜粋と unified diff では、フォーマッター名が付いた先頭行も含めてコードの空白・絶対パス・文字列内の記号列を保持し、Biome のインデント装飾は引き続き圧縮する。 - **ソース抜粋の再掲除去**: 同一診断の中で逐語一致するソース抜粋行(`3 │ code`、`> 3 │ code`、`12 | code`)は2回目以降を除去。biome は 1 件の診断をサブブロック(`!` メッセージ、`i` 補足、`i Safe fix:`)ごとに分けて同じ抜粋を再掲し、ruff も修正差分の中でコンテキストを再掲するが、これらの再掲には情報量が無い。差分行(`- old` / `+ new`)は修正内容そのものなので保持する。実出力での計測値: ruff −6%、biome −14% - **診断をまたぐ抜粋の再掲除去**: 同じ箇所を指す診断が連続すると、抜粋が**まるごと**毎回再掲される(1 つの関数定義に `ANN201` / `D103` / `ANN001` / `ANN001`、1 つの `let` に `useConst` / `noUnusedVariables` が付くケース)。直前の診断と逐語一致する抜粋はブロックごと除去する。各診断のヘッダは残るのでファイル・行・列は失われず、間に別の抜粋を挟む診断は自分の抜粋を保持する。実出力での計測値: さらに ruff −15%、biome −8%。既定の 1000 文字の上限が同じコードの再掲で埋まって後続の診断が切り捨てられていたため、これはトークン量だけでなく**エージェントに届く情報量**を増やす - **デバッグログ安全性**: 永続化するのはイベント/ツール/セッション、実行ファイルの basename、引数数、バイト数サマリーのみ。Stop/拡張子フックの引数と実行ファイルのディレクトリを除去し、生コマンド、ファイル本文、エージェントメッセージ、整形済み formatter/linter 出力はディスクに残さない(本文確認は `--trace` の stderr 経由のみ) diff --git a/README.md b/README.md index 5eb21a1..d5510b5 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ claw-hooks is a single binary that plugs into the hook systems of Claude Code, C - **Project-wide Lint on Stop**: Auto-detect project type (`Cargo.toml`, `tsconfig.json`, etc.) and run lint/typecheck; failures are surfaced back to the agent (Windsurf and Grok CLI are best-effort) - **Hook Timeout**: Configurable per-hook timeout (default 60s). On Unix the whole process group is SIGKILL'd, so grandchildren of `sh -c '...'` cannot leak past the deadline - **Output Truncation**: Multi-byte-safe truncation of hook output (default 1000 chars) to protect the agent's context window -- **Output Compression**: Collapses decorative runs (`.`, `=`, `-`, `─`, `━`, `^`, `·`, `→`, `_`), `\r`-overwriting progress bars, repeated cargo `Compiling`/`Blocking` lines, common absolute-path prefixes, rustc/ruff/biome span underlines and frame characters, and Biome's whitespace markers / duplicate diff line-number pairs. Successful no-op formatter/linter notices such as `All checks passed!` and `1 file already formatted` are omitted, while changed-file and failure output is preserved. The no-op test runs on the *normalized* text, so a tool that pairs a success line with per-run config warnings (e.g. `ruff check --select D…`, which writes ruleset-incompatibility warnings to stderr on every run) is still recognised as a no-op instead of returning a bare `All checks passed!` after every edit. Biome's `Checked N file(s) in . No fixes applied.` counter and its closing `check ━` / `× Some errors were emitted while running checks.` block are dropped when diagnostics accompany them, and kept when they are the whole output. ANSI stripping also covers the general `ESC` + intermediate-byte escape form (terminfo's `sgr0`, e.g. `\E(B\E[m`) and bare `SO`/`SI`, which otherwise leak a stray character onto every colored `cargo fmt --check` diff line and defeat all the rules above Source excerpts and unified diffs retain code whitespace and literal symbol sequences, including the first line labeled with the formatter name; Biome indentation decorations still compress. +- **Output Compression**: Collapses decorative runs (`.`, `=`, `-`, `─`, `━`, `^`, `·`, `→`, `_`), `\r`-overwriting progress bars, repeated cargo `Compiling`/`Blocking` lines, common absolute-path prefixes, rustc/ruff/biome span underlines and frame characters, and Biome's whitespace markers / duplicate diff line-number pairs. Successful no-op formatter/linter notices such as `All checks passed!` and `1 file already formatted` are omitted, while changed-file and failure output is preserved. The no-op test runs on the *normalized* text, so a tool that pairs a success line with per-run config warnings (e.g. `ruff check --select D…`, which writes ruleset-incompatibility warnings to stderr on every run) is still recognised as a no-op instead of returning a bare `All checks passed!` after every edit. Biome's `Checked N file(s) in . No fixes applied.` counter and its closing `check ━` / `× Some errors were emitted while running checks.` block are dropped when diagnostics accompany them, and kept when they are the whole output. ANSI stripping also covers the general `ESC` + intermediate-byte escape form (terminfo's `sgr0`, e.g. `\E(B\E[m`) and bare `SO`/`SI`, which otherwise leak a stray character onto every colored `cargo fmt --check` diff line and defeat all the rules above. Source excerpts and unified diffs retain code whitespace, absolute paths, and literal symbol sequences, including the first line labeled with the formatter name; Biome indentation decorations still compress. - **Repeated Source Excerpt Removal**: Within a single diagnostic, source-excerpt lines (`3 │ code`, `> 3 │ code`, `12 | code`) that repeat verbatim are dropped after the first occurrence: biome re-prints the same excerpt once per sub-block (the `!` message, the `i` note, the `i Safe fix:` block) and ruff re-prints context inside its fix diff, and those repeats carry no information. Diff lines (`- old` / `+ new`) survive because they *are* the fix. Measured on real output: ruff −6%, biome −14% - **Cross-Diagnostic Excerpt Removal**: Consecutive diagnostics that point at the same place re-print the *whole* excerpt each time — one function definition draws `ANN201` / `D103` / `ANN001` / `ANN001`, one `let` draws `useConst` / `noUnusedVariables`. When a diagnostic's excerpt is byte-identical to the previous diagnostic's, the whole block is dropped; each diagnostic keeps its own header, so the file, line, and column are never lost, and a diagnostic separated by a different excerpt keeps its own copy. Measured on real output: a further ruff −15%, biome −8%. This directly buys information rather than just tokens, since the default 1000-character cap was otherwise spent re-printing the same code instead of showing the diagnostics that followed - **Debug Log Safety**: Logs persist only event/tool/session metadata, executable basenames, argument counts, and byte-size summaries. Stop/extension hook arguments and executable directories are stripped, so raw commands, file contents, agent messages, and rendered formatter/linter output never reach disk — full output bodies are available only via `--trace` (stderr, non-persistent) diff --git a/docs/integrations.ja.md b/docs/integrations.ja.md index 6f8a573..a7b123e 100644 --- a/docs/integrations.ja.md +++ b/docs/integrations.ja.md @@ -33,6 +33,8 @@ } ``` +保存後のフォーマッターは `PostToolUse` に登録します。Claude Code のファイル監視イベント `FileChanged` は claw-hooks ではパススルーします。フォーマッターの書き換えでも再発火し、設定したコマンドが毎回ファイルを書き直す場合はループになるため、拡張子フックの登録先には使いません。 + ## Cursor `/.cursor/hooks.json`(プロジェクト)または `~/.cursor/hooks.json`(ユーザー)に追加: diff --git a/docs/integrations.md b/docs/integrations.md index b8e6370..f45d49f 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -33,6 +33,8 @@ Add to `~/.claude/settings.json` (user) or `.claude/settings.json` (project): } ``` +Use `PostToolUse` for post-edit formatters. Claude Code also offers the filesystem-watcher event `FileChanged`, which claw-hooks passes through: a formatter rewrite can trigger that event again, and a generic formatter need not be idempotent. Do not register extension hooks under `FileChanged`. + ## Cursor Add to `/.cursor/hooks.json` (project) or `~/.cursor/hooks.json` (user): diff --git a/src/domain/normalize.rs b/src/domain/normalize.rs index b3b82ba..1ba5658 100644 --- a/src/domain/normalize.rs +++ b/src/domain/normalize.rs @@ -186,23 +186,17 @@ pub fn normalize_lint_output(output: &str) -> String { let line = collapse_carriage_return(line); // rustfmt は `Diff in ...`、一般的な unified diff は `@@ ...` の後に // 先頭 1 文字をマーカーとする文脈行を出す。文脈行の空白もコードそのもの。 - let without_label = strip_tool_label_prefix(line); - if without_label.starts_with("Diff in ") || without_label.starts_with("@@ ") { - in_unified_diff = true; - } else if in_unified_diff { - if line.starts_with([' ', '+', '-']) { - if line.trim().is_empty() { - if !prev_blank && !lines.is_empty() { - lines.push(String::new()); - } - prev_blank = true; - } else { - lines.push(line.to_string()); - prev_blank = false; + if is_unified_diff_context_line(line, &mut in_unified_diff) { + if line.trim().is_empty() { + if !prev_blank && !lines.is_empty() { + lines.push(String::new()); } - continue; + prev_blank = true; + } else { + lines.push(line.to_string()); + prev_blank = false; } - in_unified_diff = false; + continue; } let trimmed = line.trim(); @@ -265,6 +259,25 @@ fn is_unified_diff_content_line(line: &str) -> bool { || (line.starts_with('-') && !line.trim_start_matches('-').starts_with('>')) } +/// 差分ヘッダーの後では、空白で始まる文脈行もソースとして扱う。 +/// 正規化とパス短縮で同じ判定を使い、片方だけがコードを書き換えるのを防ぐ。 +fn is_unified_diff_context_line(line: &str, in_unified_diff: &mut bool) -> bool { + let without_label = strip_tool_label_prefix(line); + if without_label.starts_with("Diff in ") || without_label.starts_with("@@ ") { + *in_unified_diff = true; + return false; + } + *in_unified_diff = *in_unified_diff && line.starts_with([' ', '+', '-']); + *in_unified_diff +} + +/// パスを含んでいても変更してはならないソース抜粋・差分行か。 +fn is_source_output_line(line: &str, in_unified_diff: &mut bool) -> bool { + is_unified_diff_context_line(line, in_unified_diff) + || is_unified_diff_content_line(line.trim_start()) + || source_line_body_offset(line.trim_start()).is_some() +} + /// 行番号付きのソース抜粋では、区切りより後の空白をそのまま残す。 fn collapse_whitespace_outside_source_body(line: &str) -> String { match source_line_body_offset(line) { @@ -1151,36 +1164,44 @@ fn strip_common_path_prefix(text: &str) -> String { // 偶然部分文字列として現れた場合(例: `foo/proj/src/...` のような文中の出現)に // 中身を破壊する。`extract_absolute_paths` のパス開始判定と対称な条件を採用する。 let mut result = String::with_capacity(text.len()); - let mut remaining = text; - let mut consumed = 0usize; - while let Some(pos) = remaining.find(prefix) { - result.push_str(&remaining[..pos]); - let abs_pos = consumed + pos; - let after = &remaining[pos + prefix.len()..]; - // 直前文字: 行頭/空白/`-`/`>`/`=` のいずれかがパス開始位置として有効 - let prev = text[..abs_pos].chars().next_back(); - let is_prev_path_start = match prev { - None => true, - Some(c) if c.is_ascii_whitespace() => true, - Some('-') | Some('>') | Some('=') => true, - _ => false, - }; - // 直後文字: ファイル名文字(英数字、_、.、-)が続く場合のみパス継続とみなす - let is_next_path_char = after - .chars() - .next() - .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-'); - if is_prev_path_start && is_next_path_char { - // プレフィックスを除去(直後のパス部分はそのまま保持) - remaining = after; - } else { - // パスコンテキストでなければプレフィックスをそのまま保持 - result.push_str(prefix); - remaining = after; + let mut in_unified_diff = false; + for line in text.split_inclusive('\n') { + // コード中の絶対パスは実際の引数・値なので、診断ヘッダーと同じ短縮はできない。 + if is_source_output_line(line, &mut in_unified_diff) { + result.push_str(line); + continue; + } + let mut remaining = line; + let mut consumed = 0usize; + while let Some(pos) = remaining.find(prefix) { + result.push_str(&remaining[..pos]); + let abs_pos = consumed + pos; + let after = &remaining[pos + prefix.len()..]; + // 直前文字: 行頭/空白/`-`/`>`/`=` のいずれかがパス開始位置として有効 + let prev = line[..abs_pos].chars().next_back(); + let is_prev_path_start = match prev { + None => true, + Some(c) if c.is_ascii_whitespace() => true, + Some('-') | Some('>') | Some('=') => true, + _ => false, + }; + // 直後文字: ファイル名文字(英数字、_、.、-)が続く場合のみパス継続とみなす + let is_next_path_char = after + .chars() + .next() + .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-'); + if is_prev_path_start && is_next_path_char { + // プレフィックスを除去(直後のパス部分はそのまま保持) + remaining = after; + } else { + // パスコンテキストでなければプレフィックスをそのまま保持 + result.push_str(prefix); + remaining = after; + } + consumed = abs_pos + prefix.len(); } - consumed = abs_pos + prefix.len(); + result.push_str(remaining); } - result.push_str(remaining); result } @@ -1193,7 +1214,12 @@ fn strip_common_path_prefix(text: &str) -> String { /// パスの開始位置として採用し、`http://` のような URL を誤抽出しないようにする。 fn extract_absolute_paths(text: &str) -> Vec<&str> { let mut paths = Vec::new(); + let mut in_unified_diff = false; for line in text.lines() { + // ソース内のパスから共通接頭辞を決めると、無関係な診断まで短縮してしまう。 + if is_source_output_line(line, &mut in_unified_diff) { + continue; + } let mut search_from = 0; while let Some(rel_start) = line[search_from..].find('/') { let start = search_from + rel_start; @@ -1246,6 +1272,45 @@ fn common_directory_prefix<'a>(paths: &[&'a str]) -> &'a str { mod tests { use super::*; + #[test] + fn test_normalize_preserves_absolute_paths_in_numbered_source() { + let input = "error: invalid path\n --> /work/project/main.sh:2:1\n2 | cp /work/project/input /work/project/output\n --> /work/project/other.sh:3:1"; + assert_eq!( + normalize_lint_output(input), + "error: invalid path\n-> main.sh:2:1\n2 | cp /work/project/input /work/project/output\n-> other.sh:3:1" + ); + } + + #[test] + fn test_normalize_preserves_absolute_paths_in_unified_diff() { + let input = "Diff in /work/project/main.sh:1:\n cp /work/project/input /work/project/output\n-cp /work/project/old /work/project/output\n+cp /work/project/new /work/project/output\nDiff in /work/project/other.sh:2:"; + assert_eq!( + normalize_lint_output(input), + "Diff in main.sh:1:\n cp /work/project/input /work/project/output\n-cp /work/project/old /work/project/output\n+cp /work/project/new /work/project/output\nDiff in other.sh:2:" + ); + } + + #[test] + fn test_normalize_source_paths_do_not_determine_diagnostic_prefix() { + for input in [ + "2 | cp /work/project/input /work/project/output\n3 | cat /work/project/input", + "-cp /work/project/old /work/project/output\n+cp /work/project/new /work/project/output", + "error: invalid path\n-> /work/project/main.sh:2:1\n2 | cp /work/project/input /work/project/output", + ] { + assert_eq!(normalize_lint_output(input), input); + } + } + + #[test] + fn test_normalize_migration_progress_decoration() { + assert_eq!( + normalize_lint_output( + "Using attach strategy to execute scripts...\n==== Starting migration for: 3.8.0.rc001 ====\n............................................................... 63 / 212 ( 29%)" + ), + "Using attach strategy to execute scripts.\n= Starting migration for: 3.8.0.rc001 =\n. 63 / 212 ( 29%)" + ); + } + #[test] fn test_strip_ansi_codes_removes_colors() { let input = "\x1b[31merror\x1b[0m: something failed"; From a1100d2da3162d7b346d58207b26586a39328197 Mon Sep 17 00:00:00 2001 From: owa <20108042+owayo@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:45:45 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=E3=83=91=E3=82=A4=E3=83=97?= =?UTF-8?q?=E6=8E=92=E5=87=BA=E5=BE=85=E3=81=A1=E3=81=A8=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E3=81=97=E3=81=9F=E3=83=95=E3=83=83=E3=82=AF=E3=81=AE=E6=89=B1?= =?UTF-8?q?=E3=81=84=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 子プロセスが成功した後に孫プロセスだけが出力パイプを保持する場合、 実装は排出猶予後に残ったプロセスグループを停止し、取得済みの出力と 成功状態を返している。従来の説明は一律にタイムアウトとしてブロックする 内容だったため、実装と既存テストに合わせて修正する。 AGENTS.mdと英語・日本語の設定ガイドで、直接の子が成功した場合と 失敗した場合の報告を区別する。実行時の挙動は変更しない。 --- AGENTS.md | 2 +- docs/configuration.ja.md | 2 +- docs/configuration.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 02b8970..4e37ae8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Instructions for AI coding agents (Claude Code, Cursor, Windsurf, Codex, Grok, G パスの共通接頭辞を短縮するのは診断情報だけとする。行番号付きソース抜粋、追加・削除行、`Diff in ...` / `@@ ...` に続く文脈行では、絶対パスを元のまま保持する。接頭辞の算出からもこれらの行を除外する。差分の文脈判定は正規化とパス短縮で共有し、一方だけがコードを書き換える状態を作らない。 8. **Fail-Closed Security**: Block on parse errors, empty input, or missing/blank/incorrectly typed required agent fields. Required-field validation is deliberately scoped to the fields claw-hooks actually reads for a decision (event name, tool name/input, `tool_input.command` for Bash, the patch body for `apply_patch`, `stop_hook_active` for Stop). The scope is **per (tool, event) pair**, not per tool: a shell tool's `command` is the input to the dangerous-command check and is always required, but a file-editing tool's path is only read by the post-save hooks, so it is required on `PostToolUse` and not on `PreToolUse`. No filter looks at file input before execution — the built-in and custom filters are gated on `is_shell_tool()`, and the extension hook returns false immediately for any event other than `AfterFileEdit` — so requiring `file_path` on `PreToolUse` meant rejecting a write over a field that would never be consulted, and on Claude a `PreToolUse` rejection is an immediate hard block. Metadata that no filter consults (`cwd`, `model`, `transcript_path`, `turn_id`, `permission_mode`, `tool_use_id`, `tool_response`, …) is not required, because the agent specs present those as the fields you *usually* receive rather than a strict schema (the Codex docs' own `SessionEnd` example payload omits `model`), and requiring them would let a single omitted field fail every hook call closed and take the whole tool offline. Out-of-scope pass-through events are not validated at all — validating an event whose decision is discarded has no security upside and can produce a harmful spurious block (a Codex `UserPromptSubmit` deny rejects the user's prompt; a Codex `PostToolUse` deny *replaces the real tool output* with the hook message, so `PostToolUse` is only strictly validated for file-editing tools). Non-empty unknown/unsupported event names are still passed through as allow to keep claw-hooks within its intentional scope. **Only the events claw-hooks actually gates before execution fail closed**: `blocks_would_loop_or_be_ignored` is an allowlist keyed on the event name, and everything outside it (`PreToolUse` on Claude/Codex/Antigravity/Grok, `PermissionRequest` on Codex, `beforeShellExecution`/`preToolUse` on Cursor, `pre_run_command` on Windsurf) resolves to a neutral allow. Two independent reasons drive this. First, **Stop-family events**: on Claude/Codex a Stop `decision:"block"`, on Antigravity `decision:"continue"`, and on Cursor a `followup_message` all mean "do not stop, here is a new prompt" rather than "reject", so returning one for a malformed payload creates a self-sustaining loop (fail → continue → Stop re-fires → same failure) that no loop guard can break, since `stop_hook_active` / `loop_count` / the cross-process env flag all require a successful parse first. Stop is not a pre-execution gate — allowing the stop creates no new side effects, whereas auto-continuing induces further tool calls. Second, **events claw-hooks never inspects**: denying them has zero security upside and real cost — a Claude/Codex `UserPromptSubmit` deny erases the user's prompt, a Codex `PostToolUse` deny replaces the real tool output with the hook message, a Cursor `beforeReadFile` carries the full file body (so a large file trivially exceeds `MAX_INPUT_BYTES` and the read would be blocked by a tool that has no opinion on reads), and every Windsurf post-hook and every Grok event except `PreToolUse` cannot block at all, so a deny only injects a spurious error. The neutral response is the event-specific stop-allow shape + exit 0 (Antigravity uses `{"decision":"stop"}` for Stop and `{}` elsewhere, since only its Stop output requires `decision`; the other agents use `{}`). **When the event name cannot be determined at all, the guard stays fail-closed and blocks** — a truncated or oversized `PreToolUse` still denies. That guard needs the event name, so it must keep working when the payload cannot be parsed as JSON at all — otherwise the two cases where it is needed most (a truncated write, and an oversized payload, which `read_stdin_bounded` necessarily cuts at `MAX_INPUT_BYTES + 1` and therefore leaves as invalid JSON) fall through to a block and produce exactly the self-sustaining loop described above. `scan_event_name_in_malformed_input` therefore rescans the raw text for the event-name key when `serde_json` fails, and the oversized path passes the truncated input along instead of discarding it. The scan is deliberately narrow: it matches the quoted key exactly, takes the earliest match, reads only the first 4 KiB (agents place the event name near the front of the payload, while a giant `tool_input.command` body sits after it), and requires a closing quote — anything else returns `None` and the caller stays fail-closed. Misjudging in the permissive direction only ever allows a *stop*; `PreToolUse` payloads keep blocking on both truncation and oversize. Relatedly, the fail-closed writer picks its stream by exit code rather than by format: a Stop parse error resolves to the event-specific stop-allow response + exit 0, which is a decision document and belongs on stdout, whereas a genuine exit-2 block on Claude/Windsurf still writes its plain-text reason to stderr where those agents read it. **Configuration and startup failures are fail-closed too**: a TOML syntax error or failed validation used to propagate out of `main` as `exit 1` with empty stdout, which Codex/Antigravity read as "hook failed, ignore its decision" — one typo in `config.toml` silently disabled all command blocking. `HookService::emit_config_error` now reads the payload just far enough to identify the event and returns the agent's native deny shape (or the event-specific stop-allow response for Stop, per above), writing the diagnostic to stderr with a pointer to `claw-hooks check`. Logger initialization failure is non-fatal (warn, continue without logging) for the same reason, `HookService::emit_runtime_error` covers residual internal errors from `run`, and stdin I/O failure goes through the normal fail-closed path instead of `?`. Pathologically deep command nesting or oversized command input is also treated as a block candidate, avoiding recursion-based stack overflow that would otherwise crash and fail open. The pathological-input guard counts brace nesting (`{`/`}`) in addition to parentheses, and the AST walk enforces an explicit traversal-depth limit, so deeply nested command groups (`{ ...;}`) cannot overflow the stack and fail open even under small thread stacks (Windows/containers). Wrapper-chain expansion (`sudo sudo … rm`) is iterative with an explicit recursion bound rather than self-recursive, so a long wrapper chain that slips under the size limit cannot overflow the stack; chains exceeding the bound fail closed. The stdin reader is bounded (4 MiB) so an oversized or runaway hook payload is treated as fail-closed rather than allocating until the process is OOM-killed (which would otherwise drop the safeguard entirely). Stdin is read as raw bytes and decoded with lossy UTF-8 conversion (not `read_to_string`), so a stray invalid-UTF-8 byte cannot abort the process with `exit 1` and an empty stdout — a state Codex/Antigravity interpret as a failed hook (decision ignored = fail-open). Invalid bytes become U+FFFD and the input still flows through the normal parse/detect path: a now-unparseable payload fails closed (block), while a still-parseable command keeps its dangerous-command detection. Hook subprocess stdout/stderr pipes are drained to EOF but retain at most 4 MiB per stream, preventing a noisy formatter/linter from OOM-killing the hook before output normalization and agent-facing truncation. The allowlist in `blocks_would_loop_or_be_ignored` must stay in sync with the event mapping table: `parse_codex_input` maps the legacy alias `BeforeTool` onto the same `HookEvent::BeforeCommand` as `PreToolUse`, but the allowlist named only the latter, so an event declared to be a pre-execution gate resolved to a neutral allow whenever its payload failed to parse — a divergence between the two tables is silently a fail-open, which is why `BeforeTool` now appears in both (and in the branch that picks the recommended `hookSpecificOutput` deny shape). Finally, the **diagnostic text for a configuration or runtime error is withheld when stderr is the decision channel**. `emit_config_error` writes the config file's absolute path and the TOML parse error — which quotes the offending line — to stderr for the user's benefit, but Claude and Windsurf read stderr as the block reason on a non-zero exit, so both were handed straight to the agent; the diagnostic is now emitted only when `fail_closed_uses_stderr` is false, and the agent-facing text stays the fixed sentence that points at `claw-hooks check`. -9. **Process Group Isolation (Unix)**: Hook subprocesses are placed in their own process group via `setpgid`, so timeouts kill the whole tree (`killpg`) — preventing grandchild leaks like `sh -c 'sleep ...'` after the shell is signaled. If the direct child exits but a background grandchild still keeps stdout/stderr pipes open (for example `sh -c 'sleep 60 &'`), the pipe wait is also treated as a timeout and the process group is killed. After a normal child exit, the reader-thread join uses a short post-exit drain grace rather than reusing the original timeout deadline, so a command that completes right at the deadline still has its buffered output collected instead of being mis-reported as a timeout (which would otherwise return a spurious block to the agent for `report=true` stop hooks); a grandchild that keeps the pipe open past the grace still triggers the process-group kill. +9. **Process Group Isolation (Unix)**: Hook subprocesses are placed in their own process group via `setpgid`, so timeouts kill the whole tree (`killpg`) — preventing grandchild leaks like `sh -c 'sleep ...'` after the shell is signaled. If the direct child exits but a background grandchild still keeps stdout/stderr pipes open (for example `sh -c 'sleep 60 &'`), the process group is killed after the drain grace. A successful direct child still returns success with its collected output; only a failed child is reported as a drain timeout. After a normal child exit, the reader-thread join uses a short post-exit drain grace rather than reusing the original timeout deadline, so a command that completes right at the deadline still has its buffered output collected instead of being mis-reported as a timeout (which would otherwise return a spurious block to the agent for `report=true` stop hooks); a grandchild that keeps the pipe open past the grace still triggers the process-group kill. 10. **Debug Log Safety**: Debug logs store hook event summaries (event/tool/session/size) instead of raw hook input or raw parsed `ToolInput`, so tool command text, file contents, unknown event payloads, and agent messages are not persisted to disk. Per-agent parsed-input debug lines log byte counts and booleans (`command_bytes`, `file_path_bytes`, `has_cwd`) rather than raw command/file-path/cwd values, and the formatted hook output is logged as a decision + size summary; the full output body (which may include lint diagnostics quoting source lines) is available only via `--trace`, which writes to stderr and is not persisted. Extension-hook logs record the executable basename with arg-count / inline-template booleans and the resolved file path byte length instead of the expanded command line — executable directories, the actual file path and rendered command (which can contain user-directory hierarchies or source-quoting lint snippets) are kept off disk. Extension-hook failure logs and timeout messages use the same sanitized command label — but that label is **log-only**: the string handed to `run_with_timeout` is embedded verbatim in the timeout body, which reaches the agent as `additionalContext`, so it carries only the configured program name (matching the stop-hook path). Subagent notification logs record `type_bytes=` rather than the agent name, in both the filter and the per-agent parsed-input lines: subagent names routinely contain internal project and team names, and `log_sanitizer` already suppresses the same field (its unit test pins that the name never appears in a log summary), so leaving it in an adjacent `debug!` defeated the rule. The hook-input summarizer recognises every agent's field naming — Grok's camelCase (`hookEventName` / `toolName` / `sessionId`) and Antigravity's `toolCall.name` (it has no event-name field at all) — because without them those two agents logged nothing but `event= tool=- session_id=-`, which defeats the only purpose this log still has. Stop-hook queue/execution/completion logs likewise use only the executable basename and argument count; command arguments and executable directories are never persisted. Stop-hook output logging records only the byte size of stdout/stderr per hook; the full diagnostic body is still relayed to the agent via the block `reason`/`followup_message`, but it is never persisted to disk. 11. **Command Hooks**: `[[command_hooks]]` hands each call of a named program inside a shell command to an external checker before the command runs (GitHub Issue #1; the motivating case is `noslop hook command` checking what an agent writes through `gws`, which lets one checker serve every supported agent while the shell analysis stays in claw-hooks). An entry has `command` (required; a name, not a regex, without whitespace — both sides go through `command_key`, `CommandHook::command_key()` against `Invocation::command_key()`), `run` (required; split once by `parse_shell_tokens` and spawned through the same `build_command` as extension and stop hooks — directly, **without a shell**, on Unix, and via `cmd /c` on Windows so `.cmd` / `.bat` wrappers resolve; the invocation itself only ever travels on stdin, never on the checker's command line), `timeout` (seconds per checker run, `CommandHook::DEFAULT_TIMEOUT_SECS` = 5, `1..=86400`), and `on_error` (`allow` by default, or `block`). The filter (`command_hook_filter.rs`, `priority::COMMAND_HOOK = 60`) runs after the built-in `rm`/`kill`/`dd` filters and the custom filters, so a command they already block never reaches a checker. It handles only `HookEvent::BeforeCommand` and `HookEvent::PermissionRequest` for `is_shell_tool()` inputs, and it is skipped entirely when claw-hooks was started with `CLAW_HOOKS_COMMAND_HOOK_ACTIVE` set — every checker inherits that variable as `1`, so a checker that drives an agent cannot recurse. `HookService::new` builds the chain with `FilterChain::with_agent(&config, adapter.agent_profile())`; `FilterChain::new` (compiled for tests only) uses `AgentProfile::default()` (`unknown`, no context delivery). diff --git a/docs/configuration.ja.md b/docs/configuration.ja.md index d49807c..af5fd47 100644 --- a/docs/configuration.ja.md +++ b/docs/configuration.ja.md @@ -229,7 +229,7 @@ condition = { file_exists = "tsconfig.json" } `condition`フィールドを持つStopフックは、プロジェクトの構成ファイルに応じてlint/typecheckコマンドを実行します。`commands`配列内のすべてのコマンドは**並列実行**されます。失敗したコマンドの出力はすべて収集され、AIエージェントにブロック理由としてまとめて返されます。失敗したフックは、`gate = false` を書かない限り後続のステージの実行も止めます。エージェントに何が伝わり、実行しなかったステージがいつ動くかは [Stopフックの失敗と再試行](#stopフックの失敗と再試行) で説明します。 -**タイムアウトの扱い:** `hook_timeout` は最大 `86400` 秒まで指定できます。報告対象のStopフック(`report = true`)では、`hook_timeout` を超えたコマンドを claw-hooks がプロセスツリーごと強制終了(SIGKILL)し、タイムアウトをブロック理由として返します。直接の子プロセスが終了しても、バックグラウンド孫プロセスが stdout/stderr パイプを保持している場合もタイムアウト扱いにするため、`sh -c 'sleep 60 &'` のようなコマンドでフックタイムアウトを回避できません。通常のコマンド失敗(終了コード `124` を自ら返す場合を含む)も引き続きブロック対象です。`report = false` のStopフックは stdin/stdout/stderr を null にしてデタッチ起動されるため、claw-hooks は完了待ちも `hook_timeout` の強制も行いません。必要ならコマンド自体を timeout ツールで包んでください。 +**タイムアウトの扱い:** `hook_timeout` は最大 `86400` 秒まで指定できます。報告対象のStopフック(`report = true`)では、`hook_timeout` を超えたコマンドを claw-hooks がプロセスツリーごと強制終了(SIGKILL)し、タイムアウトをブロック理由として返します。直接の子プロセスが終了してもバックグラウンド孫プロセスが stdout/stderr パイプを保持している場合は、短い排出猶予の後に残ったプロセスグループを強制終了します。直接の子が成功していれば、取得した出力と成功状態を返すため、`sh -c 'sleep 60 &'` が誤ったブロックを起こすことはありません。直接の子が失敗し、パイプの排出も終わらない場合は、排出待ちのタイムアウトとして報告します。通常のコマンド失敗(終了コード `124` を自ら返す場合を含む)も引き続きブロック対象です。`report = false` のStopフックは stdin/stdout/stderr を null にしてデタッチ起動されるため、claw-hooks は完了待ちも `hook_timeout` の強制も行いません。必要ならコマンド自体を timeout ツールで包んでください。 ただし Windsurf と Grok CLI は例外です。Windsurf の `post_cascade_response` は非同期の事後フックであり、Grok も `PreToolUse` 以外はすべて stdout が無視される事後フックです。どちらも Stopフック自体は実行されますが、失敗はベストエフォート扱いとなり、AI エージェントへのブロックとしては返されません。 diff --git a/docs/configuration.md b/docs/configuration.md index 3ee18ea..ff743bd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -229,7 +229,7 @@ All three approaches can be combined: use the global config for shared rules, `. Stop hooks with a `condition` field run lint/typecheck commands based on the project type. All commands in the `commands` array are executed **in parallel**. When any command fails (non-zero exit), all failure outputs are collected and returned to the AI agent as a block reason, prompting it to fix the issues. A failing hook also keeps the later stages from running unless it sets `gate = false`; what the agent is told and when those stages get to run are covered in [Stop Hook Failures and Retry](#stop-hook-failures-and-retry). -**Timeout handling:** `hook_timeout` accepts values up to `86400` seconds. For reported stop hooks (`report = true`), when a command exceeds `hook_timeout`, claw-hooks kills the process tree (SIGKILL) and returns the timeout as a block reason. A direct child that exits while a background grandchild still keeps stdout/stderr pipes open is also treated as timed out, so commands like `sh -c 'sleep 60 &'` cannot bypass the hook timeout. Normal command failures — including those that explicitly exit with code `124` — also block as usual. `report = false` stop hooks are started detached with stdin/stdout/stderr set to null, so claw-hooks does not wait for them or enforce `hook_timeout`; wrap the command itself with a timeout tool if needed. +**Timeout handling:** `hook_timeout` accepts values up to `86400` seconds. For reported stop hooks (`report = true`), when a command exceeds `hook_timeout`, claw-hooks kills the process tree (SIGKILL) and returns the timeout as a block reason. If a direct child exits while a background grandchild still holds stdout/stderr open, claw-hooks allows a short drain grace and then kills the remaining process group. A successful direct child keeps its success status and collected output, so `sh -c 'sleep 60 &'` does not cause a false block. A failed child with an undrained pipe is reported as a drain timeout. Normal command failures — including those that explicitly exit with code `124` — also block as usual. `report = false` stop hooks are started detached with stdin/stdout/stderr set to null, so claw-hooks does not wait for them or enforce `hook_timeout`; wrap the command itself with a timeout tool if needed. Windsurf and Grok CLI are the exceptions here: Windsurf's `post_cascade_response` is an asynchronous post-hook, and every Grok event except `PreToolUse` is a post-hook whose stdout the agent ignores. On both, stop hooks still run but failures are treated as best-effort and are not surfaced back to the agent as a block. From a44a33dd3012cf07d6506a14f7c7a4115af2a46e Mon Sep 17 00:00:00 2001 From: owa <20108042+owayo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:00:56 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=E5=B7=AE=E5=88=86=E3=83=98?= =?UTF-8?q?=E3=83=83=E3=83=80=E3=83=BC=E3=81=A8CRLF=E5=A2=83=E7=95=8C?= =?UTF-8?q?=E3=81=A7=E3=81=AE=E3=83=91=E3=82=B9=E4=BF=9D=E6=8C=81=E3=82=92?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unified diffのファイルヘッダーと、SQLコメントを削除したソース行は どちらも「--- パス」の形になるため、絶対パスを保持する挙動を固定する。 出力の形式だけから両者を決めつけて短縮し、コードを変えてしまうことを防ぐ。 CRLF入力の差分ブロックが空行で終了した後は、診断ヘッダーの パス短縮が再び有効になることも同じテストで確認する。 --- src/domain/normalize.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/domain/normalize.rs b/src/domain/normalize.rs index 1ba5658..df2a3f7 100644 --- a/src/domain/normalize.rs +++ b/src/domain/normalize.rs @@ -1290,6 +1290,17 @@ mod tests { ); } + #[test] + fn test_normalize_preserves_diff_headers_and_resets_after_crlf_blank() { + // `---` は差分ヘッダーだけでなく、SQL コメントの削除行にも現れる。 + // 同じ形の行はパスを保持し、空行を挟んだ診断情報だけを短縮する。 + let input = "--- /work/project/old.sql\r\n+++ /work/project/new.sql\r\n@@ -1 +1 @@\r\n--- /work/project/old\r\n+-- /work/project/new\r\n\r\n --> /work/project/first.sql:1:1\r\n --> /work/project/second.sql:2:1\r\n"; + assert_eq!( + normalize_lint_output(input), + "--- /work/project/old.sql\n+++ /work/project/new.sql\n@@ -1 +1 @@\n--- /work/project/old\n+-- /work/project/new\n\n-> first.sql:1:1\n-> second.sql:2:1" + ); + } + #[test] fn test_normalize_source_paths_do_not_determine_diagnostic_prefix() { for input in [