Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ 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.
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=<len>` 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=<unknown> 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).

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading