From 0ea80fb8f9561a218de8ac69a9eb27816a9f8149 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Mon, 30 Mar 2026 16:35:08 -0400 Subject: [PATCH 1/2] feat(clash_hooks): extract standalone hooks library with clean per-event architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract clash_hooks as a standalone crate with two-phase deserialization, per-event field structs (CommonFields + XxxFields), OnceLock-cached typed tool input accessors, and opaque Response builders. - 22+ hook event types with typed response constructors - No RawInput god-object — each event holds only its own fields - parse_payload() macro for concise event dispatch - debug_assert on Response builders to catch silent no-ops - Re-export CommonFields from lib.rs - Migrate clash crate to use clash_hooks types --- Cargo.lock | 329 ++-- Cargo.toml | 2 + clash_hooks/Cargo.toml | 14 + clash_hooks/src/event.rs | 1511 +++++++++++++++++ clash_hooks/src/lib.rs | 150 ++ clash_hooks/src/response.rs | 75 + clash_hooks/src/tool_input.rs | 181 ++ clash_hooks/src/wire.rs | 373 ++++ clash_hooks/tests/fixtures/config_change.json | 8 + clash_hooks/tests/fixtures/elicitation.json | 17 + clash_hooks/tests/fixtures/notification.json | 9 + .../tests/fixtures/permission_request.json | 12 + clash_hooks/tests/fixtures/post_compact.json | 8 + clash_hooks/tests/fixtures/post_tool_use.json | 16 + .../tests/fixtures/post_tool_use_failure.json | 14 + clash_hooks/tests/fixtures/pre_compact.json | 8 + .../tests/fixtures/pre_tool_use_bash.json | 14 + .../tests/fixtures/pre_tool_use_write.json | 13 + clash_hooks/tests/fixtures/session_end.json | 7 + clash_hooks/tests/fixtures/session_start.json | 9 + clash_hooks/tests/fixtures/stop.json | 8 + clash_hooks/tests/fixtures/stop_failure.json | 11 + .../tests/fixtures/subagent_start.json | 8 + clash_hooks/tests/fixtures/subagent_stop.json | 10 + .../tests/fixtures/unknown_future.json | 11 + .../tests/fixtures/user_prompt_submit.json | 8 + .../tests/fixtures/worktree_create.json | 7 + clash_hooks/tests/integration.rs | 528 ++++++ 28 files changed, 3174 insertions(+), 187 deletions(-) create mode 100644 clash_hooks/Cargo.toml create mode 100644 clash_hooks/src/event.rs create mode 100644 clash_hooks/src/lib.rs create mode 100644 clash_hooks/src/response.rs create mode 100644 clash_hooks/src/tool_input.rs create mode 100644 clash_hooks/src/wire.rs create mode 100644 clash_hooks/tests/fixtures/config_change.json create mode 100644 clash_hooks/tests/fixtures/elicitation.json create mode 100644 clash_hooks/tests/fixtures/notification.json create mode 100644 clash_hooks/tests/fixtures/permission_request.json create mode 100644 clash_hooks/tests/fixtures/post_compact.json create mode 100644 clash_hooks/tests/fixtures/post_tool_use.json create mode 100644 clash_hooks/tests/fixtures/post_tool_use_failure.json create mode 100644 clash_hooks/tests/fixtures/pre_compact.json create mode 100644 clash_hooks/tests/fixtures/pre_tool_use_bash.json create mode 100644 clash_hooks/tests/fixtures/pre_tool_use_write.json create mode 100644 clash_hooks/tests/fixtures/session_end.json create mode 100644 clash_hooks/tests/fixtures/session_start.json create mode 100644 clash_hooks/tests/fixtures/stop.json create mode 100644 clash_hooks/tests/fixtures/stop_failure.json create mode 100644 clash_hooks/tests/fixtures/subagent_start.json create mode 100644 clash_hooks/tests/fixtures/subagent_stop.json create mode 100644 clash_hooks/tests/fixtures/unknown_future.json create mode 100644 clash_hooks/tests/fixtures/user_prompt_submit.json create mode 100644 clash_hooks/tests/fixtures/worktree_create.json create mode 100644 clash_hooks/tests/integration.rs diff --git a/Cargo.lock b/Cargo.lock index 1a6a172..9729739 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,9 +113,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -128,15 +128,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -489,9 +489,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d13a61f2963b88eef9c1be03df65d42f6996dfeac1054870d950fcf66686f83" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" dependencies = [ "bon-macros", "rustversion", @@ -499,9 +499,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d314cc62af2b6b0c65780555abb4d02a03dd3b799cd42419044f0c38d99738c0" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ "darling 0.23.0", "ident_case", @@ -596,9 +596,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -676,9 +676,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -686,9 +686,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -699,9 +699,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -711,9 +711,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clash" @@ -732,7 +732,7 @@ dependencies = [ "clash_notify", "clash_starlark", "claude_settings", - "console", + "console 0.15.11", "crossterm", "dialoguer", "dirs", @@ -877,7 +877,16 @@ dependencies = [ "tracing", "utf8-chars", "uuid", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "clash_hooks" +version = "0.6.2" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", ] [[package]] @@ -972,9 +981,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -1031,6 +1040,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -1373,7 +1393,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ - "console", + "console 0.15.11", "shell-words", "tempfile", "thiserror 1.0.69", @@ -1597,9 +1617,9 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "euclid" -version = "0.22.13" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] @@ -2401,11 +2421,11 @@ dependencies = [ [[package]] name = "insta" -version = "1.46.3" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" +checksum = "99322078b2c076829a1db959d49da554fabc4342257fc0ba5a070a1eb3a01cd8" dependencies = [ - "console", + "console 0.16.3", "globset", "once_cell", "pest", @@ -2419,9 +2439,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling 0.23.0", "indoc", @@ -2451,9 +2471,9 @@ dependencies = [ [[package]] name = "inventory" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" +checksum = "8062b737e5389949f477d4760a2ebbff0c366f97798f2419b8d8f366363d3342" dependencies = [ "rustversion", ] @@ -2510,15 +2530,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ "once_cell", "wasm-bindgen", @@ -2526,9 +2546,9 @@ dependencies = [ [[package]] name = "kasuari" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", @@ -2609,9 +2629,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.11.0", "libc", @@ -2621,9 +2641,9 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ "bitflags 2.11.0", ] @@ -2821,9 +2841,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -2971,9 +2991,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -3072,9 +3092,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -3446,7 +3466,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit 0.25.8+spec-1.1.0", ] [[package]] @@ -3484,9 +3504,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", @@ -3873,9 +3893,9 @@ dependencies = [ [[package]] name = "ron" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ "bitflags 2.11.0", "once_cell", @@ -3903,9 +3923,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -3955,9 +3975,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -4251,9 +4271,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "similar" @@ -4550,9 +4570,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -4573,9 +4593,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", @@ -4597,12 +4617,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4851,9 +4871,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] @@ -4869,28 +4889,28 @@ dependencies = [ "serde_spanned", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow", + "winnow 1.0.1", ] [[package]] @@ -4981,9 +5001,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -5067,9 +5087,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", @@ -5129,9 +5149,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -5265,9 +5285,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "atomic", "getrandom 0.4.2", @@ -5388,9 +5408,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -5401,9 +5421,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5411,9 +5431,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", @@ -5424,9 +5444,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] @@ -5467,9 +5487,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" dependencies = [ "js-sys", "wasm-bindgen", @@ -5785,7 +5805,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -5794,16 +5814,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5821,31 +5832,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5872,84 +5866,42 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5957,16 +5909,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" +name = "winnow" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -6139,7 +6094,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -6167,24 +6122,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow", + "winnow 0.7.15", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -6267,7 +6222,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] @@ -6295,5 +6250,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow", + "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index dbca65e..f474641 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "clash", + "clash_hooks", "clash_notify", "claude_settings", "clash_starlark", @@ -152,6 +153,7 @@ brush-parser = { path = "clash-brush-parser", version = "0.6.2", package = "clas brush-core = { path = "clash-brush-core", version = "0.6.2", package = "clash-brush-core" } # Workspace crates (internal only) +clash_hooks = { path = "clash_hooks", version = "0.6.2" } clash_notify = { path = "clash_notify", version = "0.6.2" } clash_starlark = { path = "clash_starlark", version = "0.6.2" } claude_settings = { path = "claude_settings", version = "0.6.2" } diff --git a/clash_hooks/Cargo.toml b/clash_hooks/Cargo.toml new file mode 100644 index 0000000..2e5d47a --- /dev/null +++ b/clash_hooks/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "clash_hooks" +version = "0.6.2" +edition = "2024" +description = "Type-safe Rust library for Claude Code hooks — receive events, send responses" +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/clash_hooks/src/event.rs b/clash_hooks/src/event.rs new file mode 100644 index 0000000..5353c36 --- /dev/null +++ b/clash_hooks/src/event.rs @@ -0,0 +1,1511 @@ +//! Hook event types — one struct per Claude Code hook event. +//! +//! Each event type has methods to construct the correct [`Response`] for that +//! event. The type system prevents returning the wrong response type. + +use std::sync::OnceLock; + +use crate::response::Response; +use crate::tool_input::*; +use crate::wire::{ + CommonFields, ConfigChangeFields, ElicitationFields, ElicitationResultFields, EventPayload, + HookSpecificOutput, InstructionsLoadedFields, NotificationFields, PermissionBehavior, + PermissionDecision, PostCompactFields, PreCompactFields, PreToolUseDecision, RawOutput, + SessionEndFields, SessionStartFields, StopFailureFields, StopFields, SubagentStartFields, + SubagentStopFields, TaskCompletedFields, TeammateIdleFields, ToolFields, + UserPromptSubmitFields, WorktreeCreateFields, WorktreeRemoveFields, +}; + +// ── Cached tool input ──────────────────────────────────────────────── + +/// Wrapper that caches the parsed [`ToolInput`] so repeated calls to +/// `.bash()`, `.write()`, etc. don't re-parse from JSON each time. +#[derive(Debug)] +#[doc(hidden)] +pub struct CachedToolInput { + pub(crate) common: CommonFields, + pub(crate) fields: ToolFields, + parsed: OnceLock, +} + +impl CachedToolInput { + pub(crate) fn new(common: CommonFields, fields: ToolFields) -> Self { + Self { + common, + fields, + parsed: OnceLock::new(), + } + } + + fn typed_tool_input(&self) -> &ToolInput { + self.parsed.get_or_init(|| { + let tool_name = self.fields.tool_name.as_deref().unwrap_or(""); + let null = serde_json::Value::Null; + let tool_input = self.fields.tool_input.as_ref().unwrap_or(&null); + ToolInput::parse(tool_name, tool_input) + }) + } +} + +// ── HookEvent ──────────────────────────────────────────────────────── + +/// A hook event received from Claude Code. +/// +/// Match on this to determine which event you're handling, then call methods +/// on the inner type to build a [`Response`]. +#[derive(Debug)] +#[non_exhaustive] +pub enum HookEvent { + PreToolUse(PreToolUse), + PostToolUse(PostToolUse), + PostToolUseFailure(PostToolUseFailure), + PermissionRequest(PermissionRequest), + SessionStart(SessionStart), + SessionEnd(SessionEnd), + UserPromptSubmit(UserPromptSubmit), + Stop(Stop), + StopFailure(StopFailure), + SubagentStart(SubagentStart), + SubagentStop(SubagentStop), + TeammateIdle(TeammateIdle), + TaskCompleted(TaskCompleted), + InstructionsLoaded(InstructionsLoaded), + ConfigChange(ConfigChange), + PreCompact(PreCompact), + PostCompact(PostCompact), + Notification(Notification), + Elicitation(Elicitation), + ElicitationResult(ElicitationResult), + WorktreeCreate(WorktreeCreate), + WorktreeRemove(WorktreeRemove), + /// An event type not recognized by this library version. + Unknown(RawEvent), +} + +impl HookEvent { + /// Construct from two-phase parsed input. + pub(crate) fn from_parsed(common: CommonFields, payload: EventPayload) -> Self { + match payload { + EventPayload::Tool(fields) => match common.hook_event_name.as_str() { + "PreToolUse" => HookEvent::PreToolUse(PreToolUse { + cached: CachedToolInput::new(common, fields), + }), + "PostToolUse" => HookEvent::PostToolUse(PostToolUse { + cached: CachedToolInput::new(common, fields), + }), + "PostToolUseFailure" => HookEvent::PostToolUseFailure(PostToolUseFailure { + cached: CachedToolInput::new(common, fields), + }), + "PermissionRequest" => HookEvent::PermissionRequest(PermissionRequest { + cached: CachedToolInput::new(common, fields), + }), + _ => HookEvent::Unknown(RawEvent { + common, + extra: serde_json::Map::new(), + }), + }, + EventPayload::SessionStart(fields) => { + HookEvent::SessionStart(SessionStart { common, fields }) + } + EventPayload::SessionEnd(fields) => { + HookEvent::SessionEnd(SessionEnd { common, fields }) + } + EventPayload::UserPromptSubmit(fields) => { + HookEvent::UserPromptSubmit(UserPromptSubmit { common, fields }) + } + EventPayload::Stop(fields) => HookEvent::Stop(Stop { common, fields }), + EventPayload::StopFailure(fields) => { + HookEvent::StopFailure(StopFailure { common, fields }) + } + EventPayload::SubagentStart(fields) => { + HookEvent::SubagentStart(SubagentStart { common, fields }) + } + EventPayload::SubagentStop(fields) => { + HookEvent::SubagentStop(SubagentStop { common, fields }) + } + EventPayload::TeammateIdle(fields) => { + HookEvent::TeammateIdle(TeammateIdle { common, fields }) + } + EventPayload::TaskCompleted(fields) => { + HookEvent::TaskCompleted(TaskCompleted { common, fields }) + } + EventPayload::InstructionsLoaded(fields) => { + HookEvent::InstructionsLoaded(InstructionsLoaded { common, fields }) + } + EventPayload::ConfigChange(fields) => { + HookEvent::ConfigChange(ConfigChange { common, fields }) + } + EventPayload::PreCompact(fields) => { + HookEvent::PreCompact(PreCompact { common, fields }) + } + EventPayload::PostCompact(fields) => { + HookEvent::PostCompact(PostCompact { common, fields }) + } + EventPayload::Notification(fields) => { + HookEvent::Notification(Notification { common, fields }) + } + EventPayload::Elicitation(fields) => { + HookEvent::Elicitation(Elicitation { common, fields }) + } + EventPayload::ElicitationResult(fields) => { + HookEvent::ElicitationResult(ElicitationResult { common, fields }) + } + EventPayload::WorktreeCreate(fields) => { + HookEvent::WorktreeCreate(WorktreeCreate { common, fields }) + } + EventPayload::WorktreeRemove(fields) => { + HookEvent::WorktreeRemove(WorktreeRemove { common, fields }) + } + EventPayload::Unknown(extra) => HookEvent::Unknown(RawEvent { common, extra }), + } + } + + /// The session ID for this event. + pub fn session_id(&self) -> &str { + &self.common_ref().session_id + } + + /// The working directory. + pub fn cwd(&self) -> &str { + &self.common_ref().cwd + } + + /// The hook event name string. + pub fn hook_event_name(&self) -> &str { + &self.common_ref().hook_event_name + } + + fn common_ref(&self) -> &CommonFields { + match self { + HookEvent::PreToolUse(e) => &e.cached.common, + HookEvent::PostToolUse(e) => &e.cached.common, + HookEvent::PostToolUseFailure(e) => &e.cached.common, + HookEvent::PermissionRequest(e) => &e.cached.common, + HookEvent::SessionStart(e) => &e.common, + HookEvent::SessionEnd(e) => &e.common, + HookEvent::UserPromptSubmit(e) => &e.common, + HookEvent::Stop(e) => &e.common, + HookEvent::StopFailure(e) => &e.common, + HookEvent::SubagentStart(e) => &e.common, + HookEvent::SubagentStop(e) => &e.common, + HookEvent::TeammateIdle(e) => &e.common, + HookEvent::TaskCompleted(e) => &e.common, + HookEvent::InstructionsLoaded(e) => &e.common, + HookEvent::ConfigChange(e) => &e.common, + HookEvent::PreCompact(e) => &e.common, + HookEvent::PostCompact(e) => &e.common, + HookEvent::Notification(e) => &e.common, + HookEvent::Elicitation(e) => &e.common, + HookEvent::ElicitationResult(e) => &e.common, + HookEvent::WorktreeCreate(e) => &e.common, + HookEvent::WorktreeRemove(e) => &e.common, + HookEvent::Unknown(e) => &e.common, + } + } +} + +// ── Shared helpers ─────────────────────────────────────────────────── + +/// Common fields available on all events. +pub trait HookEventCommon { + /// Access the common wire fields shared by every event. + fn common(&self) -> &CommonFields; + + /// The session ID. + fn session_id(&self) -> &str { + &self.common().session_id + } + + /// The working directory. + fn cwd(&self) -> &str { + &self.common().cwd + } + + /// The transcript file path. + fn transcript_path(&self) -> &str { + &self.common().transcript_path + } + + /// The permission mode (e.g., "default", "plan", "bypassPermissions"). + fn permission_mode(&self) -> Option<&str> { + self.common().permission_mode.as_deref() + } + + /// The hook event name string. + fn hook_event_name(&self) -> &str { + &self.common().hook_event_name + } + + /// Pass through — tells Claude Code to proceed with default behavior. + fn pass(&self) -> Response { + crate::pass() + } +} + +/// Shared tool-event fields and accessors. +pub trait ToolEvent: HookEventCommon { + /// Access the cached tool input. + #[doc(hidden)] + fn cached_tool_input(&self) -> &CachedToolInput; + + /// The tool name (e.g., "Bash", "Read", "Write"). + fn tool_name(&self) -> &str { + self.cached_tool_input() + .fields + .tool_name + .as_deref() + .unwrap_or("") + } + + /// The raw tool input JSON. + fn tool_input_raw(&self) -> &serde_json::Value { + static NULL: serde_json::Value = serde_json::Value::Null; + self.cached_tool_input() + .fields + .tool_input + .as_ref() + .unwrap_or(&NULL) + } + + /// The tool use ID. + fn tool_use_id(&self) -> Option<&str> { + self.cached_tool_input().fields.tool_use_id.as_deref() + } + + /// Parse the tool input into a typed [`ToolInput`]. + /// + /// The result is cached — subsequent calls return a reference to the + /// same parsed value without re-parsing from JSON. + fn typed_tool_input(&self) -> &ToolInput { + self.cached_tool_input().typed_tool_input() + } + + /// Get typed Bash input, if this is a Bash tool invocation. + fn bash(&self) -> Option<&BashInput> { + match self.typed_tool_input() { + ToolInput::Bash(b) => Some(b), + _ => None, + } + } + + /// Get typed Write input. + fn write(&self) -> Option<&WriteInput> { + match self.typed_tool_input() { + ToolInput::Write(w) => Some(w), + _ => None, + } + } + + /// Get typed Edit input. + fn edit(&self) -> Option<&EditInput> { + match self.typed_tool_input() { + ToolInput::Edit(e) => Some(e), + _ => None, + } + } + + /// Get typed Read input. + fn read(&self) -> Option<&ReadInput> { + match self.typed_tool_input() { + ToolInput::Read(r) => Some(r), + _ => None, + } + } + + /// Get typed Glob input. + fn glob(&self) -> Option<&GlobInput> { + match self.typed_tool_input() { + ToolInput::Glob(g) => Some(g), + _ => None, + } + } + + /// Get typed Grep input. + fn grep(&self) -> Option<&GrepInput> { + match self.typed_tool_input() { + ToolInput::Grep(g) => Some(g), + _ => None, + } + } + + /// Get typed WebFetch input. + fn web_fetch(&self) -> Option<&WebFetchInput> { + match self.typed_tool_input() { + ToolInput::WebFetch(w) => Some(w), + _ => None, + } + } + + /// Get typed WebSearch input. + fn web_search(&self) -> Option<&WebSearchInput> { + match self.typed_tool_input() { + ToolInput::WebSearch(w) => Some(w), + _ => None, + } + } + + /// Get typed NotebookEdit input. + fn notebook_edit(&self) -> Option<&NotebookEditInput> { + match self.typed_tool_input() { + ToolInput::NotebookEdit(n) => Some(n), + _ => None, + } + } + + /// Get typed Skill input. + fn skill(&self) -> Option<&SkillInput> { + match self.typed_tool_input() { + ToolInput::Skill(s) => Some(s), + _ => None, + } + } + + /// Get typed Agent/Task input. + fn agent(&self) -> Option<&AgentInput> { + match self.typed_tool_input() { + ToolInput::Agent(a) => Some(a), + _ => None, + } + } + + /// Returns true if this is an interactive tool (AskUserQuestion, + /// EnterPlanMode, ExitPlanMode) whose native UI would be skipped + /// by an allow decision. + fn is_interactive_tool(&self) -> bool { + matches!( + self.tool_name(), + "AskUserQuestion" | "EnterPlanMode" | "ExitPlanMode" + ) + } +} + +// ── Helper to build responses ──────────────────────────────────────── + +fn make_hso(event_name: &str) -> HookSpecificOutput { + HookSpecificOutput { + hook_event_name: event_name.to_string(), + permission_decision: None, + permission_decision_reason: None, + updated_input: None, + additional_context: None, + decision: None, + suppress_output: None, + system_message: None, + } +} + +fn hso_response(hso: HookSpecificOutput) -> Response { + Response(RawOutput { + should_continue: true, + hook_specific_output: Some(hso), + decision: None, + reason: None, + action: None, + content: None, + }) +} + +fn context_response(event_name: &str, ctx: impl Into) -> Response { + let mut hso = make_hso(event_name); + hso.additional_context = Some(ctx.into()); + hso_response(hso) +} + +fn block_response(reason: impl Into) -> Response { + Response(RawOutput { + should_continue: true, + hook_specific_output: None, + decision: Some("block".to_string()), + reason: Some(reason.into()), + action: None, + content: None, + }) +} + +// ── Macros to reduce boilerplate for trait impls ───────────────────── + +macro_rules! impl_common { + ($ty:ident) => { + impl HookEventCommon for $ty { + fn common(&self) -> &CommonFields { + &self.common + } + } + }; +} + +macro_rules! impl_common_cached { + ($ty:ident) => { + impl HookEventCommon for $ty { + fn common(&self) -> &CommonFields { + &self.cached.common + } + } + }; +} + +macro_rules! impl_tool_event { + ($ty:ident) => { + impl_common_cached!($ty); + impl ToolEvent for $ty { + fn cached_tool_input(&self) -> &CachedToolInput { + &self.cached + } + } + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// EVENT TYPES +// ═══════════════════════════════════════════════════════════════════════ + +// ── PreToolUse ─────────────────────────────────────────────────────── + +/// Fires before a tool executes. Can allow, deny, ask, or pass through. +#[derive(Debug)] +pub struct PreToolUse { + pub(crate) cached: CachedToolInput, +} + +impl_tool_event!(PreToolUse); + +impl PreToolUse { + /// Allow this tool invocation — bypasses the permission system. + pub fn allow(&self) -> Response { + self.allow_with_reason(None::) + } + + /// Allow with a reason string. + pub fn allow_with_reason(&self, reason: Option>) -> Response { + let mut hso = make_hso("PreToolUse"); + hso.permission_decision = Some(PreToolUseDecision::Allow); + hso.permission_decision_reason = reason.map(|r| r.into()); + hso_response(hso) + } + + /// Deny this tool invocation — prevents execution. + pub fn deny(&self, reason: impl Into) -> Response { + let mut hso = make_hso("PreToolUse"); + hso.permission_decision = Some(PreToolUseDecision::Deny); + hso.permission_decision_reason = Some(reason.into()); + hso_response(hso) + } + + /// Deny with additional context for Claude. + pub fn deny_with_context( + &self, + reason: impl Into, + context: impl Into, + ) -> Response { + let mut hso = make_hso("PreToolUse"); + hso.permission_decision = Some(PreToolUseDecision::Deny); + hso.permission_decision_reason = Some(reason.into()); + hso.additional_context = Some(context.into()); + hso_response(hso) + } + + /// Ask the user for confirmation before executing. + pub fn ask(&self) -> Response { + self.ask_with_reason(None::) + } + + /// Ask with a reason string. + pub fn ask_with_reason(&self, reason: Option>) -> Response { + let mut hso = make_hso("PreToolUse"); + hso.permission_decision = Some(PreToolUseDecision::Ask); + hso.permission_decision_reason = reason.map(|r| r.into()); + hso_response(hso) + } + + /// Allow and rewrite the tool input before execution. + pub fn allow_with_modified_input(&self, updated_input: serde_json::Value) -> Response { + let mut hso = make_hso("PreToolUse"); + hso.permission_decision = Some(PreToolUseDecision::Allow); + hso.updated_input = Some(updated_input); + hso_response(hso) + } + + /// Allow with both a reason and additional context. + pub fn allow_with_context( + &self, + reason: Option>, + context: impl Into, + ) -> Response { + let mut hso = make_hso("PreToolUse"); + hso.permission_decision = Some(PreToolUseDecision::Allow); + hso.permission_decision_reason = reason.map(|r| r.into()); + hso.additional_context = Some(context.into()); + hso_response(hso) + } + + /// Ask with additional context. + pub fn ask_with_context( + &self, + reason: Option>, + context: impl Into, + ) -> Response { + let mut hso = make_hso("PreToolUse"); + hso.permission_decision = Some(PreToolUseDecision::Ask); + hso.permission_decision_reason = reason.map(|r| r.into()); + hso.additional_context = Some(context.into()); + hso_response(hso) + } +} + +// ── PostToolUse ────────────────────────────────────────────────────── + +/// Fires after a tool executes successfully. +#[derive(Debug)] +pub struct PostToolUse { + pub(crate) cached: CachedToolInput, +} + +impl_tool_event!(PostToolUse); + +impl PostToolUse { + /// The tool's response (output), if available. + pub fn tool_response(&self) -> Option<&serde_json::Value> { + self.cached.fields.tool_response.as_ref() + } + + /// Provide advisory context to Claude about the tool result. + pub fn context(&self, ctx: impl Into) -> Response { + context_response("PostToolUse", ctx) + } + + /// Block after tool execution (tells Claude to disregard the result). + pub fn block(&self, reason: impl Into) -> Response { + block_response(reason) + } +} + +// ── PostToolUseFailure ─────────────────────────────────────────────── + +/// Fires after a tool fails. +#[derive(Debug)] +pub struct PostToolUseFailure { + pub(crate) cached: CachedToolInput, +} + +impl_tool_event!(PostToolUseFailure); + +impl PostToolUseFailure { + /// The error message from the failed tool. + pub fn error(&self) -> Option<&str> { + self.cached.fields.error.as_deref() + } + + /// Whether this failure was caused by an interrupt. + pub fn is_interrupt(&self) -> bool { + self.cached.fields.is_interrupt.unwrap_or(false) + } + + /// Provide advisory context about the failure. + pub fn context(&self, ctx: impl Into) -> Response { + context_response("PostToolUseFailure", ctx) + } +} + +// ── PermissionRequest ──────────────────────────────────────────────── + +/// Fires when Claude Code's permission dialog would appear. +/// The hook can approve or deny on behalf of the user. +#[derive(Debug)] +pub struct PermissionRequest { + pub(crate) cached: CachedToolInput, +} + +impl_tool_event!(PermissionRequest); + +impl PermissionRequest { + /// Approve the permission request. + pub fn approve(&self) -> Response { + self.approve_with_input(None) + } + + /// Approve and optionally rewrite the tool input. + pub fn approve_with_input(&self, updated_input: Option) -> Response { + let mut hso = make_hso("PermissionRequest"); + hso.decision = Some(PermissionDecision { + behavior: PermissionBehavior::Allow, + updated_input, + message: None, + interrupt: None, + }); + hso_response(hso) + } + + /// Deny the permission request. + pub fn deny(&self, message: impl Into) -> Response { + let mut hso = make_hso("PermissionRequest"); + hso.decision = Some(PermissionDecision { + behavior: PermissionBehavior::Deny, + updated_input: None, + message: Some(message.into()), + interrupt: Some(false), + }); + hso_response(hso) + } + + /// Deny and interrupt Claude's current turn. + pub fn deny_and_interrupt(&self, message: impl Into) -> Response { + let mut hso = make_hso("PermissionRequest"); + hso.decision = Some(PermissionDecision { + behavior: PermissionBehavior::Deny, + updated_input: None, + message: Some(message.into()), + interrupt: Some(true), + }); + hso_response(hso) + } +} + +// ── SessionStart ───────────────────────────────────────────────────── + +/// Fires when a new session starts, resumes, clears, or compacts. +#[derive(Debug, Clone)] +pub struct SessionStart { + pub(crate) common: CommonFields, + pub(crate) fields: SessionStartFields, +} + +impl_common!(SessionStart); + +impl SessionStart { + /// The session source (e.g., "startup", "resume", "clear", "compact"). + pub fn source(&self) -> Option<&str> { + self.fields.source.as_deref() + } + + /// The model being used. + pub fn model(&self) -> Option<&str> { + self.fields.model.as_deref() + } + + /// Inject context into the session (appears in Claude's system prompt). + pub fn context(&self, ctx: impl Into) -> Response { + context_response("SessionStart", ctx) + } +} + +// ── SessionEnd ─────────────────────────────────────────────────────── + +/// Fires when a session ends. +#[derive(Debug, Clone)] +pub struct SessionEnd { + pub(crate) common: CommonFields, + pub(crate) fields: SessionEndFields, +} + +impl_common!(SessionEnd); + +impl SessionEnd { + /// The exit reason (e.g., "clear", "resume", "logout"). + pub fn reason(&self) -> Option<&str> { + self.fields.reason.as_deref() + } +} + +// ── UserPromptSubmit ───────────────────────────────────────────────── + +/// Fires when the user submits a prompt. +#[derive(Debug, Clone)] +pub struct UserPromptSubmit { + pub(crate) common: CommonFields, + pub(crate) fields: UserPromptSubmitFields, +} + +impl_common!(UserPromptSubmit); + +impl UserPromptSubmit { + /// The submitted prompt text. + pub fn prompt(&self) -> Option<&str> { + self.fields.prompt.as_deref() + } + + /// Block this prompt from being processed. + pub fn block(&self, reason: impl Into) -> Response { + block_response(reason) + } + + /// Inject additional context alongside the prompt. + pub fn context(&self, ctx: impl Into) -> Response { + context_response("UserPromptSubmit", ctx) + } +} + +// ── Stop ───────────────────────────────────────────────────────────── + +/// Fires when Claude finishes responding. +#[derive(Debug, Clone)] +pub struct Stop { + pub(crate) common: CommonFields, + pub(crate) fields: StopFields, +} + +impl_common!(Stop); + +impl Stop { + /// Whether a stop hook is currently active. + pub fn stop_hook_active(&self) -> bool { + self.fields.stop_hook_active.unwrap_or(false) + } + + /// Claude's last assistant message. + pub fn last_assistant_message(&self) -> Option<&str> { + self.fields.last_assistant_message.as_deref() + } + + /// Force Claude to continue the conversation instead of stopping. + pub fn block(&self, reason: impl Into) -> Response { + block_response(reason) + } +} + +// ── StopFailure ────────────────────────────────────────────────────── + +/// Fires when a turn ends due to an API error. +#[derive(Debug, Clone)] +pub struct StopFailure { + pub(crate) common: CommonFields, + pub(crate) fields: StopFailureFields, +} + +impl_common!(StopFailure); + +impl StopFailure { + /// The error type (e.g., "rate_limit", "authentication_failed"). + pub fn error(&self) -> Option<&str> { + self.fields.error.as_deref() + } + + /// Additional error details. + pub fn error_details(&self) -> Option<&serde_json::Value> { + self.fields.error_details.as_ref() + } + + /// Claude's last assistant message before the failure. + pub fn last_assistant_message(&self) -> Option<&str> { + self.fields.last_assistant_message.as_deref() + } +} + +// ── SubagentStart ──────────────────────────────────────────────────── + +/// Fires when a subagent is spawned. +#[derive(Debug, Clone)] +pub struct SubagentStart { + pub(crate) common: CommonFields, + pub(crate) fields: SubagentStartFields, +} + +impl_common!(SubagentStart); + +impl SubagentStart { + /// The subagent's ID. + pub fn agent_id(&self) -> Option<&str> { + self.fields.agent_id.as_deref() + } + + /// The subagent's type. + pub fn agent_type(&self) -> Option<&str> { + self.fields.agent_type.as_deref() + } + + /// Inject context for the subagent. + pub fn context(&self, ctx: impl Into) -> Response { + context_response("SubagentStart", ctx) + } +} + +// ── SubagentStop ───────────────────────────────────────────────────── + +/// Fires when a subagent finishes. +#[derive(Debug, Clone)] +pub struct SubagentStop { + pub(crate) common: CommonFields, + pub(crate) fields: SubagentStopFields, +} + +impl_common!(SubagentStop); + +impl SubagentStop { + /// The subagent's ID. + pub fn agent_id(&self) -> Option<&str> { + self.fields.agent_id.as_deref() + } + + /// The subagent's type. + pub fn agent_type(&self) -> Option<&str> { + self.fields.agent_type.as_deref() + } + + /// The subagent's transcript path. + pub fn agent_transcript_path(&self) -> Option<&str> { + self.fields.agent_transcript_path.as_deref() + } + + /// The subagent's last assistant message. + pub fn last_assistant_message(&self) -> Option<&str> { + self.fields.last_assistant_message.as_deref() + } + + /// Block the subagent's completion. + pub fn block(&self, reason: impl Into) -> Response { + block_response(reason) + } +} + +// ── TeammateIdle ───────────────────────────────────────────────────── + +/// Fires when an agent team teammate becomes idle. +#[derive(Debug, Clone)] +pub struct TeammateIdle { + pub(crate) common: CommonFields, + pub(crate) fields: TeammateIdleFields, +} + +impl_common!(TeammateIdle); + +impl TeammateIdle { + pub fn teammate_name(&self) -> Option<&str> { + self.fields.teammate_name.as_deref() + } + + pub fn team_name(&self) -> Option<&str> { + self.fields.team_name.as_deref() + } +} + +// ── TaskCompleted ──────────────────────────────────────────────────── + +/// Fires when a task is marked complete. +#[derive(Debug, Clone)] +pub struct TaskCompleted { + pub(crate) common: CommonFields, + pub(crate) fields: TaskCompletedFields, +} + +impl_common!(TaskCompleted); + +impl TaskCompleted { + pub fn teammate_name(&self) -> Option<&str> { + self.fields.teammate_name.as_deref() + } + + pub fn team_name(&self) -> Option<&str> { + self.fields.team_name.as_deref() + } + + pub fn task_id(&self) -> Option<&str> { + self.fields.task_id.as_deref() + } + + pub fn task_subject(&self) -> Option<&str> { + self.fields.task_subject.as_deref() + } + + pub fn task_description(&self) -> Option<&str> { + self.fields.task_description.as_deref() + } +} + +// ── InstructionsLoaded ─────────────────────────────────────────────── + +/// Fires when CLAUDE.md or similar instructions are loaded. +#[derive(Debug, Clone)] +pub struct InstructionsLoaded { + pub(crate) common: CommonFields, + pub(crate) fields: InstructionsLoadedFields, +} + +impl_common!(InstructionsLoaded); + +impl InstructionsLoaded { + pub fn file_path(&self) -> Option<&str> { + self.fields.file_path.as_deref() + } + + pub fn memory_type(&self) -> Option<&str> { + self.fields.memory_type.as_deref() + } + + pub fn load_reason(&self) -> Option<&str> { + self.fields.load_reason.as_deref() + } +} + +// ── ConfigChange ───────────────────────────────────────────────────── + +/// Fires when a settings file changes. +#[derive(Debug, Clone)] +pub struct ConfigChange { + pub(crate) common: CommonFields, + pub(crate) fields: ConfigChangeFields, +} + +impl_common!(ConfigChange); + +impl ConfigChange { + /// The config source (e.g., "user_settings", "project_settings"). + pub fn source(&self) -> Option<&str> { + self.fields.source.as_deref() + } + + pub fn file_path(&self) -> Option<&str> { + self.fields.file_path.as_deref() + } + + /// Block the config change. + pub fn block(&self, reason: impl Into) -> Response { + block_response(reason) + } +} + +// ── PreCompact / PostCompact ───────────────────────────────────────── + +/// Fires before context compaction. +#[derive(Debug, Clone)] +pub struct PreCompact { + pub(crate) common: CommonFields, + pub(crate) fields: PreCompactFields, +} + +impl_common!(PreCompact); + +impl PreCompact { + /// What triggered compaction (e.g., "manual", "auto"). + pub fn trigger(&self) -> Option<&str> { + self.fields.trigger.as_deref() + } + + pub fn custom_instructions(&self) -> Option<&str> { + self.fields.custom_instructions.as_deref() + } +} + +/// Fires after context compaction. +#[derive(Debug, Clone)] +pub struct PostCompact { + pub(crate) common: CommonFields, + pub(crate) fields: PostCompactFields, +} + +impl_common!(PostCompact); + +impl PostCompact { + pub fn trigger(&self) -> Option<&str> { + self.fields.trigger.as_deref() + } + + pub fn compact_summary(&self) -> Option<&str> { + self.fields.compact_summary.as_deref() + } +} + +// ── Notification ───────────────────────────────────────────────────── + +/// Fires when Claude Code sends a notification. +#[derive(Debug, Clone)] +pub struct Notification { + pub(crate) common: CommonFields, + pub(crate) fields: NotificationFields, +} + +impl_common!(Notification); + +impl Notification { + pub fn message(&self) -> Option<&str> { + self.fields.message.as_deref() + } + + pub fn title(&self) -> Option<&str> { + self.fields.title.as_deref() + } + + pub fn notification_type(&self) -> Option<&str> { + self.fields.notification_type.as_deref() + } + + /// Provide advisory context. + pub fn context(&self, ctx: impl Into) -> Response { + context_response("Notification", ctx) + } +} + +// ── Elicitation ────────────────────────────────────────────────────── + +/// Fires when an MCP server requests user input. +#[derive(Debug, Clone)] +pub struct Elicitation { + pub(crate) common: CommonFields, + pub(crate) fields: ElicitationFields, +} + +impl_common!(Elicitation); + +impl Elicitation { + pub fn mcp_server_name(&self) -> Option<&str> { + self.fields.mcp_server_name.as_deref() + } + + pub fn message(&self) -> Option<&str> { + self.fields.message.as_deref() + } + + pub fn elicitation_id(&self) -> Option<&str> { + self.fields.elicitation_id.as_deref() + } + + pub fn requested_schema(&self) -> Option<&serde_json::Value> { + self.fields.requested_schema.as_ref() + } + + /// Accept the elicitation with content. + pub fn accept(&self, content: serde_json::Value) -> Response { + Response(RawOutput { + should_continue: true, + hook_specific_output: None, + decision: None, + reason: None, + action: Some("accept".to_string()), + content: Some(content), + }) + } + + /// Decline the elicitation. + pub fn decline(&self) -> Response { + Response(RawOutput { + should_continue: true, + hook_specific_output: None, + decision: None, + reason: None, + action: Some("decline".to_string()), + content: None, + }) + } + + /// Cancel the elicitation. + pub fn cancel(&self) -> Response { + Response(RawOutput { + should_continue: true, + hook_specific_output: None, + decision: None, + reason: None, + action: Some("cancel".to_string()), + content: None, + }) + } +} + +// ── ElicitationResult ──────────────────────────────────────────────── + +/// Fires when the user responds to an MCP elicitation. +#[derive(Debug, Clone)] +pub struct ElicitationResult { + pub(crate) common: CommonFields, + pub(crate) fields: ElicitationResultFields, +} + +impl_common!(ElicitationResult); + +impl ElicitationResult { + pub fn mcp_server_name(&self) -> Option<&str> { + self.fields.mcp_server_name.as_deref() + } + + pub fn action(&self) -> Option<&str> { + self.fields.action.as_deref() + } + + pub fn content(&self) -> Option<&serde_json::Value> { + self.fields.content.as_ref() + } +} + +// ── WorktreeCreate ─────────────────────────────────────────────────── + +/// Fires when a git worktree is created. +#[derive(Debug, Clone)] +pub struct WorktreeCreate { + pub(crate) common: CommonFields, + pub(crate) fields: WorktreeCreateFields, +} + +impl_common!(WorktreeCreate); + +impl WorktreeCreate { + pub fn name(&self) -> Option<&str> { + self.fields.name.as_deref() + } +} + +// ── WorktreeRemove ─────────────────────────────────────────────────── + +/// Fires when a git worktree is removed. +#[derive(Debug, Clone)] +pub struct WorktreeRemove { + pub(crate) common: CommonFields, + pub(crate) fields: WorktreeRemoveFields, +} + +impl_common!(WorktreeRemove); + +impl WorktreeRemove { + pub fn worktree_path(&self) -> Option<&str> { + self.fields.worktree_path.as_deref() + } +} + +// ── Unknown ────────────────────────────────────────────────────────── + +/// An event type not recognized by this library version. +#[derive(Debug, Clone)] +pub struct RawEvent { + pub(crate) common: CommonFields, + pub(crate) extra: serde_json::Map, +} + +impl_common!(RawEvent); + +impl RawEvent { + /// Access any extra fields not modeled by this library. + pub fn extra(&self) -> &serde_json::Map { + &self.extra + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// TESTS +// ═══════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + fn pre_tool_use_json() -> &'static str { + r#"{ + "session_id": "test-session", + "transcript_path": "/tmp/transcript.jsonl", + "cwd": "/home/user/project", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git status", "timeout": 120000}, + "tool_use_id": "toolu_01ABC" + }"# + } + + fn session_start_json() -> &'static str { + r#"{ + "session_id": "test-session", + "transcript_path": "/tmp/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "SessionStart", + "source": "startup", + "model": "claude-sonnet-4-20250514" + }"# + } + + #[test] + fn test_recv_pre_tool_use() { + let event = crate::recv_from(pre_tool_use_json().as_bytes()).unwrap(); + match event { + HookEvent::PreToolUse(e) => { + assert_eq!(e.session_id(), "test-session"); + assert_eq!(e.tool_name(), "Bash"); + let bash = e.bash().unwrap(); + assert_eq!(bash.command, "git status"); + } + other => panic!("expected PreToolUse, got {:?}", other), + } + } + + #[test] + fn test_recv_session_start() { + let event = crate::recv_from(session_start_json().as_bytes()).unwrap(); + match event { + HookEvent::SessionStart(e) => { + assert_eq!(e.session_id(), "test-session"); + assert_eq!(e.source(), Some("startup")); + assert_eq!(e.model(), Some("claude-sonnet-4-20250514")); + } + other => panic!("expected SessionStart, got {:?}", other), + } + } + + #[test] + fn test_unknown_event() { + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "FutureEvent", "new_field": 42 + }"#; + let event = crate::recv_from(json.as_bytes()).unwrap(); + assert!(matches!(event, HookEvent::Unknown(_))); + } + + #[test] + fn test_allow_response_serialization() { + let event = crate::recv_from(pre_tool_use_json().as_bytes()).unwrap(); + let response = match event { + HookEvent::PreToolUse(e) => e.allow(), + _ => unreachable!(), + }; + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(json["continue"], true); + assert_eq!(json["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + assert_eq!( + json["hookSpecificOutput"]["permissionDecision"], + "allow" + ); + } + + #[test] + fn test_deny_response_serialization() { + let event = crate::recv_from(pre_tool_use_json().as_bytes()).unwrap(); + let response = match event { + HookEvent::PreToolUse(e) => e.deny("not allowed"), + _ => unreachable!(), + }; + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["permissionDecision"], + "deny" + ); + assert_eq!( + json["hookSpecificOutput"]["permissionDecisionReason"], + "not allowed" + ); + } + + #[test] + fn test_permission_request_approve() { + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "permission_mode": "default", + "hook_event_name": "PermissionRequest", + "tool_name": "Bash", + "tool_input": {"command": "rm -rf /"} + }"#; + let event = crate::recv_from(json.as_bytes()).unwrap(); + let response = match event { + HookEvent::PermissionRequest(e) => e.approve(), + _ => unreachable!(), + }; + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["hookEventName"], + "PermissionRequest" + ); + assert_eq!( + json["hookSpecificOutput"]["decision"]["behavior"], + "allow" + ); + } + + #[test] + fn test_permission_request_deny() { + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "permission_mode": "default", + "hook_event_name": "PermissionRequest", + "tool_name": "Bash", + "tool_input": {"command": "rm -rf /"} + }"#; + let event = crate::recv_from(json.as_bytes()).unwrap(); + let response = match event { + HookEvent::PermissionRequest(e) => e.deny_and_interrupt("absolutely not"), + _ => unreachable!(), + }; + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["decision"]["behavior"], + "deny" + ); + assert_eq!( + json["hookSpecificOutput"]["decision"]["message"], + "absolutely not" + ); + assert_eq!(json["hookSpecificOutput"]["decision"]["interrupt"], true); + } + + #[test] + fn test_session_start_context() { + let event = crate::recv_from(session_start_json().as_bytes()).unwrap(); + let response = match event { + HookEvent::SessionStart(e) => e.context("Hook is active"), + _ => unreachable!(), + }; + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["hookEventName"], + "SessionStart" + ); + assert_eq!( + json["hookSpecificOutput"]["additionalContext"], + "Hook is active" + ); + } + + #[test] + fn test_pass_through() { + let response = crate::pass(); + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(json["continue"], true); + assert!(json.get("hookSpecificOutput").is_none()); + } + + #[test] + fn test_post_tool_use_context() { + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "permission_mode": "default", + "hook_event_name": "PostToolUse", + "tool_name": "Bash", + "tool_input": {"command": "ls"}, + "tool_response": {"output": "file1 file2"} + }"#; + let event = crate::recv_from(json.as_bytes()).unwrap(); + let response = match event { + HookEvent::PostToolUse(e) => { + assert!(e.tool_response().is_some()); + e.context("observed ls output") + } + _ => unreachable!(), + }; + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let out: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!( + out["hookSpecificOutput"]["additionalContext"], + "observed ls output" + ); + } + + #[test] + fn test_typed_write_input() { + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": {"file_path": "/tmp/test.txt", "content": "hello"} + }"#; + let event = crate::recv_from(json.as_bytes()).unwrap(); + match event { + HookEvent::PreToolUse(e) => { + let w = e.write().unwrap(); + assert_eq!(w.file_path, "/tmp/test.txt"); + assert_eq!(w.content, "hello"); + } + _ => unreachable!(), + } + } + + #[test] + fn test_interactive_tool_detection() { + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "AskUserQuestion", + "tool_input": {} + }"#; + let event = crate::recv_from(json.as_bytes()).unwrap(); + match event { + HookEvent::PreToolUse(e) => { + assert!(e.is_interactive_tool()); + } + _ => unreachable!(), + } + } + + #[test] + fn test_elicitation_accept_serializes_action_and_content() { + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "Elicitation", + "mcp_server_name": "test-mcp", + "message": "Enter your API key", + "elicitation_id": "elic_01" + }"#; + let event = crate::recv_from(json.as_bytes()).unwrap(); + let response = match event { + HookEvent::Elicitation(e) => { + e.accept(serde_json::json!({"api_key": "sk-123"})) + } + _ => unreachable!(), + }; + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let out: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(out["continue"], true); + assert_eq!(out["action"], "accept"); + assert_eq!(out["content"]["api_key"], "sk-123"); + // Should NOT have decision field + assert!(out.get("decision").is_none()); + } + + #[test] + fn test_elicitation_decline_serialization() { + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "Elicitation" + }"#; + let event = crate::recv_from(json.as_bytes()).unwrap(); + let response = match event { + HookEvent::Elicitation(e) => e.decline(), + _ => unreachable!(), + }; + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let out: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(out["action"], "decline"); + assert!(out.get("content").is_none()); + } + + #[test] + fn test_elicitation_cancel_serialization() { + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "Elicitation" + }"#; + let event = crate::recv_from(json.as_bytes()).unwrap(); + let response = match event { + HookEvent::Elicitation(e) => e.cancel(), + _ => unreachable!(), + }; + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let out: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(out["action"], "cancel"); + } + + #[test] + fn test_response_with_context_builder() { + let event = crate::recv_from(pre_tool_use_json().as_bytes()).unwrap(); + let response = match event { + HookEvent::PreToolUse(e) => e.allow().with_context("sandbox active"), + _ => unreachable!(), + }; + let mut buf = Vec::new(); + crate::send_to(&response, &mut buf).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["additionalContext"], + "sandbox active" + ); + } +} diff --git a/clash_hooks/src/lib.rs b/clash_hooks/src/lib.rs new file mode 100644 index 0000000..0067190 --- /dev/null +++ b/clash_hooks/src/lib.rs @@ -0,0 +1,150 @@ +//! Type-safe Rust library for Claude Code hooks. +//! +//! This crate models the complete Claude Code hooks protocol — the stdin/stdout +//! JSON pipe that Claude Code uses to communicate with hook processes. It +//! provides: +//! +//! - **[`recv()`]** / **[`send()`]** — read an event from stdin, write a response to stdout +//! - **Event types** — one struct per hook event, each with typed response constructors +//! - **Tool input accessors** — `event.bash()`, `event.write()`, etc. +//! - **[`Response`]** — opaque type that serializes to the correct JSON wire format +//! +//! # Quick start +//! +//! ```no_run +//! use clash_hooks::{recv, send, HookEvent, HookEventCommon, ToolEvent}; +//! +//! fn main() -> Result<(), clash_hooks::Error> { +//! let event = recv()?; +//! let response = match event { +//! HookEvent::PreToolUse(e) => { +//! if e.bash().is_some_and(|b| b.command.contains("rm -rf /")) { +//! e.deny("nope") +//! } else { +//! e.pass() +//! } +//! } +//! HookEvent::SessionStart(e) => e.context("My hook is active."), +//! _ => clash_hooks::pass(), +//! }; +//! send(&response)?; +//! Ok(()) +//! } +//! ``` +//! +//! # Design +//! +//! Every event type has its own response constructors, so you **cannot** return +//! a SessionStart response from a PreToolUse handler — the type system prevents +//! it. The [`Response`] type is opaque; you build it via methods on event +//! structs and it serializes correctly. +//! +//! All event and tool input types are `#[non_exhaustive]`, so new fields added +//! by future Claude Code versions won't break your code. + +pub mod event; +pub mod response; +pub mod tool_input; +pub mod wire; + +pub use event::{HookEvent, HookEventCommon, ToolEvent}; +pub use response::Response; +pub use tool_input::{ + AgentInput, BashInput, EditInput, GlobInput, GrepInput, NotebookEditInput, ReadInput, + SkillInput, ToolInput, WebFetchInput, WebSearchInput, WriteInput, +}; +pub use wire::{CommonFields, PreToolUseDecision}; + +use std::io::{Read, Write}; + +/// Errors from parsing hook input or writing hook output. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// Failed to parse hook input JSON from stdin. + #[error("failed to parse hook input: {0}")] + InvalidInput(#[from] serde_json::Error), + + /// Failed to write hook response to stdout. + #[error("failed to write hook response: {0}")] + Output(#[from] std::io::Error), +} + +/// Exit codes defined by the Claude Code hooks protocol. +pub mod exit_code { + /// Success — response JSON was written to stdout. + pub const SUCCESS: i32 = 0; + /// Blocking error — stderr message is fed back to Claude as context. + pub const BLOCKING_ERROR: i32 = 2; +} + +// ── Core API ──────────────────────────────────────────────────────── + +/// Read a hook event from stdin. +/// +/// This reads the full JSON object that Claude Code writes to the hook +/// process's stdin, and returns the appropriate [`HookEvent`] variant. +pub fn recv() -> Result { + recv_from(std::io::stdin().lock()) +} + +/// Read a hook event from any [`Read`] source (useful for testing). +pub fn recv_from(reader: impl Read) -> Result { + let envelope: wire::Envelope = serde_json::from_reader(reader)?; + let payload = wire::parse_payload(&envelope.common.hook_event_name, envelope.extra); + Ok(HookEvent::from_parsed(envelope.common, payload)) +} + +/// Write a hook response to stdout. +pub fn send(response: &Response) -> Result<(), Error> { + send_to(response, std::io::stdout().lock()) +} + +/// Write a hook response to any [`Write`] sink (useful for testing). +pub fn send_to(response: &Response, mut writer: impl Write) -> Result<(), Error> { + serde_json::to_writer(&mut writer, &response.0)?; + writeln!(writer)?; + Ok(()) +} + +/// Convenience: a pass-through response that works for any event. +/// +/// Equivalent to `continue_execution` — tells Claude Code to proceed with +/// default behavior. +pub fn pass() -> Response { + Response(wire::RawOutput { + should_continue: true, + hook_specific_output: None, + decision: None, + reason: None, + action: None, + content: None, + }) +} + +/// Run a hook with proper exit code handling. +/// +/// Reads an event from stdin, calls `f`, writes the response to stdout on +/// success, or prints the error to stderr and exits with +/// [`exit_code::BLOCKING_ERROR`] on failure. +/// +/// This function never returns. +pub fn main( + f: impl FnOnce(HookEvent) -> Result>, +) -> ! { + let result = (|| { + let event = recv()?; + let response = f(event) + .map_err(|e| Error::Output(std::io::Error::new(std::io::ErrorKind::Other, e)))?; + send(&response)?; + Ok::<(), Error>(()) + })(); + + match result { + Ok(()) => std::process::exit(exit_code::SUCCESS), + Err(e) => { + eprintln!("{e}"); + std::process::exit(exit_code::BLOCKING_ERROR); + } + } +} diff --git a/clash_hooks/src/response.rs b/clash_hooks/src/response.rs new file mode 100644 index 0000000..74058bf --- /dev/null +++ b/clash_hooks/src/response.rs @@ -0,0 +1,75 @@ +//! The opaque [`Response`] type that serializes to correct Claude Code JSON. +//! +//! You never construct a `Response` directly — instead, call methods on event +//! types (e.g., `pre_tool_use.allow()`) or use [`crate::pass()`]. + +use crate::wire::RawOutput; + +/// An opaque hook response that serializes to the correct JSON wire format. +/// +/// Built via methods on event types or [`crate::pass()`]. Cannot be constructed +/// directly by library consumers. +#[derive(Debug, Clone)] +pub struct Response(pub(crate) RawOutput); + +impl Response { + /// Set advisory context that Claude sees after this hook runs. + /// + /// # Panics (debug builds only) + /// Panics if called on a response that has no `hookSpecificOutput` + /// (e.g. a `pass()`, `block()`, or elicitation response). + pub fn with_context(mut self, ctx: impl Into) -> Self { + debug_assert!( + self.0.hook_specific_output.is_some(), + "with_context() called on a response with no hookSpecificOutput — data would be silently dropped" + ); + if let Some(ref mut hso) = self.0.hook_specific_output { + hso.additional_context = Some(ctx.into()); + } + self + } + + /// Set a system message (warning to the user, shown in the UI). + /// + /// # Panics (debug builds only) + /// Panics if called on a response that has no `hookSpecificOutput`. + pub fn with_system_message(mut self, msg: impl Into) -> Self { + debug_assert!( + self.0.hook_specific_output.is_some(), + "with_system_message() called on a response with no hookSpecificOutput — data would be silently dropped" + ); + if let Some(ref mut hso) = self.0.hook_specific_output { + hso.system_message = Some(msg.into()); + } + self + } + + /// Suppress this hook's stdout from verbose mode output. + /// + /// # Panics (debug builds only) + /// Panics if called on a response that has no `hookSpecificOutput`. + pub fn suppress_output(mut self) -> Self { + debug_assert!( + self.0.hook_specific_output.is_some(), + "suppress_output() called on a response with no hookSpecificOutput — data would be silently dropped" + ); + if let Some(ref mut hso) = self.0.hook_specific_output { + hso.suppress_output = Some(true); + } + self + } + + /// Access the underlying wire output (for advanced use or testing). + pub fn as_raw(&self) -> &RawOutput { + &self.0 + } +} + +#[cfg(test)] +mod tests { + #[test] + #[should_panic(expected = "with_context() called on a response with no hookSpecificOutput")] + fn test_with_context_panics_on_pass() { + crate::pass().with_context("should panic"); + } +} diff --git a/clash_hooks/src/tool_input.rs b/clash_hooks/src/tool_input.rs new file mode 100644 index 0000000..9e4bf38 --- /dev/null +++ b/clash_hooks/src/tool_input.rs @@ -0,0 +1,181 @@ +//! Typed tool input structs for well-known Claude Code tools. +//! +//! Each struct models the `tool_input` JSON for a specific tool. Use the +//! typed accessors on [`crate::event::PreToolUse`] (e.g., `.bash()`, +//! `.write()`) to get these, or use [`ToolInput::parse`] directly. + +use serde::Deserialize; + +/// Typed tool input, parsed from the raw `tool_input` JSON. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum ToolInput { + Bash(BashInput), + Write(WriteInput), + Edit(EditInput), + Read(ReadInput), + Glob(GlobInput), + Grep(GrepInput), + WebFetch(WebFetchInput), + WebSearch(WebSearchInput), + NotebookEdit(NotebookEditInput), + Skill(SkillInput), + Agent(AgentInput), + /// Tool not recognized by this library version, or parse failed. + Unknown(serde_json::Value), +} + +impl ToolInput { + /// Parse a typed tool input from the tool name and raw JSON value. + pub fn parse(tool_name: &str, raw: &serde_json::Value) -> Self { + match tool_name { + "Bash" => try_parse(raw, ToolInput::Bash), + "Write" => try_parse(raw, ToolInput::Write), + "Edit" => try_parse(raw, ToolInput::Edit), + "Read" => try_parse(raw, ToolInput::Read), + "Glob" => try_parse(raw, ToolInput::Glob), + "Grep" => try_parse(raw, ToolInput::Grep), + "WebFetch" => try_parse(raw, ToolInput::WebFetch), + "WebSearch" => try_parse(raw, ToolInput::WebSearch), + "NotebookEdit" => try_parse(raw, ToolInput::NotebookEdit), + "Skill" => try_parse(raw, ToolInput::Skill), + "Agent" | "Task" => try_parse(raw, ToolInput::Agent), + _ => ToolInput::Unknown(raw.clone()), + } + } +} + +fn try_parse Deserialize<'de>>( + raw: &serde_json::Value, + wrap: fn(T) -> ToolInput, +) -> ToolInput { + serde_json::from_value(raw.clone()) + .map(wrap) + .unwrap_or_else(|_| ToolInput::Unknown(raw.clone())) +} + +/// Bash tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct BashInput { + pub command: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub run_in_background: Option, + #[serde(default, rename = "dangerouslyDisableSandbox")] + pub dangerously_disable_sandbox: Option, +} + +/// Write tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct WriteInput { + pub file_path: String, + pub content: String, +} + +/// Edit tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct EditInput { + pub file_path: String, + pub old_string: String, + pub new_string: String, + #[serde(default)] + pub replace_all: Option, +} + +/// Read tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct ReadInput { + pub file_path: String, + #[serde(default)] + pub offset: Option, + #[serde(default)] + pub limit: Option, +} + +/// Glob tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct GlobInput { + pub pattern: String, + #[serde(default)] + pub path: Option, +} + +/// Grep tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct GrepInput { + pub pattern: String, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub glob: Option, + #[serde(default)] + pub output_mode: Option, + #[serde(default, rename = "type")] + pub file_type: Option, + #[serde(default)] + pub multiline: Option, +} + +/// WebFetch tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct WebFetchInput { + pub url: String, + pub prompt: String, +} + +/// WebSearch tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct WebSearchInput { + pub query: String, + #[serde(default)] + pub allowed_domains: Option>, + #[serde(default)] + pub blocked_domains: Option>, +} + +/// NotebookEdit tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct NotebookEditInput { + pub notebook_path: String, + pub new_source: String, + #[serde(default)] + pub cell_id: Option, + #[serde(default)] + pub cell_type: Option, + #[serde(default)] + pub edit_mode: Option, +} + +/// Skill tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct SkillInput { + pub skill: String, + #[serde(default)] + pub args: Option, +} + +/// Agent/Task tool input. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct AgentInput { + pub prompt: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub subagent_type: Option, + #[serde(default)] + pub model: Option, +} diff --git a/clash_hooks/src/wire.rs b/clash_hooks/src/wire.rs new file mode 100644 index 0000000..fdbc0c4 --- /dev/null +++ b/clash_hooks/src/wire.rs @@ -0,0 +1,373 @@ +//! Wire format types — the raw JSON structures Claude Code sends and expects. +//! +//! These types map 1:1 to the protocol JSON. They are public so advanced users +//! can inspect or construct raw responses, but most consumers should use the +//! typed event API in [`crate::event`] instead. + +use serde::{Deserialize, Serialize}; + +// ── Input (stdin) ─────────────────────────────────────────────────── + +/// Fields shared by every hook event. +#[derive(Debug, Clone, Deserialize)] +pub struct CommonFields { + pub session_id: String, + pub transcript_path: String, + pub cwd: String, + #[serde(default)] + pub permission_mode: Option, + pub hook_event_name: String, +} + +/// Fields specific to tool events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest). +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ToolFields { + #[serde(default)] + pub tool_name: Option, + #[serde(default)] + pub tool_input: Option, + #[serde(default)] + pub tool_use_id: Option, + #[serde(default)] + pub tool_response: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub is_interrupt: Option, +} + +/// Fields specific to SessionStart events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct SessionStartFields { + #[serde(default)] + pub source: Option, + #[serde(default)] + pub model: Option, +} + +/// Fields specific to SessionEnd events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct SessionEndFields { + #[serde(default)] + pub reason: Option, +} + +/// Fields specific to UserPromptSubmit events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct UserPromptSubmitFields { + #[serde(default)] + pub prompt: Option, +} + +/// Fields specific to Stop events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct StopFields { + #[serde(default)] + pub stop_hook_active: Option, + #[serde(default)] + pub last_assistant_message: Option, +} + +/// Fields specific to StopFailure events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct StopFailureFields { + #[serde(default)] + pub error: Option, + #[serde(default)] + pub error_details: Option, + #[serde(default)] + pub last_assistant_message: Option, +} + +/// Fields specific to SubagentStart events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct SubagentStartFields { + #[serde(default)] + pub agent_id: Option, + #[serde(default)] + pub agent_type: Option, +} + +/// Fields specific to SubagentStop events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct SubagentStopFields { + #[serde(default)] + pub agent_id: Option, + #[serde(default)] + pub agent_type: Option, + #[serde(default)] + pub agent_transcript_path: Option, + #[serde(default)] + pub last_assistant_message: Option, +} + +/// Fields specific to TeammateIdle events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct TeammateIdleFields { + #[serde(default)] + pub teammate_name: Option, + #[serde(default)] + pub team_name: Option, +} + +/// Fields specific to TaskCompleted events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct TaskCompletedFields { + #[serde(default)] + pub teammate_name: Option, + #[serde(default)] + pub team_name: Option, + #[serde(default)] + pub task_id: Option, + #[serde(default)] + pub task_subject: Option, + #[serde(default)] + pub task_description: Option, +} + +/// Fields specific to InstructionsLoaded events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct InstructionsLoadedFields { + #[serde(default)] + pub file_path: Option, + #[serde(default)] + pub memory_type: Option, + #[serde(default)] + pub load_reason: Option, + #[serde(default)] + pub globs: Option>, + #[serde(default)] + pub trigger_file_path: Option, + #[serde(default)] + pub parent_file_path: Option, +} + +/// Fields specific to ConfigChange events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ConfigChangeFields { + #[serde(default)] + pub source: Option, + #[serde(default)] + pub file_path: Option, +} + +/// Fields specific to PreCompact events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct PreCompactFields { + #[serde(default)] + pub trigger: Option, + #[serde(default)] + pub custom_instructions: Option, +} + +/// Fields specific to PostCompact events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct PostCompactFields { + #[serde(default)] + pub trigger: Option, + #[serde(default)] + pub compact_summary: Option, +} + +/// Fields specific to Notification events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct NotificationFields { + #[serde(default)] + pub message: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub notification_type: Option, +} + +/// Fields specific to Elicitation events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ElicitationFields { + #[serde(default)] + pub mcp_server_name: Option, + #[serde(default)] + pub message: Option, + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub elicitation_id: Option, + #[serde(default)] + pub requested_schema: Option, +} + +/// Fields specific to ElicitationResult events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ElicitationResultFields { + #[serde(default)] + pub mcp_server_name: Option, + #[serde(default)] + pub action: Option, + #[serde(default)] + pub content: Option, +} + +/// Fields specific to WorktreeCreate events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct WorktreeCreateFields { + #[serde(default)] + pub name: Option, +} + +/// Fields specific to WorktreeRemove events. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct WorktreeRemoveFields { + #[serde(default)] + pub worktree_path: Option, +} + +// ── Two-phase deserialization ──────────────────────────────────────── + +/// Phase-1 envelope: common fields + flattened catch-all for the rest. +#[derive(Debug, Deserialize)] +pub(crate) struct Envelope { + #[serde(flatten)] + pub common: CommonFields, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// The parsed per-event payload after two-phase deserialization. +#[derive(Debug, Clone)] +pub(crate) enum EventPayload { + Tool(ToolFields), + SessionStart(SessionStartFields), + SessionEnd(SessionEndFields), + UserPromptSubmit(UserPromptSubmitFields), + Stop(StopFields), + StopFailure(StopFailureFields), + SubagentStart(SubagentStartFields), + SubagentStop(SubagentStopFields), + TeammateIdle(TeammateIdleFields), + TaskCompleted(TaskCompletedFields), + InstructionsLoaded(InstructionsLoadedFields), + ConfigChange(ConfigChangeFields), + PreCompact(PreCompactFields), + PostCompact(PostCompactFields), + Notification(NotificationFields), + Elicitation(ElicitationFields), + ElicitationResult(ElicitationResultFields), + WorktreeCreate(WorktreeCreateFields), + WorktreeRemove(WorktreeRemoveFields), + /// Unknown event — keep the raw extra map. + Unknown(serde_json::Map), +} + +/// Parse the extra fields into the appropriate per-event struct. +pub(crate) fn parse_payload( + event_name: &str, + extra: serde_json::Map, +) -> EventPayload { + macro_rules! parse_event { + ($variant:ident) => { + serde_json::from_value(serde_json::Value::Object(extra.clone())) + .map(EventPayload::$variant) + .unwrap_or(EventPayload::Unknown(extra)) + }; + } + match event_name { + "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" => { + parse_event!(Tool) + } + "SessionStart" => parse_event!(SessionStart), + "SessionEnd" => parse_event!(SessionEnd), + "UserPromptSubmit" => parse_event!(UserPromptSubmit), + "Stop" => parse_event!(Stop), + "StopFailure" => parse_event!(StopFailure), + "SubagentStart" => parse_event!(SubagentStart), + "SubagentStop" => parse_event!(SubagentStop), + "TeammateIdle" => parse_event!(TeammateIdle), + "TaskCompleted" => parse_event!(TaskCompleted), + "InstructionsLoaded" => parse_event!(InstructionsLoaded), + "ConfigChange" => parse_event!(ConfigChange), + "PreCompact" => parse_event!(PreCompact), + "PostCompact" => parse_event!(PostCompact), + "Notification" => parse_event!(Notification), + "Elicitation" => parse_event!(Elicitation), + "ElicitationResult" => parse_event!(ElicitationResult), + "WorktreeCreate" => parse_event!(WorktreeCreate), + "WorktreeRemove" => parse_event!(WorktreeRemove), + _ => EventPayload::Unknown(extra), + } +} + +// ── Output (stdout) ────────────────────────────────────────────────── + +/// Raw JSON response written to stdout. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RawOutput { + #[serde(rename = "continue")] + pub should_continue: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub hook_specific_output: Option, + /// Top-level decision for PostToolUse/Stop/UserPromptSubmit blocking. + #[serde(skip_serializing_if = "Option::is_none")] + pub decision: Option, + /// Reason for a top-level block decision. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Top-level action for Elicitation responses (accept/decline/cancel). + #[serde(skip_serializing_if = "Option::is_none")] + pub action: Option, + /// Top-level content for Elicitation accept responses. + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +/// The `hookSpecificOutput` object inside a hook response. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HookSpecificOutput { + pub hook_event_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_decision_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_context: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suppress_output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_message: Option, +} + +/// PreToolUse permission decision values. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PreToolUseDecision { + Allow, + Deny, + Ask, +} + +/// PermissionRequest decision payload. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionDecision { + pub behavior: PermissionBehavior, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub interrupt: Option, +} + +/// Behavior in a PermissionRequest decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PermissionBehavior { + Allow, + Deny, +} diff --git a/clash_hooks/tests/fixtures/config_change.json b/clash_hooks/tests/fixtures/config_change.json new file mode 100644 index 0000000..0c8f331 --- /dev/null +++ b/clash_hooks/tests/fixtures/config_change.json @@ -0,0 +1,8 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "ConfigChange", + "source": "project_settings", + "file_path": "/home/user/project/.claude/settings.json" +} diff --git a/clash_hooks/tests/fixtures/elicitation.json b/clash_hooks/tests/fixtures/elicitation.json new file mode 100644 index 0000000..b56d9f9 --- /dev/null +++ b/clash_hooks/tests/fixtures/elicitation.json @@ -0,0 +1,17 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "Elicitation", + "mcp_server_name": "github-mcp", + "message": "Enter your GitHub API token", + "elicitation_id": "elic_01ABC", + "requested_schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + } +} diff --git a/clash_hooks/tests/fixtures/notification.json b/clash_hooks/tests/fixtures/notification.json new file mode 100644 index 0000000..660aaa6 --- /dev/null +++ b/clash_hooks/tests/fixtures/notification.json @@ -0,0 +1,9 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "Notification", + "message": "Task completed successfully", + "title": "Claude Code", + "notification_type": "info" +} diff --git a/clash_hooks/tests/fixtures/permission_request.json b/clash_hooks/tests/fixtures/permission_request.json new file mode 100644 index 0000000..23c02d3 --- /dev/null +++ b/clash_hooks/tests/fixtures/permission_request.json @@ -0,0 +1,12 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "permission_mode": "default", + "hook_event_name": "PermissionRequest", + "tool_name": "Bash", + "tool_input": { + "command": "npm install express" + }, + "tool_use_id": "toolu_05VWXyz1" +} diff --git a/clash_hooks/tests/fixtures/post_compact.json b/clash_hooks/tests/fixtures/post_compact.json new file mode 100644 index 0000000..c3b5087 --- /dev/null +++ b/clash_hooks/tests/fixtures/post_compact.json @@ -0,0 +1,8 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "PostCompact", + "trigger": "auto", + "compact_summary": "Summarized 50 messages into 5 key points" +} diff --git a/clash_hooks/tests/fixtures/post_tool_use.json b/clash_hooks/tests/fixtures/post_tool_use.json new file mode 100644 index 0000000..487c120 --- /dev/null +++ b/clash_hooks/tests/fixtures/post_tool_use.json @@ -0,0 +1,16 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "permission_mode": "default", + "hook_event_name": "PostToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "cargo test" + }, + "tool_use_id": "toolu_03JKLmno", + "tool_response": { + "stdout": "test result: ok. 5 passed", + "stderr": "" + } +} diff --git a/clash_hooks/tests/fixtures/post_tool_use_failure.json b/clash_hooks/tests/fixtures/post_tool_use_failure.json new file mode 100644 index 0000000..09f06a9 --- /dev/null +++ b/clash_hooks/tests/fixtures/post_tool_use_failure.json @@ -0,0 +1,14 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "permission_mode": "default", + "hook_event_name": "PostToolUseFailure", + "tool_name": "Bash", + "tool_input": { + "command": "rm -rf /" + }, + "tool_use_id": "toolu_04PQRstu", + "error": "Command execution failed", + "is_interrupt": false +} diff --git a/clash_hooks/tests/fixtures/pre_compact.json b/clash_hooks/tests/fixtures/pre_compact.json new file mode 100644 index 0000000..c00a354 --- /dev/null +++ b/clash_hooks/tests/fixtures/pre_compact.json @@ -0,0 +1,8 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "PreCompact", + "trigger": "auto", + "custom_instructions": "Keep the test plan in context" +} diff --git a/clash_hooks/tests/fixtures/pre_tool_use_bash.json b/clash_hooks/tests/fixtures/pre_tool_use_bash.json new file mode 100644 index 0000000..e184394 --- /dev/null +++ b/clash_hooks/tests/fixtures/pre_tool_use_bash.json @@ -0,0 +1,14 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "git status", + "timeout": 120000, + "description": "Show working tree status" + }, + "tool_use_id": "toolu_01ABCdef" +} diff --git a/clash_hooks/tests/fixtures/pre_tool_use_write.json b/clash_hooks/tests/fixtures/pre_tool_use_write.json new file mode 100644 index 0000000..ca8ad78 --- /dev/null +++ b/clash_hooks/tests/fixtures/pre_tool_use_write.json @@ -0,0 +1,13 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "/home/user/project/src/main.rs", + "content": "fn main() {}" + }, + "tool_use_id": "toolu_02XYZghi" +} diff --git a/clash_hooks/tests/fixtures/session_end.json b/clash_hooks/tests/fixtures/session_end.json new file mode 100644 index 0000000..51459b4 --- /dev/null +++ b/clash_hooks/tests/fixtures/session_end.json @@ -0,0 +1,7 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "SessionEnd", + "reason": "user_exit" +} diff --git a/clash_hooks/tests/fixtures/session_start.json b/clash_hooks/tests/fixtures/session_start.json new file mode 100644 index 0000000..163780b --- /dev/null +++ b/clash_hooks/tests/fixtures/session_start.json @@ -0,0 +1,9 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "permission_mode": "default", + "hook_event_name": "SessionStart", + "source": "startup", + "model": "claude-sonnet-4-20250514" +} diff --git a/clash_hooks/tests/fixtures/stop.json b/clash_hooks/tests/fixtures/stop.json new file mode 100644 index 0000000..f42c905 --- /dev/null +++ b/clash_hooks/tests/fixtures/stop.json @@ -0,0 +1,8 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "Stop", + "stop_hook_active": false, + "last_assistant_message": "I've fixed the bug. The test suite passes now." +} diff --git a/clash_hooks/tests/fixtures/stop_failure.json b/clash_hooks/tests/fixtures/stop_failure.json new file mode 100644 index 0000000..3667bbc --- /dev/null +++ b/clash_hooks/tests/fixtures/stop_failure.json @@ -0,0 +1,11 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "StopFailure", + "error": "rate_limit", + "error_details": { + "retry_after": 30 + }, + "last_assistant_message": "I was working on..." +} diff --git a/clash_hooks/tests/fixtures/subagent_start.json b/clash_hooks/tests/fixtures/subagent_start.json new file mode 100644 index 0000000..0e6fe41 --- /dev/null +++ b/clash_hooks/tests/fixtures/subagent_start.json @@ -0,0 +1,8 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "SubagentStart", + "agent_id": "agent_001", + "agent_type": "Explore" +} diff --git a/clash_hooks/tests/fixtures/subagent_stop.json b/clash_hooks/tests/fixtures/subagent_stop.json new file mode 100644 index 0000000..0df6134 --- /dev/null +++ b/clash_hooks/tests/fixtures/subagent_stop.json @@ -0,0 +1,10 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "SubagentStop", + "agent_id": "agent_001", + "agent_type": "Explore", + "agent_transcript_path": "/home/user/.claude/sessions/sess_abc123/agent_001.jsonl", + "last_assistant_message": "Found 3 matching files." +} diff --git a/clash_hooks/tests/fixtures/unknown_future.json b/clash_hooks/tests/fixtures/unknown_future.json new file mode 100644 index 0000000..3f60928 --- /dev/null +++ b/clash_hooks/tests/fixtures/unknown_future.json @@ -0,0 +1,11 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "FutureEventV99", + "new_field_1": "some_value", + "new_field_2": 42, + "nested": { + "deep": true + } +} diff --git a/clash_hooks/tests/fixtures/user_prompt_submit.json b/clash_hooks/tests/fixtures/user_prompt_submit.json new file mode 100644 index 0000000..57f1f4e --- /dev/null +++ b/clash_hooks/tests/fixtures/user_prompt_submit.json @@ -0,0 +1,8 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "permission_mode": "default", + "hook_event_name": "UserPromptSubmit", + "prompt": "Please fix the bug in src/main.rs" +} diff --git a/clash_hooks/tests/fixtures/worktree_create.json b/clash_hooks/tests/fixtures/worktree_create.json new file mode 100644 index 0000000..ae20ef4 --- /dev/null +++ b/clash_hooks/tests/fixtures/worktree_create.json @@ -0,0 +1,7 @@ +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.claude/sessions/sess_abc123/transcript.jsonl", + "cwd": "/home/user/project", + "hook_event_name": "WorktreeCreate", + "name": "feature/new-auth" +} diff --git a/clash_hooks/tests/integration.rs b/clash_hooks/tests/integration.rs new file mode 100644 index 0000000..00c9a65 --- /dev/null +++ b/clash_hooks/tests/integration.rs @@ -0,0 +1,528 @@ +//! Fixture-based integration tests for clash_hooks. +//! +//! Each test loads a real-world JSON fixture, parses it with `recv_from()`, +//! verifies the event fields, builds a response, serializes with `send_to()`, +//! and asserts the wire JSON matches Claude Code expectations. + +use clash_hooks::{recv_from, send_to, HookEvent, HookEventCommon, ToolEvent}; + +fn fixture(name: &str) -> Vec { + let path = format!( + "{}/tests/fixtures/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read fixture {path}: {e}")) +} + +fn response_json(response: &clash_hooks::Response) -> serde_json::Value { + let mut buf = Vec::new(); + send_to(response, &mut buf).unwrap(); + serde_json::from_slice(&buf).unwrap() +} + +// ═══════════════════════════════════════════════════════════════════════ +// Round-trip tests per event type +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn test_roundtrip_pre_tool_use_bash() { + let event = recv_from(fixture("pre_tool_use_bash.json").as_slice()).unwrap(); + match event { + HookEvent::PreToolUse(ref e) => { + assert_eq!(e.session_id(), "sess_abc123"); + assert_eq!(e.cwd(), "/home/user/project"); + assert_eq!(e.permission_mode(), Some("default")); + assert_eq!(e.tool_name(), "Bash"); + assert_eq!(e.tool_use_id(), Some("toolu_01ABCdef")); + let bash = e.bash().unwrap(); + assert_eq!(bash.command, "git status"); + assert_eq!(bash.timeout, Some(120000)); + + let json = response_json(&e.allow()); + assert_eq!(json["continue"], true); + assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "allow"); + } + other => panic!("expected PreToolUse, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_pre_tool_use_write() { + let event = recv_from(fixture("pre_tool_use_write.json").as_slice()).unwrap(); + match event { + HookEvent::PreToolUse(ref e) => { + assert_eq!(e.tool_name(), "Write"); + let write = e.write().unwrap(); + assert_eq!(write.file_path, "/home/user/project/src/main.rs"); + assert_eq!(write.content, "fn main() {}"); + + let json = response_json(&e.deny("not allowed")); + assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny"); + assert_eq!(json["hookSpecificOutput"]["permissionDecisionReason"], "not allowed"); + } + other => panic!("expected PreToolUse, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_post_tool_use() { + let event = recv_from(fixture("post_tool_use.json").as_slice()).unwrap(); + match event { + HookEvent::PostToolUse(ref e) => { + assert_eq!(e.tool_name(), "Bash"); + assert!(e.tool_response().is_some()); + let resp = e.tool_response().unwrap(); + assert_eq!(resp["stdout"], "test result: ok. 5 passed"); + + let json = response_json(&e.context("tests passed")); + assert_eq!(json["hookSpecificOutput"]["additionalContext"], "tests passed"); + } + other => panic!("expected PostToolUse, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_post_tool_use_failure() { + let event = recv_from(fixture("post_tool_use_failure.json").as_slice()).unwrap(); + match event { + HookEvent::PostToolUseFailure(ref e) => { + assert_eq!(e.tool_name(), "Bash"); + assert_eq!(e.error(), Some("Command execution failed")); + assert!(!e.is_interrupt()); + + let json = response_json(&e.context("command failed")); + assert_eq!(json["hookSpecificOutput"]["hookEventName"], "PostToolUseFailure"); + } + other => panic!("expected PostToolUseFailure, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_permission_request() { + let event = recv_from(fixture("permission_request.json").as_slice()).unwrap(); + match event { + HookEvent::PermissionRequest(ref e) => { + assert_eq!(e.tool_name(), "Bash"); + let bash = e.bash().unwrap(); + assert_eq!(bash.command, "npm install express"); + + // Test approve + let json = response_json(&e.approve()); + assert_eq!(json["hookSpecificOutput"]["hookEventName"], "PermissionRequest"); + assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow"); + + // Test deny + let json = response_json(&e.deny("denied by policy")); + assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny"); + assert_eq!(json["hookSpecificOutput"]["decision"]["message"], "denied by policy"); + } + other => panic!("expected PermissionRequest, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_session_start() { + let event = recv_from(fixture("session_start.json").as_slice()).unwrap(); + match event { + HookEvent::SessionStart(ref e) => { + assert_eq!(e.session_id(), "sess_abc123"); + assert_eq!(e.source(), Some("startup")); + assert_eq!(e.model(), Some("claude-sonnet-4-20250514")); + + let json = response_json(&e.context("clash is active")); + assert_eq!(json["hookSpecificOutput"]["hookEventName"], "SessionStart"); + assert_eq!(json["hookSpecificOutput"]["additionalContext"], "clash is active"); + } + other => panic!("expected SessionStart, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_session_end() { + let event = recv_from(fixture("session_end.json").as_slice()).unwrap(); + match event { + HookEvent::SessionEnd(ref e) => { + assert_eq!(e.reason(), Some("user_exit")); + + let json = response_json(&e.pass()); + assert_eq!(json["continue"], true); + } + other => panic!("expected SessionEnd, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_user_prompt_submit() { + let event = recv_from(fixture("user_prompt_submit.json").as_slice()).unwrap(); + match event { + HookEvent::UserPromptSubmit(ref e) => { + assert_eq!(e.prompt(), Some("Please fix the bug in src/main.rs")); + + let json = response_json(&e.block("blocked by policy")); + assert_eq!(json["decision"], "block"); + assert_eq!(json["reason"], "blocked by policy"); + } + other => panic!("expected UserPromptSubmit, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_stop() { + let event = recv_from(fixture("stop.json").as_slice()).unwrap(); + match event { + HookEvent::Stop(ref e) => { + assert!(!e.stop_hook_active()); + assert_eq!( + e.last_assistant_message(), + Some("I've fixed the bug. The test suite passes now.") + ); + + let json = response_json(&e.pass()); + assert_eq!(json["continue"], true); + } + other => panic!("expected Stop, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_stop_failure() { + let event = recv_from(fixture("stop_failure.json").as_slice()).unwrap(); + match event { + HookEvent::StopFailure(ref e) => { + assert_eq!(e.error(), Some("rate_limit")); + assert!(e.error_details().is_some()); + assert_eq!(e.last_assistant_message(), Some("I was working on...")); + } + other => panic!("expected StopFailure, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_subagent_start() { + let event = recv_from(fixture("subagent_start.json").as_slice()).unwrap(); + match event { + HookEvent::SubagentStart(ref e) => { + assert_eq!(e.agent_id(), Some("agent_001")); + assert_eq!(e.agent_type(), Some("Explore")); + + let json = response_json(&e.context("subagent launched")); + assert_eq!(json["hookSpecificOutput"]["hookEventName"], "SubagentStart"); + } + other => panic!("expected SubagentStart, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_subagent_stop() { + let event = recv_from(fixture("subagent_stop.json").as_slice()).unwrap(); + match event { + HookEvent::SubagentStop(ref e) => { + assert_eq!(e.agent_id(), Some("agent_001")); + assert_eq!(e.agent_type(), Some("Explore")); + assert!(e.agent_transcript_path().is_some()); + assert_eq!(e.last_assistant_message(), Some("Found 3 matching files.")); + } + other => panic!("expected SubagentStop, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_elicitation() { + let event = recv_from(fixture("elicitation.json").as_slice()).unwrap(); + match event { + HookEvent::Elicitation(ref e) => { + assert_eq!(e.mcp_server_name(), Some("github-mcp")); + assert_eq!(e.message(), Some("Enter your GitHub API token")); + assert_eq!(e.elicitation_id(), Some("elic_01ABC")); + assert!(e.requested_schema().is_some()); + + // Test accept + let json = response_json(&e.accept(serde_json::json!({"token": "ghp_xxx"}))); + assert_eq!(json["continue"], true); + assert_eq!(json["action"], "accept"); + assert_eq!(json["content"]["token"], "ghp_xxx"); + + // Test decline + let json = response_json(&e.decline()); + assert_eq!(json["action"], "decline"); + + // Test cancel + let json = response_json(&e.cancel()); + assert_eq!(json["action"], "cancel"); + } + other => panic!("expected Elicitation, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_notification() { + let event = recv_from(fixture("notification.json").as_slice()).unwrap(); + match event { + HookEvent::Notification(ref e) => { + assert_eq!(e.message(), Some("Task completed successfully")); + assert_eq!(e.title(), Some("Claude Code")); + assert_eq!(e.notification_type(), Some("info")); + } + other => panic!("expected Notification, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_config_change() { + let event = recv_from(fixture("config_change.json").as_slice()).unwrap(); + match event { + HookEvent::ConfigChange(ref e) => { + assert_eq!(e.source(), Some("project_settings")); + assert_eq!(e.file_path(), Some("/home/user/project/.claude/settings.json")); + + let json = response_json(&e.block("config change blocked")); + assert_eq!(json["decision"], "block"); + } + other => panic!("expected ConfigChange, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_pre_compact() { + let event = recv_from(fixture("pre_compact.json").as_slice()).unwrap(); + match event { + HookEvent::PreCompact(ref e) => { + assert_eq!(e.trigger(), Some("auto")); + assert_eq!(e.custom_instructions(), Some("Keep the test plan in context")); + } + other => panic!("expected PreCompact, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_post_compact() { + let event = recv_from(fixture("post_compact.json").as_slice()).unwrap(); + match event { + HookEvent::PostCompact(ref e) => { + assert_eq!(e.trigger(), Some("auto")); + assert_eq!(e.compact_summary(), Some("Summarized 50 messages into 5 key points")); + } + other => panic!("expected PostCompact, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_worktree_create() { + let event = recv_from(fixture("worktree_create.json").as_slice()).unwrap(); + match event { + HookEvent::WorktreeCreate(ref e) => { + assert_eq!(e.name(), Some("feature/new-auth")); + } + other => panic!("expected WorktreeCreate, got {other:?}"), + } +} + +#[test] +fn test_roundtrip_unknown_future() { + let event = recv_from(fixture("unknown_future.json").as_slice()).unwrap(); + match event { + HookEvent::Unknown(ref e) => { + assert_eq!(e.hook_event_name(), "FutureEventV99"); + assert_eq!(e.session_id(), "sess_abc123"); + // Extra fields are preserved + let extra = e.extra(); + assert_eq!(extra.get("new_field_1").and_then(|v| v.as_str()), Some("some_value")); + assert_eq!(extra.get("new_field_2").and_then(|v| v.as_i64()), Some(42)); + } + other => panic!("expected Unknown, got {other:?}"), + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Forward compatibility +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn test_forward_compat_unknown_fields() { + // A PreToolUse with extra fields that don't exist in the current schema + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "ls"}, + "tool_use_id": "toolu_01", + "future_field": "hello", + "another_future": [1, 2, 3] + }"#; + let event = recv_from(json.as_bytes()).unwrap(); + match event { + HookEvent::PreToolUse(ref e) => { + assert_eq!(e.tool_name(), "Bash"); + // Should parse fine despite unknown fields + let bash = e.bash().unwrap(); + assert_eq!(bash.command, "ls"); + } + other => panic!("expected PreToolUse, got {other:?}"), + } +} + +#[test] +fn test_forward_compat_unknown_event_type() { + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "NewFeatureEvent", + "feature_id": "feat_123", + "metadata": {"version": 2} + }"#; + let event = recv_from(json.as_bytes()).unwrap(); + assert!(matches!(event, HookEvent::Unknown(_))); + assert_eq!(event.session_id(), "s"); + assert_eq!(event.hook_event_name(), "NewFeatureEvent"); +} + +// ═══════════════════════════════════════════════════════════════════════ +// All tool accessors +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn test_all_tool_accessors() { + // Bash + let event = recv_from(fixture("pre_tool_use_bash.json").as_slice()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + assert!(e.bash().is_some()); + assert!(e.write().is_none()); + assert!(e.edit().is_none()); + assert!(e.read().is_none()); + assert!(e.glob().is_none()); + assert!(e.grep().is_none()); + assert!(e.web_fetch().is_none()); + assert!(e.web_search().is_none()); + assert!(e.notebook_edit().is_none()); + assert!(e.skill().is_none()); + assert!(e.agent().is_none()); + } + + // Write + let event = recv_from(fixture("pre_tool_use_write.json").as_slice()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + assert!(e.bash().is_none()); + assert!(e.write().is_some()); + } + + // Edit + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": {"file_path": "/tmp/f.rs", "old_string": "a", "new_string": "b"} + }"#; + let event = recv_from(json.as_bytes()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + let edit = e.edit().unwrap(); + assert_eq!(edit.file_path, "/tmp/f.rs"); + assert_eq!(edit.old_string, "a"); + assert_eq!(edit.new_string, "b"); + } + + // Read + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "PreToolUse", + "tool_name": "Read", + "tool_input": {"file_path": "/tmp/f.rs"} + }"#; + let event = recv_from(json.as_bytes()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + let read = e.read().unwrap(); + assert_eq!(read.file_path, "/tmp/f.rs"); + } + + // Glob + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "PreToolUse", + "tool_name": "Glob", + "tool_input": {"pattern": "**/*.rs"} + }"#; + let event = recv_from(json.as_bytes()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + let glob = e.glob().unwrap(); + assert_eq!(glob.pattern, "**/*.rs"); + } + + // Grep + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "PreToolUse", + "tool_name": "Grep", + "tool_input": {"pattern": "fn main"} + }"#; + let event = recv_from(json.as_bytes()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + let grep = e.grep().unwrap(); + assert_eq!(grep.pattern, "fn main"); + } + + // WebFetch + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "PreToolUse", + "tool_name": "WebFetch", + "tool_input": {"url": "https://example.com", "prompt": "get page"} + }"#; + let event = recv_from(json.as_bytes()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + let wf = e.web_fetch().unwrap(); + assert_eq!(wf.url, "https://example.com"); + } + + // WebSearch + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "PreToolUse", + "tool_name": "WebSearch", + "tool_input": {"query": "rust async"} + }"#; + let event = recv_from(json.as_bytes()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + let ws = e.web_search().unwrap(); + assert_eq!(ws.query, "rust async"); + } + + // Skill + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "PreToolUse", + "tool_name": "Skill", + "tool_input": {"skill": "commit"} + }"#; + let event = recv_from(json.as_bytes()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + let skill = e.skill().unwrap(); + assert_eq!(skill.skill, "commit"); + } + + // Agent/Task + let json = r#"{ + "session_id": "s", "transcript_path": "t", "cwd": "c", + "hook_event_name": "PreToolUse", + "tool_name": "Task", + "tool_input": {"prompt": "find files", "subagent_type": "Explore"} + }"#; + let event = recv_from(json.as_bytes()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + let agent = e.agent().unwrap(); + assert_eq!(agent.prompt, "find files"); + assert_eq!(agent.subagent_type.as_deref(), Some("Explore")); + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Caching verification +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn test_typed_tool_input_is_cached() { + let event = recv_from(fixture("pre_tool_use_bash.json").as_slice()).unwrap(); + if let HookEvent::PreToolUse(ref e) = event { + // Call typed_tool_input twice — the second should return the cached ref. + let first = e.typed_tool_input() as *const _; + let second = e.typed_tool_input() as *const _; + assert_eq!(first, second, "typed_tool_input should return same cached reference"); + } +} From 09b6325e162ffe2381bf2a85c2f49c59bc242236 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Tue, 31 Mar 2026 16:23:51 -0400 Subject: [PATCH 2/2] refactor: unify output path, kill legacy types, disambiguate naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unify Claude output through HookProtocol trait: all agents (including Claude) go through format_decision/format_session_start/etc. with no AgentKind::Claude branches in cmd/hooks.rs - Introduce PolicyDecision enum as the boundary type between policy engine and protocol output, replacing HookOutput-based returns from check_permission - Delete ToolUseHookInput, SessionStartHookInput, StopHookInput, and check_permission_legacy — all input handling uses clash_hooks typed events via recv_from_value - Add recv_from_value to clash_hooks for non-Claude agent protocol normalization - Rename Decision → MatchVerdict, ir::PolicyDecision → PolicyEvaluation, trace::PolicyDecision → TraceDecision to eliminate naming collisions - Eliminate duplicated display/ui formatting: delete print_tool_header, print_decision, print_sandbox_summary from ui.rs; cmd/explain.rs now uses display:: formatters like all other callers - Thread agent through handle_permission_request for consistent check_permission calls - Restore dropped edge-case tests for tool_response: None path --- Cargo.lock | 1 + clash/Cargo.toml | 1 + clash/src/agents/amazonq.rs | 65 +++-- clash/src/agents/claude.rs | 164 +++++------ clash/src/agents/codex.rs | 88 ++++-- clash/src/agents/copilot.rs | 75 ++++-- clash/src/agents/gemini.rs | 93 ++++--- clash/src/agents/opencode.rs | 82 ++++-- clash/src/agents/protocol.rs | 143 +++++----- clash/src/cmd/explain.rs | 21 +- clash/src/cmd/hooks.rs | 345 ++++++++++-------------- clash/src/cmd/policy.rs | 10 +- clash/src/debug/replay.rs | 4 +- clash/src/display.rs | 6 +- clash/src/handlers.rs | 186 +++++++------ clash/src/hooks.rs | 238 ++-------------- clash/src/lib.rs | 11 +- clash/src/network_hints.rs | 130 +++++---- clash/src/permissions.rs | 151 ++++++----- clash/src/policy/format.rs | 20 +- clash/src/policy/ir.rs | 4 +- clash/src/policy/manifest_edit.rs | 49 ++-- clash/src/policy/match_tree.rs | 124 ++++----- clash/src/policy/mod.rs | 2 +- clash/src/policy/proptests.rs | 16 +- clash/src/policy/test_eval.rs | 4 +- clash/src/policy_decision.rs | 134 +++++++++ clash/src/sandbox_hints/audit_source.rs | 11 +- clash/src/sandbox_hints/mod.rs | 134 +++++---- clash/src/test_utils.rs | 115 ++++---- clash/src/trace.rs | 14 +- clash/src/trace_display.rs | 26 +- clash/src/tui/inline_form.rs | 70 ++--- clash/src/tui/test_panel.rs | 2 +- clash/src/tui/tree_view.rs | 30 +-- clash/src/ui.rs | 67 +---- clash_hooks/src/event.rs | 29 +- clash_hooks/src/lib.rs | 96 ++++++- clash_hooks/tests/integration.rs | 62 +++-- 39 files changed, 1461 insertions(+), 1362 deletions(-) create mode 100644 clash/src/policy_decision.rs diff --git a/Cargo.lock b/Cargo.lock index 9729739..2485b84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -729,6 +729,7 @@ dependencies = [ "clash-brush-core", "clash-brush-interactive", "clash-brush-parser", + "clash_hooks", "clash_notify", "clash_starlark", "claude_settings", diff --git a/clash/Cargo.toml b/clash/Cargo.toml index 55e3426..ee250e9 100644 --- a/clash/Cargo.toml +++ b/clash/Cargo.toml @@ -51,6 +51,7 @@ crossterm = { workspace = true } similar = { workspace = true } # from the workspace +clash_hooks = { workspace = true } clash_notify = { workspace = true } clash_starlark = { workspace = true } claude_settings = { workspace = true } diff --git a/clash/src/agents/amazonq.rs b/clash/src/agents/amazonq.rs index 00fb984..1da6e34 100644 --- a/clash/src/agents/amazonq.rs +++ b/clash/src/agents/amazonq.rs @@ -5,38 +5,44 @@ use anyhow::Result; use serde_json::Value; -use super::protocol::{HookProtocol, json_str}; -use super::{AgentKind, resolve_tool_name}; -use crate::hooks::ToolUseHookInput; +use super::AgentKind; +use super::protocol::HookProtocol; pub struct AmazonQProtocol; +impl AmazonQProtocol { + /// Normalize Amazon Q JSON to Claude convention for `recv_from_value`. + fn normalize(raw: &Value) -> Value { + let mut normalized = serde_json::Map::new(); + + if let Some(obj) = raw.as_object() { + for (k, v) in obj { + normalized.insert(k.clone(), v.clone()); + } + } + + // Amazon Q may use camelCase hook_event_name + if let Some(name) = normalized.get("hook_event_name").and_then(|v| v.as_str()) { + let pascal = super::copilot::normalize_event_name(name); + normalized.insert("hook_event_name".into(), Value::String(pascal)); + } + + // Default transcript_path if missing + if !normalized.contains_key("transcript_path") { + normalized.insert("transcript_path".into(), Value::String(String::new())); + } + + Value::Object(normalized) + } +} + impl HookProtocol for AmazonQProtocol { fn agent(&self) -> AgentKind { AgentKind::AmazonQ } - fn parse_tool_use(&self, raw: &Value) -> Result { - let tool_name = json_str(raw, "tool_name").to_string(); - let original = tool_name.clone(); - let resolved = resolve_tool_name(AgentKind::AmazonQ, &tool_name).to_string(); - - Ok(ToolUseHookInput { - session_id: json_str(raw, "session_id").to_string(), - transcript_path: json_str(raw, "transcript_path").to_string(), - cwd: json_str(raw, "cwd").to_string(), - permission_mode: "default".to_string(), - hook_event_name: json_str(raw, "hook_event_name").to_string(), - tool_name: resolved, - tool_input: raw - .get("tool_input") - .cloned() - .unwrap_or(Value::Object(serde_json::Map::new())), - tool_use_id: None, - tool_response: raw.get("tool_response").cloned(), - agent: Some(AgentKind::AmazonQ), - original_tool_name: Some(original), - }) + fn parse_event(&self, raw: &Value) -> Result { + Ok(clash_hooks::recv_from_value(Self::normalize(raw))?) } // Amazon Q uses "action" instead of "decision" @@ -65,9 +71,10 @@ impl HookProtocol for AmazonQProtocol { #[cfg(test)] mod tests { use super::*; + use clash_hooks::ToolEvent; #[test] - fn parse_amazonq_execute_bash() { + fn parse_event_amazonq_execute_bash() { let raw = serde_json::json!({ "session_id": "q-123", "cwd": "/home/user", @@ -75,9 +82,11 @@ mod tests { "tool_name": "execute_bash", "tool_input": {"command": "npm test"} }); - let input = AmazonQProtocol.parse_tool_use(&raw).unwrap(); - assert_eq!(input.tool_name, "Bash"); - assert_eq!(input.original_tool_name.as_deref(), Some("execute_bash")); + let event = AmazonQProtocol.parse_event(&raw).unwrap(); + let clash_hooks::HookEvent::PreToolUse(e) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(e.tool_name(), "execute_bash"); } #[test] diff --git a/clash/src/agents/claude.rs b/clash/src/agents/claude.rs index 9f58d61..7bd7f61 100644 --- a/clash/src/agents/claude.rs +++ b/clash/src/agents/claude.rs @@ -1,15 +1,18 @@ //! Claude Code hook protocol implementation. //! -//! Claude Code has a unique output format using `HookOutput` structs with -//! `permissionDecision`, `hookSpecificOutput`, etc. This requires custom -//! format methods — the defaults don't apply here. +//! Claude uses the nested `{ continue, hookSpecificOutput: { hookEventName, ... } }` +//! JSON format defined by [`HookOutput`]. All `format_*` methods serialize through +//! `HookOutput`'s serde implementation so the output is always wire-correct. +//! +//! `parse_event` uses the default implementation (`recv_from_value`), which +//! works because Claude's JSON is already in the canonical format. -use anyhow::Result; use serde_json::Value; +use super::AgentKind; use super::protocol::HookProtocol; -use super::{AgentKind, resolve_tool_name}; -use crate::hooks::{HookOutput, SessionStartHookInput, StopHookInput, ToolUseHookInput}; +use crate::hooks::HookOutput; +use crate::policy_decision::PolicyDecision; pub struct ClaudeProtocol; @@ -18,67 +21,77 @@ impl HookProtocol for ClaudeProtocol { AgentKind::Claude } - fn parse_tool_use(&self, raw: &Value) -> Result { - let mut input: ToolUseHookInput = serde_json::from_value(raw.clone())?; - let original = input.tool_name.clone(); - input.tool_name = resolve_tool_name(AgentKind::Claude, &original).to_string(); - input.original_tool_name = Some(original); - input.agent = Some(AgentKind::Claude); - Ok(input) + fn session_context(&self) -> &str { + include_str!("../../docs/session-context.md") } - fn parse_session_start(&self, raw: &Value) -> Result { - Ok(serde_json::from_value(raw.clone())?) - } + // parse_event — uses default (recv_from_value), Claude JSON is canonical - fn parse_stop(&self, raw: &Value) -> Result { - Ok(serde_json::from_value(raw.clone())?) + fn format_decision(&self, decision: &PolicyDecision) -> Value { + serde_json::to_value(to_hook_output(decision)).unwrap() } - // Claude has a unique output format — must override all format methods. - - fn format_allow( - &self, - reason: Option<&str>, - context: Option<&str>, - updated_input: Option, - ) -> Value { - let mut output = HookOutput::allow(reason.map(String::from), context.map(String::from)); - if let Some(ui) = updated_input { - output.set_updated_input(ui); - } - serde_json::to_value(output).expect("HookOutput serialization cannot fail") + fn format_session_start(&self, context: Option<&str>) -> Value { + serde_json::to_value(HookOutput::session_start(context.map(String::from))).unwrap() } - fn format_deny(&self, reason: &str, context: Option<&str>) -> Value { - let output = HookOutput::deny(reason.to_string(), context.map(String::from)); - serde_json::to_value(output).expect("HookOutput serialization cannot fail") + fn format_post_tool_use(&self, context: Option<&str>) -> Value { + serde_json::to_value(HookOutput::post_tool_use(context.map(String::from))).unwrap() } - fn format_ask(&self, reason: Option<&str>, context: Option<&str>) -> Value { - let output = HookOutput::ask(reason.map(String::from), context.map(String::from)); - serde_json::to_value(output).expect("HookOutput serialization cannot fail") + fn format_continue(&self) -> Value { + serde_json::to_value(HookOutput::continue_execution()).unwrap() } - fn format_session_start(&self, context: Option<&str>) -> Value { - let output = HookOutput::session_start(context.map(String::from)); - serde_json::to_value(output).expect("HookOutput serialization cannot fail") + fn format_permission_response( + &self, + behavior: &str, + message: Option<&str>, + updated_input: Option<&Value>, + interrupt: Option, + ) -> Value { + let output = match behavior { + "allow" => HookOutput::approve_permission(updated_input.cloned()), + _ => HookOutput::deny_permission( + message.unwrap_or("denied").to_string(), + interrupt.unwrap_or(false), + ), + }; + serde_json::to_value(&output).unwrap() } +} - fn session_context(&self) -> &str { - include_str!("../../docs/session-context.md") +/// Convert a [`PolicyDecision`] into a Claude-format [`HookOutput`]. +fn to_hook_output(decision: &PolicyDecision) -> HookOutput { + match decision { + PolicyDecision::Allow { + reason, + context, + updated_input, + } => { + let mut output = HookOutput::allow(Some(reason.clone()), context.clone()); + if let Some(ui) = updated_input { + output.set_updated_input(ui.clone()); + } + output + } + PolicyDecision::Deny { reason, context } => { + HookOutput::deny(reason.clone(), context.clone()) + } + PolicyDecision::Ask { reason, context } => { + HookOutput::ask(Some(reason.clone()), context.clone()) + } + PolicyDecision::Pass => HookOutput::continue_execution(), } - - // parse_post_tool_use — uses default (delegates to parse_tool_use) - // rewrite_for_sandbox — uses default (rewrites "Bash" command field) } #[cfg(test)] mod tests { use super::*; + use clash_hooks::ToolEvent; #[test] - fn parse_tool_use_normalizes_name() { + fn parse_event_claude_pre_tool_use() { let raw = serde_json::json!({ "session_id": "test", "transcript_path": "/tmp/t.jsonl", @@ -89,44 +102,43 @@ mod tests { "tool_input": {"command": "ls"}, "tool_use_id": "toolu_01" }); - let input = ClaudeProtocol.parse_tool_use(&raw).unwrap(); - assert_eq!(input.tool_name, "Bash"); - assert_eq!(input.original_tool_name.as_deref(), Some("Bash")); - assert_eq!(input.agent, Some(AgentKind::Claude)); - } - - #[test] - fn format_allow_matches_existing_format() { - let output = ClaudeProtocol.format_allow(Some("safe"), None, None); - assert_eq!(output["continue"], true); - assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "allow"); + let event = ClaudeProtocol.parse_event(&raw).unwrap(); + let clash_hooks::HookEvent::PreToolUse(e) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(e.tool_name(), "Bash"); } #[test] - fn format_deny_matches_existing_format() { - let output = ClaudeProtocol.format_deny("blocked", Some("context")); - assert_eq!(output["continue"], true); - assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "deny"); + fn format_decision_round_trip() { + let decision = PolicyDecision::Allow { + reason: "policy: allowed".into(), + context: None, + updated_input: None, + }; + let json = ClaudeProtocol.format_decision(&decision); + let expected = + serde_json::to_value(&HookOutput::allow(Some("policy: allowed".into()), None)).unwrap(); + assert_eq!(json, expected); } #[test] - fn format_ask_matches_existing_format() { - let output = ClaudeProtocol.format_ask(None, None); - assert_eq!(output["continue"], true); - assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "ask"); + fn format_decision_deny_round_trip() { + let decision = PolicyDecision::Deny { + reason: "blocked".into(), + context: Some("ctx".into()), + }; + let json = ClaudeProtocol.format_decision(&decision); + let expected = + serde_json::to_value(&HookOutput::deny("blocked".into(), Some("ctx".into()))).unwrap(); + assert_eq!(json, expected); } #[test] - fn rewrite_for_sandbox_uses_default() { - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: serde_json::json!({"command": "ls -la"}), - cwd: "/home/user".into(), - ..Default::default() - }; - let result = ClaudeProtocol - .rewrite_for_sandbox(&input, "/usr/bin/clash") - .unwrap(); - assert!(result["command"].as_str().unwrap().contains("clash")); + fn format_decision_pass_round_trip() { + let decision = PolicyDecision::Pass; + let json = ClaudeProtocol.format_decision(&decision); + let expected = serde_json::to_value(&HookOutput::continue_execution()).unwrap(); + assert_eq!(json, expected); } } diff --git a/clash/src/agents/codex.rs b/clash/src/agents/codex.rs index 88ef064..723d817 100644 --- a/clash/src/agents/codex.rs +++ b/clash/src/agents/codex.rs @@ -5,36 +5,53 @@ use anyhow::Result; use serde_json::Value; -use super::protocol::{HookProtocol, json_str, json_str_any, json_value_any}; -use super::{AgentKind, resolve_tool_name}; -use crate::hooks::ToolUseHookInput; +use super::AgentKind; +use super::protocol::HookProtocol; pub struct CodexProtocol; +impl CodexProtocol { + /// Normalize Codex JSON to Claude convention for `recv_from_value`. + fn normalize(raw: &Value) -> Value { + let mut normalized = serde_json::Map::new(); + + // Map field names to Claude convention + if let Some(obj) = raw.as_object() { + for (k, v) in obj { + normalized.insert(k.clone(), v.clone()); + } + } + + // Ensure cwd (Codex may use working_directory) + if !normalized.contains_key("cwd") + && let Some(wd) = raw.get("working_directory") + { + normalized.insert("cwd".into(), wd.clone()); + } + + // Ensure tool_input (Codex may use input) + if !normalized.contains_key("tool_input") + && let Some(input) = raw.get("input") + { + normalized.insert("tool_input".into(), input.clone()); + } + + // Default transcript_path if missing + if !normalized.contains_key("transcript_path") { + normalized.insert("transcript_path".into(), Value::String(String::new())); + } + + Value::Object(normalized) + } +} + impl HookProtocol for CodexProtocol { fn agent(&self) -> AgentKind { AgentKind::Codex } - fn parse_tool_use(&self, raw: &Value) -> Result { - let tool_name = json_str(raw, "tool_name").to_string(); - let original = tool_name.clone(); - let resolved = resolve_tool_name(AgentKind::Codex, &tool_name).to_string(); - - Ok(ToolUseHookInput { - session_id: json_str(raw, "session_id").to_string(), - transcript_path: json_str(raw, "transcript_path").to_string(), - cwd: json_str_any(raw, &["cwd", "working_directory"]).to_string(), - permission_mode: "default".to_string(), - hook_event_name: json_str(raw, "hook_event_name").to_string(), - tool_name: resolved, - tool_input: json_value_any(raw, &["tool_input", "input"]) - .unwrap_or(Value::Object(serde_json::Map::new())), - tool_use_id: None, - tool_response: raw.get("tool_response").cloned(), - agent: Some(AgentKind::Codex), - original_tool_name: Some(original), - }) + fn parse_event(&self, raw: &Value) -> Result { + Ok(clash_hooks::recv_from_value(Self::normalize(raw))?) } // Codex uses "proceed"/"block"/"modify" instead of "allow"/"deny" @@ -70,9 +87,10 @@ impl HookProtocol for CodexProtocol { #[cfg(test)] mod tests { use super::*; + use clash_hooks::{HookEventCommon, ToolEvent}; #[test] - fn parse_codex_shell() { + fn parse_event_codex_shell() { let raw = serde_json::json!({ "session_id": "codex-123", "cwd": "/home/user", @@ -80,9 +98,27 @@ mod tests { "tool_name": "shell", "tool_input": {"command": "git status"} }); - let input = CodexProtocol.parse_tool_use(&raw).unwrap(); - assert_eq!(input.tool_name, "Bash"); - assert_eq!(input.original_tool_name.as_deref(), Some("shell")); + let event = CodexProtocol.parse_event(&raw).unwrap(); + let clash_hooks::HookEvent::PreToolUse(e) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(e.tool_name(), "shell"); + } + + #[test] + fn parse_event_codex_working_directory() { + let raw = serde_json::json!({ + "session_id": "codex-123", + "working_directory": "/home/user", + "hook_event_name": "PreToolUse", + "tool_name": "shell", + "tool_input": {"command": "ls"} + }); + let event = CodexProtocol.parse_event(&raw).unwrap(); + let clash_hooks::HookEvent::PreToolUse(e) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(e.cwd(), "/home/user"); } #[test] diff --git a/clash/src/agents/copilot.rs b/clash/src/agents/copilot.rs index f17f2df..6be58a8 100644 --- a/clash/src/agents/copilot.rs +++ b/clash/src/agents/copilot.rs @@ -5,38 +5,55 @@ use anyhow::Result; use serde_json::Value; -use super::protocol::{HookProtocol, json_str}; -use super::{AgentKind, resolve_tool_name}; -use crate::hooks::ToolUseHookInput; +use super::AgentKind; +use super::protocol::HookProtocol; pub struct CopilotProtocol; +impl CopilotProtocol { + /// Normalize Copilot JSON to Claude convention for `recv_from_value`. + fn normalize(raw: &Value) -> Value { + let mut normalized = serde_json::Map::new(); + + if let Some(obj) = raw.as_object() { + for (k, v) in obj { + normalized.insert(k.clone(), v.clone()); + } + } + + // Copilot may use camelCase hook_event_name (e.g., "preToolUse") + if let Some(name) = normalized.get("hook_event_name").and_then(|v| v.as_str()) { + let pascal = normalize_event_name(name); + normalized.insert("hook_event_name".into(), Value::String(pascal)); + } + + // Default transcript_path if missing + if !normalized.contains_key("transcript_path") { + normalized.insert("transcript_path".into(), Value::String(String::new())); + } + + Value::Object(normalized) + } +} + +/// Normalize camelCase event names to PascalCase Claude convention. +pub(super) fn normalize_event_name(name: &str) -> String { + match name { + "preToolUse" => "PreToolUse".into(), + "postToolUse" => "PostToolUse".into(), + "sessionStart" => "SessionStart".into(), + "stop" => "Stop".into(), + _ => name.to_string(), + } +} + impl HookProtocol for CopilotProtocol { fn agent(&self) -> AgentKind { AgentKind::Copilot } - fn parse_tool_use(&self, raw: &Value) -> Result { - let tool_name = json_str(raw, "tool_name").to_string(); - let original = tool_name.clone(); - let resolved = resolve_tool_name(AgentKind::Copilot, &tool_name).to_string(); - - Ok(ToolUseHookInput { - session_id: json_str(raw, "session_id").to_string(), - transcript_path: json_str(raw, "transcript_path").to_string(), - cwd: json_str(raw, "cwd").to_string(), - permission_mode: "default".to_string(), - hook_event_name: json_str(raw, "hook_event_name").to_string(), - tool_name: resolved, - tool_input: raw - .get("tool_input") - .cloned() - .unwrap_or(Value::Object(serde_json::Map::new())), - tool_use_id: None, - tool_response: raw.get("tool_response").cloned(), - agent: Some(AgentKind::Copilot), - original_tool_name: Some(original), - }) + fn parse_event(&self, raw: &Value) -> Result { + Ok(clash_hooks::recv_from_value(Self::normalize(raw))?) } // Copilot uses "approve"/"deny" @@ -65,9 +82,10 @@ impl HookProtocol for CopilotProtocol { #[cfg(test)] mod tests { use super::*; + use clash_hooks::ToolEvent; #[test] - fn parse_copilot_bash() { + fn parse_event_copilot_bash() { let raw = serde_json::json!({ "session_id": "cp-123", "cwd": "/home/user", @@ -75,8 +93,11 @@ mod tests { "tool_name": "bash", "tool_input": {"command": "git status"} }); - let input = CopilotProtocol.parse_tool_use(&raw).unwrap(); - assert_eq!(input.tool_name, "Bash"); + let event = CopilotProtocol.parse_event(&raw).unwrap(); + let clash_hooks::HookEvent::PreToolUse(e) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(e.tool_name(), "bash"); } #[test] diff --git a/clash/src/agents/gemini.rs b/clash/src/agents/gemini.rs index 19be5a1..cfb5d3b 100644 --- a/clash/src/agents/gemini.rs +++ b/clash/src/agents/gemini.rs @@ -3,42 +3,60 @@ //! Key differences from the default protocol: //! - Tool input rewriting uses `hookSpecificOutput.tool_input` merge //! - Session start uses `hookSpecificOutput` wrapper +//! - Uses non-standard hook_event_name values (e.g., "BeforeTool") use anyhow::Result; use serde_json::Value; -use super::protocol::{HookProtocol, json_str}; -use super::{AgentKind, resolve_tool_name}; -use crate::hooks::ToolUseHookInput; +use super::AgentKind; +use super::protocol::HookProtocol; pub struct GeminiProtocol; +impl GeminiProtocol { + /// Normalize Gemini JSON to Claude convention for `recv_from_value`. + fn normalize(raw: &Value) -> Value { + let mut normalized = serde_json::Map::new(); + + if let Some(obj) = raw.as_object() { + for (k, v) in obj { + normalized.insert(k.clone(), v.clone()); + } + } + + // Gemini uses non-standard hook_event_name values + if let Some(name) = normalized.get("hook_event_name").and_then(|v| v.as_str()) { + let pascal = normalize_gemini_event(name); + normalized.insert("hook_event_name".into(), Value::String(pascal)); + } + + // Default transcript_path if missing + if !normalized.contains_key("transcript_path") { + normalized.insert("transcript_path".into(), Value::String(String::new())); + } + + Value::Object(normalized) + } +} + +/// Normalize Gemini event names to Claude PascalCase convention. +fn normalize_gemini_event(name: &str) -> String { + match name { + "BeforeTool" | "beforeTool" => "PreToolUse".into(), + "AfterTool" | "afterTool" => "PostToolUse".into(), + "sessionStart" | "SessionStart" => "SessionStart".into(), + "stop" | "Stop" => "Stop".into(), + _ => super::copilot::normalize_event_name(name), + } +} + impl HookProtocol for GeminiProtocol { fn agent(&self) -> AgentKind { AgentKind::Gemini } - fn parse_tool_use(&self, raw: &Value) -> Result { - let tool_name = json_str(raw, "tool_name").to_string(); - let original = tool_name.clone(); - let resolved = resolve_tool_name(AgentKind::Gemini, &tool_name).to_string(); - - Ok(ToolUseHookInput { - session_id: json_str(raw, "session_id").to_string(), - transcript_path: json_str(raw, "transcript_path").to_string(), - cwd: json_str(raw, "cwd").to_string(), - permission_mode: "default".to_string(), - hook_event_name: json_str(raw, "hook_event_name").to_string(), - tool_name: resolved, - tool_input: raw - .get("tool_input") - .cloned() - .unwrap_or(Value::Object(serde_json::Map::new())), - tool_use_id: None, - tool_response: raw.get("tool_response").cloned(), - agent: Some(AgentKind::Gemini), - original_tool_name: Some(original), - }) + fn parse_event(&self, raw: &Value) -> Result { + Ok(clash_hooks::recv_from_value(Self::normalize(raw))?) } // Gemini uses hookSpecificOutput for tool input rewrites @@ -68,16 +86,15 @@ impl HookProtocol for GeminiProtocol { } output } - - // format_deny, format_ask, rewrite_for_sandbox, session_context — use defaults } #[cfg(test)] mod tests { use super::*; + use clash_hooks::{HookEventCommon, ToolEvent}; #[test] - fn parse_gemini_tool_use() { + fn parse_event_gemini_tool_use() { let raw = serde_json::json!({ "session_id": "gem-123", "transcript_path": "/tmp/gemini.jsonl", @@ -87,17 +104,16 @@ mod tests { "tool_input": {"command": "git status"}, "timestamp": "2026-03-26T00:00:00Z" }); - let input = GeminiProtocol.parse_tool_use(&raw).unwrap(); - assert_eq!(input.tool_name, "Bash"); - assert_eq!( - input.original_tool_name.as_deref(), - Some("run_shell_command") - ); - assert_eq!(input.agent, Some(AgentKind::Gemini)); + let event = GeminiProtocol.parse_event(&raw).unwrap(); + let clash_hooks::HookEvent::PreToolUse(e) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(e.tool_name(), "run_shell_command"); + assert_eq!(e.session_id(), "gem-123"); } #[test] - fn parse_gemini_read_file() { + fn parse_event_gemini_read_file() { let raw = serde_json::json!({ "session_id": "gem-123", "cwd": "/home/user", @@ -105,8 +121,11 @@ mod tests { "tool_name": "read_file", "tool_input": {"file_path": "/tmp/foo.txt"} }); - let input = GeminiProtocol.parse_tool_use(&raw).unwrap(); - assert_eq!(input.tool_name, "Read"); + let event = GeminiProtocol.parse_event(&raw).unwrap(); + let clash_hooks::HookEvent::PreToolUse(e) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(e.tool_name(), "read_file"); } #[test] diff --git a/clash/src/agents/opencode.rs b/clash/src/agents/opencode.rs index 80036e2..af2b7f8 100644 --- a/clash/src/agents/opencode.rs +++ b/clash/src/agents/opencode.rs @@ -6,36 +6,61 @@ use anyhow::Result; use serde_json::Value; +use super::AgentKind; use super::protocol::{HookProtocol, json_str_any, json_value_any}; -use super::{AgentKind, resolve_tool_name}; -use crate::hooks::ToolUseHookInput; pub struct OpenCodeProtocol; +impl OpenCodeProtocol { + /// Normalize OpenCode JSON to Claude convention for `recv_from_value`. + fn normalize(raw: &Value) -> Value { + let mut normalized = serde_json::Map::new(); + + // Map OpenCode field names to Claude convention + let session_id = json_str_any(raw, &["session_id", "sessionID"]); + normalized.insert("session_id".into(), Value::String(session_id.to_string())); + + normalized.insert("transcript_path".into(), Value::String(String::new())); + + let cwd = json_str_any(raw, &["cwd", "directory"]); + normalized.insert("cwd".into(), Value::String(cwd.to_string())); + + let event_name = json_str_any(raw, &["hook_event_name", "event"]); + let pascal = super::copilot::normalize_event_name(event_name); + normalized.insert("hook_event_name".into(), Value::String(pascal)); + + let tool_name = json_str_any(raw, &["tool_name", "tool"]); + if !tool_name.is_empty() { + normalized.insert("tool_name".into(), Value::String(tool_name.to_string())); + } + + if let Some(ti) = json_value_any(raw, &["tool_input", "args"]) { + normalized.insert("tool_input".into(), ti); + } + + if let Some(tr) = raw.get("tool_response") { + normalized.insert("tool_response".into(), tr.clone()); + } + + // Copy through any standard fields we didn't already handle + if let Some(pm) = raw.get("permission_mode") { + normalized.insert("permission_mode".into(), pm.clone()); + } + if let Some(tui) = raw.get("tool_use_id") { + normalized.insert("tool_use_id".into(), tui.clone()); + } + + Value::Object(normalized) + } +} + impl HookProtocol for OpenCodeProtocol { fn agent(&self) -> AgentKind { AgentKind::OpenCode } - fn parse_tool_use(&self, raw: &Value) -> Result { - let tool_name = json_str_any(raw, &["tool_name", "tool"]).to_string(); - let original = tool_name.clone(); - let resolved = resolve_tool_name(AgentKind::OpenCode, &tool_name).to_string(); - - Ok(ToolUseHookInput { - session_id: json_str_any(raw, &["session_id", "sessionID"]).to_string(), - transcript_path: String::new(), - cwd: json_str_any(raw, &["cwd", "directory"]).to_string(), - permission_mode: "default".to_string(), - hook_event_name: json_str_any(raw, &["hook_event_name", "event"]).to_string(), - tool_name: resolved, - tool_input: json_value_any(raw, &["tool_input", "args"]) - .unwrap_or(Value::Object(serde_json::Map::new())), - tool_use_id: None, - tool_response: raw.get("tool_response").cloned(), - agent: Some(AgentKind::OpenCode), - original_tool_name: Some(original), - }) + fn parse_event(&self, raw: &Value) -> Result { + Ok(clash_hooks::recv_from_value(Self::normalize(raw))?) } // OpenCode uses "action" field and "ask" for passthrough @@ -64,19 +89,24 @@ impl HookProtocol for OpenCodeProtocol { #[cfg(test)] mod tests { use super::*; + use clash_hooks::{HookEventCommon, ToolEvent}; #[test] - fn parse_opencode_bash() { + fn parse_event_opencode_bash() { let raw = serde_json::json!({ "tool": "bash", "sessionID": "oc-123", "directory": "/home/user", + "event": "PreToolUse", "args": {"command": "ls"} }); - let input = OpenCodeProtocol.parse_tool_use(&raw).unwrap(); - assert_eq!(input.tool_name, "Bash"); - assert_eq!(input.session_id, "oc-123"); - assert_eq!(input.cwd, "/home/user"); + let event = OpenCodeProtocol.parse_event(&raw).unwrap(); + let clash_hooks::HookEvent::PreToolUse(e) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(e.tool_name(), "bash"); + assert_eq!(e.session_id(), "oc-123"); + assert_eq!(e.cwd(), "/home/user"); } #[test] diff --git a/clash/src/agents/protocol.rs b/clash/src/agents/protocol.rs index c5fa363..64c9a71 100644 --- a/clash/src/agents/protocol.rs +++ b/clash/src/agents/protocol.rs @@ -5,76 +5,39 @@ //! core permission logic works identically regardless of which agent //! is calling Clash. //! -//! Most methods have default implementations that handle the common case. -//! Adding a new agent typically requires overriding only [`HookProtocol::agent`] -//! and [`HookProtocol::parse_tool_use`]. Override format methods only if -//! the agent uses a non-standard output format. +//! Required methods: [`agent`](HookProtocol::agent) and +//! [`parse_event`](HookProtocol::parse_event). Override format methods +//! only if the agent uses a non-standard output format. use anyhow::Result; use serde_json::Value; use super::AgentKind; -use crate::hooks::{SessionStartHookInput, StopHookInput, ToolUseHookInput}; +use crate::policy_decision::PolicyDecision; /// Abstraction over agent-specific hook JSON formats. /// /// Each agent (Claude Code, Gemini CLI, etc.) implements this trait to handle: -/// - Parsing its native JSON stdin into Clash's internal types +/// - Parsing its native JSON stdin into typed [`clash_hooks::HookEvent`]s /// - Formatting Clash decisions back into the agent's expected JSON output -/// - Rewriting tool inputs for sandbox enforcement /// /// # Adding a New Agent /// -/// Only two methods are required: [`agent`](HookProtocol::agent) and -/// [`parse_tool_use`](HookProtocol::parse_tool_use). All other methods -/// have sensible defaults. Override them only when your agent's protocol +/// Two methods are required: [`agent`](HookProtocol::agent) and +/// [`parse_event`](HookProtocol::parse_event). All other methods have +/// sensible defaults. Override them only when your agent's protocol /// diverges from the common JSON format. pub trait HookProtocol { /// Which agent this protocol handles. **Required.** fn agent(&self) -> AgentKind; - /// Parse the agent's PreToolUse JSON into a `ToolUseHookInput`. **Required.** + /// Parse the agent's raw JSON into a typed [`clash_hooks::HookEvent`]. /// - /// The returned `tool_name` MUST be the internal (Claude-style) name, - /// translated via [`super::resolve_tool_name`]. The original agent-native - /// name is preserved in `original_tool_name`. - fn parse_tool_use(&self, raw: &Value) -> Result; - - /// Parse the agent's PostToolUse JSON into a `ToolUseHookInput`. - /// - /// Default: delegates to `parse_tool_use`. - fn parse_post_tool_use(&self, raw: &Value) -> Result { - self.parse_tool_use(raw) - } - - /// Parse the agent's SessionStart JSON. - /// - /// Default: extracts common fields (session_id, cwd, source, model). - fn parse_session_start(&self, raw: &Value) -> Result { - Ok(SessionStartHookInput { - session_id: json_str(raw, "session_id").to_string(), - transcript_path: json_str(raw, "transcript_path").to_string(), - cwd: json_str(raw, "cwd").to_string(), - permission_mode: raw - .get("permission_mode") - .and_then(|v| v.as_str()) - .map(String::from), - hook_event_name: json_str_or(raw, "hook_event_name", "SessionStart").to_string(), - source: raw.get("source").and_then(|v| v.as_str()).map(String::from), - model: raw.get("model").and_then(|v| v.as_str()).map(String::from), - }) - } - - /// Parse the agent's Stop JSON. - /// - /// Default: extracts common fields (session_id, cwd). - fn parse_stop(&self, raw: &Value) -> Result { - Ok(StopHookInput { - session_id: json_str(raw, "session_id").to_string(), - transcript_path: json_str(raw, "transcript_path").to_string(), - cwd: json_str(raw, "cwd").to_string(), - hook_event_name: json_str_or(raw, "hook_event_name", "Stop").to_string(), - }) + /// Default: assumes the JSON is in Claude convention and delegates to + /// [`clash_hooks::recv_from_value`]. Override for agents that need + /// field normalization before deserialization. + fn parse_event(&self, raw: &Value) -> Result { + Ok(clash_hooks::recv_from_value(raw.clone())?) } /// Format an "allow" decision in the agent's expected output format. @@ -124,25 +87,55 @@ pub trait HookProtocol { output } - /// Rewrite a shell command's tool_input to run through `clash shell`. + /// Format a post-tool-use response with optional advisory context. + /// + /// Default: delegates to `format_allow` with reason "post-tool-use". + fn format_post_tool_use(&self, context: Option<&str>) -> Value { + self.format_allow(Some("post-tool-use"), context, None) + } + + /// Format a continue/passthrough response. + /// + /// Default: `{ "continue": true }` + fn format_continue(&self) -> Value { + serde_json::json!({"continue": true}) + } + + /// Format a permission request response (approve or deny on behalf of user). + /// + /// Default: `{ "continue": true }` (passthrough to agent's native UI). + fn format_permission_response( + &self, + _behavior: &str, + _message: Option<&str>, + _updated_input: Option<&Value>, + _interrupt: Option, + ) -> Value { + self.format_continue() + } + + /// Format a [`PolicyDecision`] into the agent's expected JSON output. /// - /// Default: rewrites the `command` field for tools with internal name "Bash". - fn rewrite_for_sandbox(&self, input: &ToolUseHookInput, sandbox_cmd: &str) -> Option { - if input.tool_name != "Bash" { - return None; + /// Default: dispatches to `format_allow` / `format_deny` / `format_ask`. + fn format_decision(&self, decision: &PolicyDecision) -> Value { + match decision { + PolicyDecision::Allow { + reason, + context, + updated_input, + } => self.format_allow( + Some(reason.as_str()), + context.as_deref(), + updated_input.clone(), + ), + PolicyDecision::Deny { reason, context } => { + self.format_deny(reason, context.as_deref()) + } + PolicyDecision::Ask { reason, context } => { + self.format_ask(Some(reason.as_str()), context.as_deref()) + } + PolicyDecision::Pass => self.format_ask(None, None), } - let command = input.tool_input.get("command")?.as_str()?; - let sandboxed = format!( - "{} shell --cwd {} -c {}", - shell_escape(sandbox_cmd), - shell_escape(&input.cwd), - shell_escape(command), - ); - let mut updated = input.tool_input.clone(); - updated - .as_object_mut()? - .insert("command".into(), Value::String(sandboxed)); - Some(updated) } /// Context string injected into the agent's session at startup. @@ -158,16 +151,6 @@ pub trait HookProtocol { // Helpers // --------------------------------------------------------------------------- -/// Extract a string field from JSON, returning "" if missing. -pub(crate) fn json_str<'a>(raw: &'a Value, field: &str) -> &'a str { - raw.get(field).and_then(|v| v.as_str()).unwrap_or("") -} - -/// Extract a string field from JSON with a default value. -pub(crate) fn json_str_or<'a>(raw: &'a Value, field: &str, default: &'a str) -> &'a str { - raw.get(field).and_then(|v| v.as_str()).unwrap_or(default) -} - /// Extract a string from one of several possible field names. pub(crate) fn json_str_any<'a>(raw: &'a Value, fields: &[&str]) -> &'a str { for field in fields { @@ -188,10 +171,6 @@ pub(crate) fn json_value_any(raw: &Value, fields: &[&str]) -> Option { None } -pub(crate) fn shell_escape(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - /// Construct the appropriate protocol implementation for an agent. pub fn get_protocol(agent: AgentKind) -> Box { match agent { diff --git a/clash/src/cmd/explain.rs b/clash/src/cmd/explain.rs index 415be2a..eccbcec 100644 --- a/clash/src/cmd/explain.rs +++ b/clash/src/cmd/explain.rs @@ -7,7 +7,6 @@ use crate::policy::match_tree::QueryContext; use crate::settings::ClashSettings; use crate::style; use crate::trace_display; -use crate::ui; /// Explain which policy rule would match a given tool invocation. /// @@ -47,11 +46,12 @@ pub fn run(json_output: bool, trace_mode: bool, tool: String, input_args: String let output = trace_display::trace_to_json(&policy_trace); println!("{}", serde_json::to_string_pretty(&output)?); } else { - ui::print_tool_header("Input:", &tool_name, &tool_input); - println!(); + let mut lines = display::format_tool_header("Input:", &tool_name, &tool_input); + lines.push(String::new()); for line in trace_display::render_trace(&policy_trace) { - println!("{line}"); + lines.push(line); } + println!("{}", lines.join("\n")); } } else { let decision = tree.evaluate(&tool_name, &tool_input); @@ -60,15 +60,16 @@ pub fn run(json_output: bool, trace_mode: bool, tool: String, input_args: String let output = display::decision_to_json(&decision); println!("{}", serde_json::to_string_pretty(&output)?); } else { - ui::print_tool_header("Input:", &tool_name, &tool_input); - println!(); - ui::print_decision(&decision); + let mut lines = display::format_tool_header("Input:", &tool_name, &tool_input); + lines.push(String::new()); + lines.extend(display::format_decision(&decision)); if let Some(ref sandbox) = decision.sandbox { - println!(); - println!("{}", style::header("Sandbox policy:")); - ui::print_sandbox_summary(sandbox); + lines.push(String::new()); + lines.push(style::header("Sandbox policy:").to_string()); + lines.extend(display::format_sandbox_summary(sandbox)); } + println!("{}", lines.join("\n")); } } diff --git a/clash/src/cmd/hooks.rs b/clash/src/cmd/hooks.rs index 65c525f..a880287 100644 --- a/clash/src/cmd/hooks.rs +++ b/clash/src/cmd/hooks.rs @@ -4,15 +4,17 @@ use anyhow::{Context, Result}; use tracing::{Level, info, instrument}; use crate::agents::AgentKind; -use crate::agents::protocol::{HookProtocol, get_protocol}; +use crate::agents::protocol::get_protocol; use crate::cli::{HookCmd, HookSubcommand}; -use crate::hooks::{HookOutput, HookSpecificOutput, ToolUseHookInput, is_interactive_tool}; +use crate::hooks::{HookOutput, HookSpecificOutput, PermissionBehavior, is_interactive_tool}; use crate::permissions::check_permission; -use crate::policy::Effect; +use crate::policy_decision::PolicyDecision; use crate::session_policy; use crate::settings::{ClashSettings, HookContext}; use crate::trace; +use clash_hooks::{HookEvent, HookEventCommon, ToolEvent}; + /// Generate a fallback session ID when the agent doesn't provide one. /// /// Uses the parent PID as a stable identifier — every `clash hook` invocation @@ -22,8 +24,6 @@ fn fallback_session_id(agent: AgentKind) -> String { format!("{agent}-{ppid}") } -use claude_settings::PermissionRule; - impl HookCmd { /// Handle hook when clash is disabled — drain stdin and return pass-through. fn run_disabled(&self) -> Result<()> { @@ -31,7 +31,7 @@ impl HookCmd { let output = match self.subcommand { HookSubcommand::SessionStart => { // Still read stdin to avoid broken pipe. - let _ = crate::hooks::SessionStartHookInput::from_reader(std::io::stdin().lock()); + let _ = std::io::copy(&mut std::io::stdin().lock(), &mut std::io::sink()); HookOutput::session_start(Some( "Clash is disabled (CLASH_DISABLE is set). \ All hooks are pass-through — no policy enforcement is active. \ @@ -51,6 +51,12 @@ impl HookCmd { Ok(()) } + /// Read a hook event from stdin, dispatching via the agent's protocol. + fn recv_event(&self) -> Result { + let raw: serde_json::Value = serde_json::from_reader(std::io::stdin().lock())?; + get_protocol(self.agent).parse_event(&raw) + } + #[instrument(level = Level::TRACE, skip(self), fields(agent = %self.agent))] pub fn run(&self) -> Result<()> { if crate::settings::is_disabled() { @@ -59,99 +65,100 @@ impl HookCmd { let passthrough = crate::settings::is_passthrough(); - let output = match self.subcommand { - HookSubcommand::PreToolUse => { - let input = self.parse_tool_use_input() - .context("parsing PreToolUse hook input from stdin — expected JSON with tool_name and tool_input fields")?; + // Read the event from stdin via the agent's protocol. + let event = self.recv_event().context("parsing hook event from stdin")?; + + // Dispatch on the event type. + match event { + HookEvent::PreToolUse(ref e) => { + let mut session_id = e.session_id().to_string(); + if session_id.is_empty() { + session_id = fallback_session_id(self.agent); + info!(session_id = %session_id, "Agent did not provide session_id, using fallback"); + } if passthrough { info!( - tool = %input.tool_name, + tool = %e.tool_name(), "CLASH_PASSTHROUGH: deferring to native permissions" ); - if let Err(e) = trace::sync_trace(&input.session_id, None) { - tracing::warn!(error = %e, "Failed to sync trace (PreToolUse/passthrough)"); + if let Err(err) = trace::sync_trace(&session_id, None) { + tracing::warn!(error = %err, "Failed to sync trace (PreToolUse/passthrough)"); } - HookOutput::continue_execution() + self.write_output(&HookOutput::continue_execution())?; } else { - let hook_ctx = HookContext::from_transcript_path(&input.transcript_path); + let hook_ctx = HookContext::from_transcript_path(e.transcript_path()); let settings = ClashSettings::load_or_create_with_session( - Some(&input.session_id), + Some(&session_id), Some(&hook_ctx), )?; - let output = check_permission(&input, &settings)?; + let decision = check_permission(e, Some(self.agent), &settings)?; // Interactive tools (e.g., AskUserQuestion) require user input - // via Claude Code's native UI. When the policy says "ask", pass - // through to CC's native prompt. When the policy explicitly allows - // or denies, enforce it — this enables mode-aware automation - // (e.g., allow ExitPlanMode in plan mode). - if is_interactive_tool(&input.tool_name) - && !is_deny_decision(&output) - && is_ask_decision(&output) + // via Claude Code's native UI. + if is_interactive_tool(e.tool_name()) + && !decision.is_deny() + && decision.is_ask() { - info!(tool = %input.tool_name, "Passthrough: interactive tool deferred to Claude Code"); - HookOutput::continue_execution() + info!(tool = %e.tool_name(), "Passthrough: interactive tool deferred to Claude Code"); + self.write_output(&HookOutput::continue_execution())?; } else { - // Update session stats for the status line (only here, not in - // log_decision, to avoid double-counting PermissionRequest). - if let Some(effect) = extract_effect(&output) { + // Update session stats. + if let Some(effect) = decision.effect() { crate::audit::update_session_stats( - &input.session_id, - &input.tool_name, - &input.tool_input, + &session_id, + e.tool_name(), + e.tool_input_raw(), effect, - &input.cwd, + e.cwd(), ); } - // If the decision is Ask, record it so PostToolUse can detect - // user approval and suggest a session policy rule. - if is_ask_decision(&output) - && let Some(ref tool_use_id) = input.tool_use_id + // Record Ask for session policy advice. + if decision.is_ask() + && let Some(tool_use_id) = e.tool_use_id() { session_policy::record_pending_ask( - &input.session_id, + &session_id, tool_use_id, - &input.tool_name, - &input.tool_input, - &input.cwd, + e.tool_name(), + e.tool_input_raw(), + e.cwd(), ); } - // Sync trace with the policy decision for this tool use. - let decision = input.tool_use_id.as_ref().and_then(|id| { - let effect = extract_effect(&output)?; - Some(trace::PolicyDecision { - tool_use_id: id.clone(), - tool_name: Some(input.tool_name.clone()), + // Sync trace. + let trace_decision = e.tool_use_id().and_then(|id| { + let effect = decision.effect()?; + Some(trace::TraceDecision { + tool_use_id: id.to_string(), + tool_name: Some(e.tool_name().to_string()), effect, reason: None, }) }); - if let Err(e) = trace::sync_trace(&input.session_id, decision) { - tracing::warn!(error = %e, "Failed to sync trace (PreToolUse)"); + if let Err(err) = trace::sync_trace(&session_id, trace_decision) { + tracing::warn!(error = %err, "Failed to sync trace (PreToolUse)"); } - output + self.write_decision(&decision)?; } } } - HookSubcommand::PostToolUse => { - let input = self - .parse_tool_use_input() - .context("parsing PostToolUse hook input from stdin")?; + HookEvent::PostToolUse(ref e) => { + let mut session_id = e.session_id().to_string(); + if session_id.is_empty() { + session_id = fallback_session_id(self.agent); + } - // Check if this tool use was previously "ask"ed and the user - // accepted. If so, return advisory context suggesting a session - // rule for Claude to offer the user. - let session_context = input.tool_use_id.as_deref().and_then(|tool_use_id| { + // Check if this tool use was previously "ask"ed. + let session_context = e.tool_use_id().and_then(|tool_use_id| { let advice = session_policy::process_post_tool_use( tool_use_id, - &input.session_id, - &input.tool_name, - &input.tool_input, - &input.cwd, + &session_id, + e.tool_name(), + e.tool_input_raw(), + e.cwd(), )?; info!( rule = %advice.suggested_rule, @@ -160,25 +167,24 @@ impl HookCmd { Some(advice.as_context()) }); - // Check if a sandboxed Bash command failed with network or - // filesystem errors, and provide hints about sandbox restrictions. + // Check for sandbox hints. let (network_context, fs_context) = { - let hook_ctx = HookContext::from_transcript_path(&input.transcript_path); + let hook_ctx = HookContext::from_transcript_path(e.transcript_path()); let settings = ClashSettings::load_or_create_with_session( - Some(&input.session_id), + Some(&session_id), Some(&hook_ctx), ) .ok(); - let net = settings.as_ref().and_then(|s| { - crate::network_hints::check_for_sandbox_network_hint(&input, s) - }); + let net = settings + .as_ref() + .and_then(|s| crate::network_hints::check_for_sandbox_network_hint(e, s)); let fs = settings .as_ref() - .and_then(|s| crate::sandbox_hints::check_for_sandbox_fs_hint(&input, s)); + .and_then(|s| crate::sandbox_hints::check_for_sandbox_fs_hint(e, s)); (net, fs) }; - // Combine contexts (session policy advice + sandbox hints). + // Combine contexts. let context = [session_context, network_context, fs_context] .into_iter() .flatten() @@ -189,157 +195,104 @@ impl HookCmd { Some(context.join("\n\n")) }; - // Sync trace to pick up tool responses. - if let Err(e) = trace::sync_trace(&input.session_id, None) { - tracing::warn!(error = %e, "Failed to sync trace (PostToolUse)"); + if let Err(err) = trace::sync_trace(&session_id, None) { + tracing::warn!(error = %err, "Failed to sync trace (PostToolUse)"); } - HookOutput::post_tool_use(context) + self.write_output(&HookOutput::post_tool_use(context))?; } - HookSubcommand::PermissionRequest => { - let input = self - .parse_tool_use_input() - .context("parsing PermissionRequest hook input from stdin")?; + HookEvent::PermissionRequest(ref e) => { + let mut session_id = e.session_id().to_string(); + if session_id.is_empty() { + session_id = fallback_session_id(self.agent); + } + if passthrough { info!( - tool = %input.tool_name, + tool = %e.tool_name(), "CLASH_PASSTHROUGH: deferring permission request to native UI" ); - HookOutput::continue_execution() + self.write_output(&HookOutput::continue_execution())?; } else { - let hook_ctx = HookContext::from_transcript_path(&input.transcript_path); + let hook_ctx = HookContext::from_transcript_path(e.transcript_path()); let settings = ClashSettings::load_or_create_with_session( - Some(&input.session_id), + Some(&session_id), Some(&hook_ctx), )?; - crate::handlers::handle_permission_request(&input, &settings)? + let output = + crate::handlers::handle_permission_request(e, Some(self.agent), &settings)?; + self.write_output(&output)?; } } - HookSubcommand::SessionStart => { - let mut input = self - .parse_session_start_input() - .context("parsing SessionStart hook input from stdin")?; - if input.session_id.is_empty() { - input.session_id = fallback_session_id(self.agent); - info!(session_id = %input.session_id, "Agent did not provide session_id, using fallback"); + HookEvent::SessionStart(ref e) => { + let mut session_id = e.session_id().to_string(); + if session_id.is_empty() { + session_id = fallback_session_id(self.agent); + info!(session_id = %session_id, "Agent did not provide session_id, using fallback"); } - crate::handlers::handle_session_start(&input)? + let output = crate::handlers::handle_session_start(e, &session_id)?; + self.write_output(&output)?; } - HookSubcommand::Stop => { - let mut input = self - .parse_stop_input() - .context("parsing Stop hook input from stdin")?; - if input.session_id.is_empty() { - input.session_id = fallback_session_id(self.agent); - info!(session_id = %input.session_id, "Agent did not provide session_id, using fallback"); + HookEvent::Stop(ref e) => { + let mut session_id = e.session_id().to_string(); + if session_id.is_empty() { + session_id = fallback_session_id(self.agent); + info!(session_id = %session_id, "Agent did not provide session_id, using fallback"); } - // Final catch-up sync for non-tool conversation turns. - if let Err(e) = trace::sync_trace(&input.session_id, None) { - tracing::warn!(error = %e, "Failed to sync trace (Stop)"); + if let Err(err) = trace::sync_trace(&session_id, None) { + tracing::warn!(error = %err, "Failed to sync trace (Stop)"); } - HookOutput::continue_execution() + self.write_output(&HookOutput::continue_execution())?; + } + _ => { + // Unknown or unhandled events — pass through. + self.write_output(&HookOutput::continue_execution())?; } - }; - - // For Claude, write the HookOutput directly (existing format). - // For other agents, convert the decision to their protocol format. - if self.agent == AgentKind::Claude { - output - .write_stdout() - .context("serializing hook response to stdout")?; - } else { - let protocol = get_protocol(self.agent); - let json = hook_output_to_protocol(&*protocol, &output); - serde_json::to_writer(std::io::stdout().lock(), &json) - .context("serializing hook response to stdout")?; - writeln!(std::io::stdout().lock())?; } - Ok(()) - } - - /// Read stdin as raw JSON, then delegate to the agent's protocol. - fn read_stdin_json(&self) -> Result { - Ok(serde_json::from_reader(std::io::stdin().lock())?) - } - /// Parse tool-use input from stdin via the agent's protocol. - fn parse_tool_use_input(&self) -> Result { - let protocol = get_protocol(self.agent); - let raw = self.read_stdin_json()?; - let mut input = protocol.parse_tool_use(&raw)?; - if input.session_id.is_empty() { - input.session_id = fallback_session_id(self.agent); - info!(session_id = %input.session_id, "Agent did not provide session_id, using fallback"); - } - Ok(input) + Ok(()) } - /// Parse session-start input from stdin via the agent's protocol. - fn parse_session_start_input(&self) -> Result { - let protocol = get_protocol(self.agent); - let raw = self.read_stdin_json()?; - protocol.parse_session_start(&raw) + /// Write a [`PolicyDecision`] directly to stdout in the agent's format. + fn write_decision(&self, decision: &PolicyDecision) -> Result<()> { + let json = get_protocol(self.agent).format_decision(decision); + serde_json::to_writer(std::io::stdout().lock(), &json) + .context("serializing hook response to stdout")?; + writeln!(std::io::stdout().lock())?; + Ok(()) } - /// Parse stop input from stdin via the agent's protocol. - fn parse_stop_input(&self) -> Result { + /// Write a HookOutput to stdout, converting to agent protocol format. + /// + /// Used for non-PreToolUse events (PostToolUse, SessionStart, PermissionRequest, etc.) + fn write_output(&self, output: &HookOutput) -> Result<()> { let protocol = get_protocol(self.agent); - let raw = self.read_stdin_json()?; - protocol.parse_stop(&raw) - } -} - -fn extract_effect(output: &HookOutput) -> Option { - match &output.hook_specific_output { - Some(HookSpecificOutput::PreToolUse(pre)) => match pre.permission_decision { - Some(PermissionRule::Allow) => Some(Effect::Allow), - Some(PermissionRule::Deny) => Some(Effect::Deny), - Some(PermissionRule::Ask) => Some(Effect::Ask), - Some(PermissionRule::Unset) | None => None, - }, - _ => None, - } -} - -fn is_ask_decision(output: &HookOutput) -> bool { - matches!(extract_effect(output), Some(Effect::Ask)) -} - -fn is_deny_decision(output: &HookOutput) -> bool { - matches!(extract_effect(output), Some(Effect::Deny)) -} - -/// Convert a Claude-format HookOutput into the agent's protocol format. -fn hook_output_to_protocol(protocol: &dyn HookProtocol, output: &HookOutput) -> serde_json::Value { - let (reason, context, updated_input) = match &output.hook_specific_output { - Some(HookSpecificOutput::PreToolUse(pre)) => ( - pre.permission_decision_reason.as_deref(), - pre.additional_context.as_deref(), - pre.updated_input.clone(), - ), - Some(HookSpecificOutput::SessionStart(ss)) => { - return protocol.format_session_start(ss.additional_context.as_deref()); - } - Some(HookSpecificOutput::PostToolUse(pt)) => { - // PostToolUse is advisory — just continue - return protocol.format_allow( - Some("post-tool-use"), - pt.additional_context.as_deref(), - None, - ); - } - _ => (None, None, None), - }; - - match extract_effect(output) { - Some(Effect::Allow) => protocol.format_allow(reason, context, updated_input), - Some(Effect::Deny) => protocol.format_deny(reason.unwrap_or("policy: denied"), context), - Some(Effect::Ask) => protocol.format_ask(reason, context), - None => { - // No decision (e.g., continue_execution) — allow passthrough - protocol.format_allow(None, None, None) - } + let json = match &output.hook_specific_output { + Some(HookSpecificOutput::SessionStart(ss)) => { + protocol.format_session_start(ss.additional_context.as_deref()) + } + Some(HookSpecificOutput::PostToolUse(pt)) => { + protocol.format_post_tool_use(pt.additional_context.as_deref()) + } + Some(HookSpecificOutput::PermissionRequest(pr)) => { + let behavior = match pr.decision.behavior { + PermissionBehavior::Allow => "allow", + PermissionBehavior::Deny => "deny", + }; + protocol.format_permission_response( + behavior, + pr.decision.message.as_deref(), + pr.decision.updated_input.as_ref(), + pr.decision.interrupt, + ) + } + _ => protocol.format_continue(), + }; + serde_json::to_writer(std::io::stdout().lock(), &json) + .context("serializing hook response to stdout")?; + writeln!(std::io::stdout().lock())?; + Ok(()) } } diff --git a/clash/src/cmd/policy.rs b/clash/src/cmd/policy.rs index f8e72ef..05a3ab9 100644 --- a/clash/src/cmd/policy.rs +++ b/clash/src/cmd/policy.rs @@ -5,7 +5,7 @@ use tracing::{Level, info, instrument}; use crate::cli::PolicyCmd; use crate::policy::manifest_edit; -use crate::policy::match_tree::{Decision, PolicyManifest}; +use crate::policy::match_tree::{MatchVerdict, PolicyManifest}; use crate::settings::{ClashSettings, PolicyLevel}; use crate::style; @@ -559,10 +559,10 @@ fn apply_mutation( let path = resolve_manifest_path(scope)?; let mut manifest = crate::policy_loader::read_manifest(&path)?; - // For Remove we only need the observable chain — Decision::Deny is a dummy. - let dummy_decision = Decision::Deny; + // For Remove we only need the observable chain — MatchVerdict::Deny is a dummy. + let dummy_decision = MatchVerdict::Deny; let decision = match &mutation { - PolicyMutation::Allow { sandbox } => Decision::Allow( + PolicyMutation::Allow { sandbox } => MatchVerdict::Allow( sandbox .as_deref() .map(|s| crate::policy::match_tree::SandboxRef(s.to_string())), @@ -679,7 +679,7 @@ fn build_rule_node( command: &[String], tool: Option, bin: Option, - decision: Decision, + decision: MatchVerdict, ) -> Result { // Positional command takes priority. if let Some((bin_name, args)) = parse_command(command) { diff --git a/clash/src/debug/replay.rs b/clash/src/debug/replay.rs index 6b32207..1d21d96 100644 --- a/clash/src/debug/replay.rs +++ b/clash/src/debug/replay.rs @@ -3,7 +3,7 @@ use anyhow::{Context, Result}; use crate::display; -use crate::policy::ir::PolicyDecision; +use crate::policy::ir::PolicyEvaluation; use crate::settings::ClashSettings; use crate::style; @@ -11,7 +11,7 @@ use crate::style; pub struct ReplayResult { pub tool_name: String, pub tool_input: serde_json::Value, - pub decision: PolicyDecision, + pub decision: PolicyEvaluation, pub multi_level: bool, } diff --git a/clash/src/display.rs b/clash/src/display.rs index 3713e40..4edf6c5 100644 --- a/clash/src/display.rs +++ b/clash/src/display.rs @@ -4,7 +4,7 @@ //! and `debug::sandbox` so that each call-site only needs to join/print the //! returned `Vec` lines. -use crate::policy::ir::PolicyDecision; +use crate::policy::ir::PolicyEvaluation; use crate::policy::sandbox_types::SandboxPolicy; use crate::style; @@ -22,7 +22,7 @@ pub fn format_tool_header( } /// Format a policy decision: effect, reason, matched/skipped rules, resolution. -pub fn format_decision(decision: &PolicyDecision) -> Vec { +pub fn format_decision(decision: &PolicyEvaluation) -> Vec { let mut lines = Vec::new(); lines.push(format!( @@ -91,7 +91,7 @@ pub fn format_sandbox_summary(sandbox: &SandboxPolicy) -> Vec { } /// Build the standard JSON representation of a policy decision. -pub fn decision_to_json(decision: &PolicyDecision) -> serde_json::Value { +pub fn decision_to_json(decision: &PolicyEvaluation) -> serde_json::Value { serde_json::json!({ "effect": format!("{}", decision.effect), "reason": decision.reason, diff --git a/clash/src/handlers.rs b/clash/src/handlers.rs index fdaca5f..dc1c2a7 100644 --- a/clash/src/handlers.rs +++ b/clash/src/handlers.rs @@ -6,15 +6,15 @@ use tracing::{Level, info, instrument, warn}; -use crate::hooks::{ - HookOutput, HookSpecificOutput, SessionStartHookInput, ToolUseHookInput, is_interactive_tool, -}; +use clash_hooks::{HookEventCommon, ToolEvent}; + +use crate::agents::AgentKind; +use crate::hooks::{HookOutput, is_interactive_tool}; use crate::notifications; use crate::permissions::check_permission; +use crate::policy_decision::PolicyDecision; use crate::settings::ClashSettings; -use claude_settings::PermissionRule; - /// Handle a permission request — decide whether to approve or deny on behalf of user. /// /// When the policy evaluates to "ask" and a Zulip bot is configured, the request @@ -22,71 +22,51 @@ use claude_settings::PermissionRule; /// present or the poll times out, we fall through to let the terminal user decide. #[instrument(level = Level::TRACE, skip(input, settings))] pub fn handle_permission_request( - input: &ToolUseHookInput, + input: &(impl ToolEvent + std::fmt::Debug), + agent: Option, settings: &ClashSettings, ) -> anyhow::Result { // Interactive tools (AskUserQuestion, EnterPlanMode, ExitPlanMode): // When the policy says "ask", pass through to CC's native UI so the // user sees the prompt. When the policy explicitly allows or denies, // enforce it — this enables mode-aware automation. - if is_interactive_tool(&input.tool_name) { - let pre_tool_result = check_permission(input, settings)?; - let is_deny = matches!( - pre_tool_result.hook_specific_output, - Some(HookSpecificOutput::PreToolUse(ref pre)) - if matches!(pre.permission_decision, Some(PermissionRule::Deny)) - ); - let is_allow = matches!( - pre_tool_result.hook_specific_output, - Some(HookSpecificOutput::PreToolUse(ref pre)) - if matches!(pre.permission_decision, Some(PermissionRule::Allow)) - ); - if is_deny { - let reason = match &pre_tool_result.hook_specific_output { - Some(HookSpecificOutput::PreToolUse(pre)) => pre - .permission_decision_reason - .clone() - .unwrap_or_else(|| "denied by policy".into()), - _ => "denied by policy".into(), - }; + if is_interactive_tool(input.tool_name()) { + let decision = check_permission(input, agent, settings)?; + if decision.is_deny() { + let reason = decision.reason().unwrap_or("denied by policy").to_string(); return Ok(HookOutput::deny_permission(reason, false)); } - if is_allow { - info!(tool = %input.tool_name, "Policy allows interactive tool"); + if decision.is_allow() { + info!(tool = %input.tool_name(), "Policy allows interactive tool"); return Ok(HookOutput::approve_permission(None)); } - info!(tool = %input.tool_name, "Passthrough: interactive tool deferred to Claude Code"); + info!(tool = %input.tool_name(), "Passthrough: interactive tool deferred to Claude Code"); return Ok(HookOutput::continue_execution()); } - let pre_tool_result = check_permission(input, settings)?; + let decision = check_permission(input, agent, settings)?; - // Convert PreToolUse decision to PermissionRequest format. + // Convert PolicyDecision to PermissionRequest format. // Claude Code validates that hookEventName matches the event type. - Ok(match pre_tool_result.hook_specific_output { - Some(HookSpecificOutput::PreToolUse(ref pre)) => match pre.permission_decision { - Some(PermissionRule::Allow) => HookOutput::approve_permission(None), - Some(PermissionRule::Deny) => { - let reason = pre - .permission_decision_reason - .clone() - .unwrap_or_else(|| "denied by policy".into()); - HookOutput::deny_permission(reason, false) - } - // Ask or no decision: try interactive desktop prompt first, - // then fall through to Zulip / terminal. - _ => resolve_via_desktop_or_zulip(input, settings), - }, - _ => pre_tool_result, + Ok(match decision { + PolicyDecision::Allow { .. } => HookOutput::approve_permission(None), + PolicyDecision::Deny { reason, .. } => HookOutput::deny_permission(reason, false), + // Ask or Pass: try interactive desktop prompt first, + // then fall through to Zulip / terminal. + PolicyDecision::Ask { .. } | PolicyDecision::Pass => { + resolve_via_desktop_or_zulip(input, settings) + } }) } /// Build a human-readable summary of the permission request for notifications. -fn permission_summary(input: &ToolUseHookInput) -> String { - let display = crate::agents::display_name(&input.tool_name); - match input.tool_name.as_str() { +fn permission_summary(input: &impl ToolEvent) -> String { + let display = crate::agents::display_name(input.tool_name()); + match input.tool_name() { "Bash" => { - let cmd = input.tool_input["command"].as_str().unwrap_or("(unknown)"); + let cmd = input.tool_input_raw()["command"] + .as_str() + .unwrap_or("(unknown)"); format!("{}: {}", display, cmd) } _ => display.to_string(), @@ -96,7 +76,7 @@ fn permission_summary(input: &ToolUseHookInput) -> String { /// Try to resolve a permission ask via desktop notification and/or Zulip. #[instrument(level = Level::TRACE, skip(input, settings))] pub fn resolve_via_desktop_or_zulip( - input: &ToolUseHookInput, + input: &impl ToolEvent, settings: &ClashSettings, ) -> HookOutput { let has_desktop = settings.notifications.desktop; @@ -119,7 +99,7 @@ pub fn resolve_via_desktop_or_zulip( } fn resolve_via_desktop_then_continue( - input: &ToolUseHookInput, + input: &impl ToolEvent, settings: &ClashSettings, ) -> HookOutput { let summary = permission_summary(input); @@ -146,13 +126,13 @@ fn resolve_via_desktop_then_continue( } } -/// Build a [`notifications::PermissionRequest`] from a tool use hook input. -fn build_permission_request(input: &ToolUseHookInput) -> notifications::PermissionRequest { +/// Build a [`notifications::PermissionRequest`] from a tool event. +fn build_permission_request(input: &impl ToolEvent) -> notifications::PermissionRequest { notifications::PermissionRequest { - tool_name: input.tool_name.clone(), - tool_input: input.tool_input.clone(), - session_id: input.session_id.clone(), - cwd: input.cwd.clone(), + tool_name: input.tool_name().to_string(), + tool_input: input.tool_input_raw().clone(), + session_id: input.session_id().to_string(), + cwd: input.cwd().to_string(), } } @@ -175,7 +155,7 @@ fn zulip_result_to_output( } } -fn start_zulip_background(input: &ToolUseHookInput, settings: &ClashSettings) { +fn start_zulip_background(input: &impl ToolEvent, settings: &ClashSettings) { let Some(ref zulip_config) = settings.notifications.zulip else { return; }; @@ -205,7 +185,7 @@ fn start_zulip_background(input: &ToolUseHookInput, settings: &ClashSettings) { } #[instrument(level = Level::TRACE, skip(input, settings))] -fn resolve_via_zulip_or_continue(input: &ToolUseHookInput, settings: &ClashSettings) -> HookOutput { +fn resolve_via_zulip_or_continue(input: &impl ToolEvent, settings: &ClashSettings) -> HookOutput { let Some(ref zulip_config) = settings.notifications.zulip else { return HookOutput::continue_execution(); }; @@ -221,14 +201,21 @@ fn resolve_via_zulip_or_continue(input: &ToolUseHookInput, settings: &ClashSetti } /// Handle a session start event — validate policy/settings and report status to Claude. +/// +/// # Arguments +/// * `input` — the SessionStart event (may have empty session_id) +/// * `session_id` — the effective session ID (may be a fallback derived from +/// parent PID if the agent didn't provide one) #[instrument(level = Level::TRACE, skip(input))] -pub fn handle_session_start(input: &SessionStartHookInput) -> anyhow::Result { +pub fn handle_session_start( + input: &clash_hooks::event::SessionStart, + session_id: &str, +) -> anyhow::Result { // Ensure the user has a policy file — create one with safe defaults if not. let created_policy = ClashSettings::ensure_user_policy_exists()?; - let hook_ctx = crate::settings::HookContext::from_transcript_path(&input.transcript_path); - let _settings = - ClashSettings::load_or_create_with_session(Some(&input.session_id), Some(&hook_ctx))?; + let hook_ctx = crate::settings::HookContext::from_transcript_path(input.transcript_path()); + let _settings = ClashSettings::load_or_create_with_session(Some(session_id), Some(&hook_ctx))?; let mut lines = Vec::new(); @@ -246,7 +233,7 @@ pub fn handle_session_start(input: &SessionStartHookInput) -> anyhow::Result &'static str { } /// Check sandbox support, init session, and symlink — shared by both paths. -fn check_sandbox_and_session(lines: &mut Vec, input: &SessionStartHookInput) { +/// +/// # Arguments +/// * `lines` — accumulator for status messages to return to the agent +/// * `input` — the SessionStart event (may have empty session_id) +/// * `session_id` — the effective session ID (may be a fallback derived from +/// parent PID if the agent didn't provide one) +fn check_sandbox_and_session( + lines: &mut Vec, + input: &clash_hooks::event::SessionStart, + session_id: &str, +) { // 3. Check sandbox support let support = crate::sandbox::check_support(); match support { @@ -279,12 +276,7 @@ fn check_sandbox_and_session(lines: &mut Vec, input: &SessionStartHookIn } // 4. Initialize per-session history directory - match crate::audit::init_session( - &input.session_id, - &input.cwd, - input.source.as_deref(), - input.model.as_deref(), - ) { + match crate::audit::init_session(session_id, input.cwd(), input.source(), input.model()) { Ok(session_dir) => { lines.push(format!("session history: {}", session_dir.display())); } @@ -294,26 +286,26 @@ fn check_sandbox_and_session(lines: &mut Vec, input: &SessionStartHookIn } // 4b. Write active session marker so CLI commands can find this session. - if let Err(e) = ClashSettings::set_active_session(&input.session_id) { + if let Err(e) = ClashSettings::set_active_session(session_id) { warn!(error = %e, "Failed to write active session marker"); } // 4c. Initialize toolpath tracing for this session. if let Err(e) = crate::trace::init_trace( - &input.session_id, - &input.transcript_path, - &input.cwd, - input.model.as_deref(), - input.source.as_deref(), + session_id, + input.transcript_path(), + input.cwd(), + input.model(), + input.source(), ) { warn!(error = %e, "Failed to initialize session trace"); } // 5. Session metadata - if let Some(ref source) = input.source { + if let Some(source) = input.source() { lines.push(format!("session source: {}", source)); } - if let Some(ref model) = input.model { + if let Some(model) = input.model() { lines.push(format!("model: {}", model)); } } @@ -333,9 +325,11 @@ fn finish_session_start(lines: Vec) -> anyhow::Result { #[cfg(test)] mod tests { use super::*; + use crate::hooks::HookSpecificOutput; - fn session_start_context(input: &SessionStartHookInput) -> String { - let output = handle_session_start(input).expect("session start should succeed"); + fn session_start_context(input: &clash_hooks::event::SessionStart) -> String { + let output = + handle_session_start(input, input.session_id()).expect("session start should succeed"); match &output.hook_specific_output { Some(HookSpecificOutput::SessionStart(s)) => { s.additional_context.clone().expect("should have context") @@ -344,21 +338,25 @@ mod tests { } } - fn default_session_start_input() -> SessionStartHookInput { - SessionStartHookInput { - session_id: "test-session".into(), - transcript_path: "/tmp/transcript.jsonl".into(), - cwd: "/tmp".into(), - permission_mode: Some("default".into()), - hook_event_name: "SessionStart".into(), - source: Some("startup".into()), - model: Some("claude-sonnet-4-20250514".into()), + fn default_session_start_event() -> clash_hooks::event::SessionStart { + let json = serde_json::json!({ + "session_id": "test-session", + "transcript_path": "/tmp/transcript.jsonl", + "cwd": "/tmp", + "permission_mode": "default", + "hook_event_name": "SessionStart", + "source": "startup", + "model": "claude-sonnet-4-20250514", + }); + match clash_hooks::recv_from_value(json).unwrap() { + clash_hooks::HookEvent::SessionStart(e) => e, + _ => panic!("expected SessionStart"), } } #[test] fn test_session_start_reports_sandbox_support() { - let ctx = session_start_context(&default_session_start_input()); + let ctx = session_start_context(&default_session_start_event()); assert!( ctx.contains("sandbox:"), "should report sandbox status, got: {ctx}" @@ -367,7 +365,7 @@ mod tests { #[test] fn test_session_start_reports_session_metadata() { - let ctx = session_start_context(&default_session_start_input()); + let ctx = session_start_context(&default_session_start_event()); assert!(ctx.contains("session source: startup"), "got: {ctx}"); assert!( ctx.contains("model: claude-sonnet-4-20250514"), @@ -377,7 +375,7 @@ mod tests { #[test] fn test_session_start_reports_managing_permissions() { - let ctx = session_start_context(&default_session_start_input()); + let ctx = session_start_context(&default_session_start_event()); assert!( ctx.contains("Clash is managing permissions via hooks"), "should report clash is managing permissions, got: {ctx}" diff --git a/clash/src/hooks.rs b/clash/src/hooks.rs index 9468692..354b1ba 100644 --- a/clash/src/hooks.rs +++ b/clash/src/hooks.rs @@ -1,162 +1,26 @@ -use std::io::{Read, Write}; +//! Hook types for the Claude Code hook protocol. +//! +//! **Input:** Typed events from [`clash_hooks`] are used for all input handling. +//! +//! **Output:** [`HookOutput`] and [`HookSpecificOutput`] are the wire format that +//! Claude Code expects. `HookOutput` is used as the output boundary for all agents. -use claude_settings::PermissionRule; -use serde::{Deserialize, Serialize}; -use tracing::{Level, instrument}; - -/// The complete hook input received from Claude Code via stdin -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -pub enum HookInput { - /// PreToolUse, PostToolUse, PermissionRequest events - ToolUse(ToolUseHookInput), - /// SessionStart events - SessionStart(SessionStartHookInput), -} - -/// Hook input for tool-related events (PreToolUse, PostToolUse, PermissionRequest) -/// -/// The `tool_name` field carries the internal (Claude-style) name after protocol -/// normalization. The original agent-native name is preserved in `original_tool_name`. -#[derive(Debug, Clone, Deserialize, Default)] -pub struct ToolUseHookInput { - pub session_id: String, - pub transcript_path: String, - pub cwd: String, - pub permission_mode: String, - pub hook_event_name: String, - pub tool_name: String, - pub tool_input: serde_json::Value, - pub tool_use_id: Option, - /// Present in PostToolUse events - #[serde(default)] - pub tool_response: Option, - - // -- Multi-agent fields (not deserialized from JSON, set by protocol layer) -- - /// Which agent sent this hook input. - #[serde(skip)] - pub agent: Option, - /// The agent's original tool name before normalization (e.g. "run_shell_command"). - /// For Claude, this is the same as `tool_name`. - #[serde(skip)] - pub original_tool_name: Option, -} - -/// Hook input for SessionStart events -#[derive(Debug, Clone, Deserialize, Default)] -pub struct SessionStartHookInput { - #[serde(default)] - pub session_id: String, - #[serde(default)] - pub transcript_path: String, - #[serde(default)] - pub cwd: String, - #[serde(default)] - pub permission_mode: Option, - #[serde(default)] - pub hook_event_name: String, - #[serde(default)] - pub source: Option, - #[serde(default)] - pub model: Option, -} - -impl SessionStartHookInput { - /// Parse from any reader (for testability) - #[instrument(level = Level::TRACE, skip(reader))] - pub fn from_reader(reader: impl Read) -> anyhow::Result { - Ok(serde_json::from_reader(reader)?) - } -} - -/// Hook input for Stop events (conversation turn ended without a tool call) -#[derive(Debug, Clone, Deserialize, Default)] -pub struct StopHookInput { - #[serde(default)] - pub session_id: String, - #[serde(default)] - pub transcript_path: String, - #[serde(default)] - pub cwd: String, - #[serde(default)] - pub hook_event_name: String, -} - -impl StopHookInput { - /// Parse from any reader (for testability) - #[instrument(level = Level::TRACE, skip(reader))] - pub fn from_reader(reader: impl Read) -> anyhow::Result { - Ok(serde_json::from_reader(reader)?) - } -} - -impl HookInput { - /// Parse from any reader (for testability) - #[instrument(level = Level::TRACE, skip(reader))] - pub fn from_reader(reader: impl Read) -> anyhow::Result { - Ok(serde_json::from_reader(reader)?) - } - - /// Parse from stdin (convenience wrapper for production) - #[instrument(level = Level::TRACE)] - pub fn from_stdin() -> anyhow::Result { - Self::from_reader(std::io::stdin().lock()) - } - - /// Get the hook event name - pub fn hook_event_name(&self) -> &str { - match self { - HookInput::ToolUse(input) => &input.hook_event_name, - HookInput::SessionStart(input) => &input.hook_event_name, - } - } - - /// Get the session ID - pub fn session_id(&self) -> &str { - match self { - HookInput::ToolUse(input) => &input.session_id, - HookInput::SessionStart(input) => &input.session_id, - } - } +use std::io::Write; - /// Check if this is a tool use event - pub fn as_tool_use(&self) -> Option<&ToolUseHookInput> { - match self { - HookInput::ToolUse(input) => Some(input), - _ => None, - } - } - - /// Check if this is a session start event - pub fn as_session_start(&self) -> Option<&SessionStartHookInput> { - match self { - HookInput::SessionStart(input) => Some(input), - _ => None, - } - } -} - -impl ToolUseHookInput { - /// Parse from any reader (for testability) - #[instrument(level = Level::TRACE, skip(reader))] - pub fn from_reader(reader: impl Read) -> anyhow::Result { - Ok(serde_json::from_reader(reader)?) - } +use claude_settings::PermissionRule; +use serde::Serialize; - /// Get typed tool input based on tool_name. - /// - /// Delegates to [`crate::claude::tools::ToolInput::parse`] for the - /// canonical tool-name → typed-struct mapping. - #[instrument(level = Level::TRACE, skip(self))] - pub fn typed_tool_input(&self) -> crate::claude::tools::ToolInput { - crate::claude::tools::ToolInput::parse(&self.tool_name, self.tool_input.clone()) - } -} +// Re-export clash_hooks types used across the crate boundary. +pub use clash_hooks::CommonFields; +pub use clash_hooks::{HookEvent, HookEventCommon, ToolEvent}; -// Typed tool input structs live in crate::claude::tools. -// Re-export for backwards compatibility. +// Re-export typed tool input types for backwards compatibility. pub use crate::claude::tools::{BashInput, EditInput, ReadInput, ToolInput, WriteInput}; +// ═══════════════════════════════════════════════════════════════════════ +// OUTPUT TYPES — wire format for Claude Code hook responses +// ═══════════════════════════════════════════════════════════════════════ + /// Hook-specific output for PreToolUse #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -260,25 +124,21 @@ impl HookOutput { } /// Create an "allow" response for PreToolUse - bypasses permission system. - #[instrument(level = Level::TRACE)] pub fn allow(reason: Option, context: Option) -> Self { Self::pretooluse_output(PermissionRule::Allow, reason, context, None) } /// Create a "deny" response for PreToolUse - prevents tool execution. - #[instrument(level = Level::TRACE)] pub fn deny(reason: String, context: Option) -> Self { Self::pretooluse_output(PermissionRule::Deny, Some(reason), context, None) } /// Create an "ask" response for PreToolUse - prompts user for confirmation. - #[instrument(level = Level::TRACE)] pub fn ask(reason: Option, context: Option) -> Self { Self::pretooluse_output(PermissionRule::Ask, reason, context, None) } /// Approve a permission request on behalf of the user - #[instrument(level = Level::TRACE)] pub fn approve_permission(updated_input: Option) -> Self { Self { should_continue: true, @@ -297,7 +157,6 @@ impl HookOutput { } /// Deny a permission request on behalf of the user - #[instrument(level = Level::TRACE)] pub fn deny_permission(message: String, interrupt: bool) -> Self { Self { should_continue: true, @@ -317,7 +176,6 @@ impl HookOutput { /// Set the updated_input field on a PreToolUse response. /// This rewrites the tool input before Claude Code executes it. - #[instrument(level = Level::TRACE, skip(self))] pub fn set_updated_input(&mut self, updated_input: serde_json::Value) { if let Some(HookSpecificOutput::PreToolUse(ref mut pre)) = self.hook_specific_output { pre.updated_input = Some(updated_input); @@ -325,7 +183,6 @@ impl HookOutput { } /// Create a SessionStart response with optional context about the session setup. - #[instrument(level = Level::TRACE)] pub fn session_start(additional_context: Option) -> Self { Self { should_continue: true, @@ -337,7 +194,6 @@ impl HookOutput { } /// Create a PostToolUse response with optional advisory context. - #[instrument(level = Level::TRACE)] pub fn post_tool_use(additional_context: Option) -> Self { match additional_context { Some(ctx) => Self { @@ -352,7 +208,6 @@ impl HookOutput { } /// Continue execution without making a decision (for informational hooks) - #[instrument(level = Level::TRACE)] pub fn continue_execution() -> Self { Self { should_continue: true, @@ -361,7 +216,6 @@ impl HookOutput { } /// Write response to any writer (for testability) - #[instrument(level = Level::TRACE, skip(self, writer))] pub fn write_to(&self, mut writer: impl Write) -> anyhow::Result<()> { serde_json::to_writer(&mut writer, self)?; writeln!(writer)?; @@ -369,7 +223,6 @@ impl HookOutput { } /// Write response to stdout (convenience wrapper for production) - #[instrument(level = Level::TRACE, skip(self))] pub fn write_stdout(&self) -> anyhow::Result<()> { self.write_to(std::io::stdout().lock()) } @@ -394,63 +247,6 @@ pub mod exit_code { mod tests { use super::*; - fn sample_tool_use_json() -> &'static str { - r#"{ - "session_id": "test-session", - "transcript_path": "/tmp/transcript.jsonl", - "cwd": "/home/user/project", - "permission_mode": "default", - "hook_event_name": "PreToolUse", - "tool_name": "Bash", - "tool_input": {"command": "git status", "timeout": 120000}, - "tool_use_id": "toolu_01ABC" - }"# - } - - #[test] - fn test_parse_tool_use_input() { - let input = HookInput::from_reader(sample_tool_use_json().as_bytes()).unwrap(); - assert_eq!(input.session_id(), "test-session"); - assert_eq!(input.hook_event_name(), "PreToolUse"); - - let tool_use = input.as_tool_use().expect("Should be ToolUse variant"); - assert_eq!(tool_use.tool_name, "Bash"); - } - - #[test] - fn test_typed_bash_input() { - let input = ToolUseHookInput::from_reader(sample_tool_use_json().as_bytes()).unwrap(); - match input.typed_tool_input() { - ToolInput::Bash(bash) => { - assert_eq!(bash.command, "git status"); - assert_eq!(bash.timeout, Some(120000)); - } - other => panic!("Expected Bash input, got {:?}", other), - } - } - - #[test] - fn test_typed_write_input() { - let json = r#"{ - "session_id": "test", - "transcript_path": "/tmp/t.jsonl", - "cwd": "/tmp", - "permission_mode": "default", - "hook_event_name": "PreToolUse", - "tool_name": "Write", - "tool_input": {"file_path": "/tmp/test.txt", "content": "hello world"}, - "tool_use_id": "toolu_02" - }"#; - let input = ToolUseHookInput::from_reader(json.as_bytes()).unwrap(); - match input.typed_tool_input() { - ToolInput::Write(write) => { - assert_eq!(write.file_path, "/tmp/test.txt"); - assert_eq!(write.content, "hello world"); - } - other => panic!("Expected Write input, got {:?}", other), - } - } - #[test] fn test_output_allow() { let output = HookOutput::allow(Some("Safe command".into()), None); diff --git a/clash/src/lib.rs b/clash/src/lib.rs index b60d1d1..ad90f65 100644 --- a/clash/src/lib.rs +++ b/clash/src/lib.rs @@ -19,14 +19,16 @@ //! # Example //! //! ```no_run -//! use clash::hooks::ToolUseHookInput; +//! use clash::hooks::ToolEvent; //! use clash::permissions::check_permission; //! use clash::settings::ClashSettings; //! //! let settings = ClashSettings::load_or_create().unwrap(); -//! let input = ToolUseHookInput::from_reader(std::io::stdin().lock()).unwrap(); -//! let output = check_permission(&input, &settings).unwrap(); -//! output.write_stdout().unwrap(); +//! let event = clash_hooks::recv().unwrap(); +//! if let clash_hooks::HookEvent::PreToolUse(ref e) = event { +//! let decision = check_permission(e, None, &settings).unwrap(); +//! println!("decision: {:?}", decision.effect()); +//! } //! ``` pub mod agents; @@ -45,6 +47,7 @@ pub mod network_hints; pub mod notifications; pub mod permissions; pub mod policy; +pub mod policy_decision; pub mod policy_loader; pub mod sandbox; pub mod sandbox_cmd; diff --git a/clash/src/network_hints.rs b/clash/src/network_hints.rs index cdde6ff..e9be6d7 100644 --- a/clash/src/network_hints.rs +++ b/clash/src/network_hints.rs @@ -7,7 +7,8 @@ use tracing::{Level, info, instrument}; -use crate::hooks::ToolUseHookInput; +use clash_hooks::ToolEvent; + use crate::policy::sandbox_types::NetworkPolicy; use crate::settings::ClashSettings; @@ -52,16 +53,16 @@ const NETWORK_ERROR_PATTERNS: &[&str] = &[ /// by sandbox network restrictions. Returns advisory context if so. #[instrument(level = Level::TRACE, skip(input, settings))] pub fn check_for_sandbox_network_hint( - input: &ToolUseHookInput, + input: &clash_hooks::event::PostToolUse, settings: &ClashSettings, ) -> Option { // Only check Bash tool responses - if input.tool_name != "Bash" { + if input.tool_name() != "Bash" { return None; } // Extract text from tool_response - let response_text = extract_response_text(input.tool_response.as_ref()?)?; + let response_text = extract_response_text(input.tool_response()?)?; // Check for network error patterns if !contains_network_error(&response_text) { @@ -71,7 +72,7 @@ pub fn check_for_sandbox_network_hint( // Re-evaluate the policy to check if this command would run under // a sandbox with NetworkPolicy::Deny let tree = settings.policy_tree()?; - let decision = tree.evaluate(&input.tool_name, &input.tool_input); + let decision = tree.evaluate(input.tool_name(), input.tool_input_raw()); let network_policy = decision.sandbox.as_ref().map(|s| &s.network); @@ -147,6 +148,48 @@ mod tests { use super::*; use serde_json::json; + fn post_tool_use_event( + tool_name: &str, + tool_input: serde_json::Value, + tool_response: serde_json::Value, + ) -> clash_hooks::event::PostToolUse { + let json = json!({ + "session_id": "test", + "transcript_path": "/tmp/t.jsonl", + "cwd": "/tmp", + "permission_mode": "default", + "hook_event_name": "PostToolUse", + "tool_name": tool_name, + "tool_input": tool_input, + "tool_use_id": "toolu_test", + "tool_response": tool_response, + }); + match clash_hooks::recv_from_value(json).unwrap() { + clash_hooks::HookEvent::PostToolUse(e) => e, + _ => panic!("expected PostToolUse"), + } + } + + fn post_tool_use_event_no_response( + tool_name: &str, + tool_input: serde_json::Value, + ) -> clash_hooks::event::PostToolUse { + let json = json!({ + "session_id": "test", + "transcript_path": "/tmp/t.jsonl", + "cwd": "/tmp", + "permission_mode": "default", + "hook_event_name": "PostToolUse", + "tool_name": tool_name, + "tool_input": tool_input, + "tool_use_id": "toolu_test", + }); + match clash_hooks::recv_from_value(json).unwrap() { + clash_hooks::HookEvent::PostToolUse(e) => e, + _ => panic!("expected PostToolUse"), + } + } + #[test] fn test_contains_network_error_dns() { assert!(contains_network_error( @@ -225,33 +268,21 @@ mod tests { #[test] fn test_check_returns_none_for_non_bash() { - let input = ToolUseHookInput { - tool_name: "Read".into(), - tool_response: Some(json!("Could not resolve host")), - ..Default::default() - }; + let input = post_tool_use_event("Read", json!({}), json!("Could not resolve host")); let settings = ClashSettings::default(); assert!(check_for_sandbox_network_hint(&input, &settings).is_none()); } #[test] fn test_check_returns_none_without_response() { - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_response: None, - ..Default::default() - }; + let input = post_tool_use_event_no_response("Bash", json!({"command": "curl example.com"})); let settings = ClashSettings::default(); assert!(check_for_sandbox_network_hint(&input, &settings).is_none()); } #[test] fn test_check_returns_none_for_non_network_error() { - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_response: Some(json!("file not found")), - ..Default::default() - }; + let input = post_tool_use_event("Bash", json!({"command": "ls"}), json!("file not found")); let settings = ClashSettings::default(); assert!(check_for_sandbox_network_hint(&input, &settings).is_none()); } @@ -261,12 +292,11 @@ mod tests { // No compiled policy → no decision tree → returns None let settings = ClashSettings::default(); assert!(settings.decision_tree().is_none()); - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: json!({"command": "curl example.com"}), - tool_response: Some(json!("Could not resolve host")), - ..Default::default() - }; + let input = post_tool_use_event( + "Bash", + json!({"command": "curl example.com"}), + json!("Could not resolve host"), + ); assert!(check_for_sandbox_network_hint(&input, &settings).is_none()); } @@ -283,13 +313,11 @@ mod tests { ]}} ]}"#, ); - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: json!({"command": "curl example.com"}), - tool_response: Some(json!("curl: (6) Could not resolve host: example.com")), - cwd: "/tmp".into(), - ..Default::default() - }; + let input = post_tool_use_event( + "Bash", + json!({"command": "curl example.com"}), + json!("curl: (6) Could not resolve host: example.com"), + ); let result = check_for_sandbox_network_hint(&input, &settings); assert!( result.is_some(), @@ -314,13 +342,11 @@ mod tests { ]}} ]}"#, ); - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: json!({"command": "curl example.com"}), - tool_response: Some(json!("curl: (6) Could not resolve host: example.com")), - cwd: "/tmp".into(), - ..Default::default() - }; + let input = post_tool_use_event( + "Bash", + json!({"command": "curl example.com"}), + json!("curl: (6) Could not resolve host: example.com"), + ); let result = check_for_sandbox_network_hint(&input, &settings); assert!( result.is_some(), @@ -345,13 +371,11 @@ mod tests { ]}} ]}"#, ); - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: json!({"command": "curl example.com"}), - tool_response: Some(json!("Could not resolve host")), - cwd: "/tmp".into(), - ..Default::default() - }; + let input = post_tool_use_event( + "Bash", + json!({"command": "curl example.com"}), + json!("Could not resolve host"), + ); assert!(check_for_sandbox_network_hint(&input, &settings).is_none()); } @@ -370,13 +394,11 @@ mod tests { ]}} ]}"#, ); - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: json!({"command": "curl example.com"}), - tool_response: Some(json!("Could not resolve host")), - cwd: "/tmp".into(), - ..Default::default() - }; + let input = post_tool_use_event( + "Bash", + json!({"command": "curl example.com"}), + json!("Could not resolve host"), + ); assert!(check_for_sandbox_network_hint(&input, &settings).is_some()); } } diff --git a/clash/src/permissions.rs b/clash/src/permissions.rs index 5b43da2..8b915f3 100644 --- a/clash/src/permissions.rs +++ b/clash/src/permissions.rs @@ -1,15 +1,39 @@ +use clash_hooks::ToolEvent; + +use crate::agents::AgentKind; use crate::policy::Effect; +use crate::policy_decision::PolicyDecision; use tracing::{Level, info, instrument, warn}; -use crate::hooks::{HookOutput, ToolInput, ToolUseHookInput}; use crate::settings::ClashSettings; /// Check if a tool invocation should be allowed, denied, or require user confirmation. -#[instrument(level = Level::TRACE, ret)] +#[instrument(level = Level::TRACE, skip(input), ret)] pub fn check_permission( - input: &ToolUseHookInput, + input: &impl ToolEvent, + agent: Option, settings: &ClashSettings, -) -> anyhow::Result { +) -> anyhow::Result { + check_permission_raw( + input.tool_name(), + input.tool_input_raw(), + input.permission_mode().unwrap_or("default"), + input.session_id(), + input.cwd(), + agent, + settings, + ) +} + +fn check_permission_raw( + tool_name: &str, + tool_input: &serde_json::Value, + permission_mode: &str, + session_id: &str, + cwd: &str, + agent: Option, + settings: &ClashSettings, +) -> anyhow::Result { let tree = match settings.policy_tree() { Some(t) => t, None => { @@ -54,20 +78,23 @@ pub fn check_permission( ); warn!("{}", reason); - return Ok(HookOutput::ask(Some(reason), Some(context))); + return Ok(PolicyDecision::Ask { + reason, + context: Some(context), + }); } }; let decision = tree.evaluate_with_context( - &input.tool_name, - &input.tool_input, - Some(&input.permission_mode), - input.agent.as_ref().map(|a| a.to_string()).as_deref(), + tool_name, + tool_input, + Some(permission_mode), + agent.as_ref().map(|a| a.to_string()).as_deref(), ); - let noun = extract_noun(&input.tool_name, &input.tool_input); + let noun = extract_noun(tool_name, tool_input); info!( - tool = %input.tool_name, + tool = %tool_name, noun = %noun, effect = %decision.effect, reason = ?decision.reason, @@ -78,9 +105,9 @@ pub fn check_permission( // Write audit log entry (global + session). crate::audit::log_decision( &settings.audit, - &input.session_id, - &input.tool_name, - &input.tool_input, + session_id, + tool_name, + tool_input, decision.effect, decision.reason.as_deref(), &decision.trace, @@ -95,7 +122,7 @@ pub fn check_permission( // Print a concise denial message to stderr so the user sees it in the terminal. if decision.effect == Effect::Deny { - let verb_str = tool_to_verb_str(&input.tool_name); + let verb_str = tool_to_verb_str(tool_name); let noun_summary = truncate_noun(&noun, 60); eprintln!( @@ -132,34 +159,36 @@ pub fn check_permission( Ok(match decision.effect { Effect::Allow => { - let mut output = - HookOutput::allow(decision.reason.or(Some("policy: allowed".into())), None); // If the policy decision includes a per-command sandbox, rewrite the // command to run through `clash shell` (brush) which handles // per-command sandbox enforcement internally. - if decision.sandbox.is_some() - && let Some(updated) = wrap_bash_with_sandbox(input) - { - output.set_updated_input(updated); - info!("Rewrote Bash command to run under sandbox"); + let updated_input = if decision.sandbox.is_some() { + let updated = wrap_bash_with_sandbox(tool_name, tool_input, cwd); + if updated.is_some() { + info!("Rewrote Bash command to run under sandbox"); + } + updated + } else { + None + }; + PolicyDecision::Allow { + reason: decision.reason.unwrap_or_else(|| "policy: allowed".into()), + context: None, + updated_input, } - output } Effect::Deny => { - let deny_context = build_deny_context( - &input.tool_name, - decision.reason.as_deref(), - &input.tool_input, - ); - HookOutput::deny( - decision.reason.unwrap_or_else(|| "policy: denied".into()), - Some(deny_context), - ) + let deny_context = + build_deny_context(tool_name, decision.reason.as_deref(), tool_input); + PolicyDecision::Deny { + reason: decision.reason.unwrap_or_else(|| "policy: denied".into()), + context: Some(deny_context), + } } - Effect::Ask => HookOutput::ask( - decision.reason.or(Some("policy: ask".into())), - additional_context, - ), + Effect::Ask => PolicyDecision::Ask { + reason: decision.reason.unwrap_or_else(|| "policy: ask".into()), + context: additional_context, + }, }) } @@ -186,13 +215,17 @@ fn tool_to_verb_str(tool_name: &str) -> String { /// rewrite the command to run through `clash sandbox exec`. /// /// Returns the updated `tool_input` JSON if rewriting is applicable, or None. -#[instrument(level = Level::TRACE, skip(input))] -fn wrap_bash_with_sandbox(input: &ToolUseHookInput) -> Option { - let bash_input = match input.typed_tool_input() { - ToolInput::Bash(b) => b, - _ => return None, - }; +#[instrument(level = Level::TRACE, skip(tool_name, tool_input, cwd))] +fn wrap_bash_with_sandbox( + tool_name: &str, + tool_input: &serde_json::Value, + cwd: &str, +) -> Option { + if tool_name != "Bash" { + return None; + } + let command = tool_input.get("command")?.as_str()?; let clash_bin = std::env::current_exe().ok()?; // Run the command through `clash shell` (brush) which handles per-command @@ -202,11 +235,11 @@ fn wrap_bash_with_sandbox(input: &ToolUseHookInput) -> Option let sandboxed_command = format!( "{} shell --cwd {} -c {}", shell_escape(&clash_bin.to_string_lossy()), - shell_escape(&input.cwd), - shell_escape(&bash_input.command), + shell_escape(cwd), + shell_escape(command), ); - let mut updated = input.tool_input.clone(); + let mut updated = tool_input.clone(); if let Some(obj) = updated.as_object_mut() { obj.insert( "command".into(), @@ -342,12 +375,11 @@ pub fn extract_noun(tool_name: &str, tool_input: &serde_json::Value) -> String { mod tests { use super::*; use crate::assert_decision; - use crate::hooks::ToolUseHookInput; use crate::test_utils::{TestPolicy, bash_command, get_context, pre_tool_use, read_file}; use anyhow::Result; use serde_json::json; - fn bash_input(command: &str) -> ToolUseHookInput { + fn bash_input(command: &str) -> clash_hooks::event::PreToolUse { pre_tool_use("Bash", bash_command(command)) } @@ -426,14 +458,14 @@ mod tests { fn test_explanation_contains_matched_rule() -> Result<()> { let settings = TestPolicy::deny_all().allow_exec("git").build(); let input = pre_tool_use("Bash", bash_command("git status")); - let _result = check_permission(&input, &settings)?; + let _result = check_permission(&input, None, &settings)?; Ok(()) } #[test] fn test_explanation_no_rules_matched() -> Result<()> { let settings = TestPolicy::ask_all().allow_exec("git").build(); - let result = check_permission(&bash_input("ls"), &settings)?; + let result = check_permission(&bash_input("ls"), None, &settings)?; let ctx = get_context(&result).expect("should have additional_context"); assert!( ctx.contains("No rules matched"), @@ -513,15 +545,6 @@ mod tests { // --- wrap_bash_with_sandbox tests --- - fn bash_input_for_sandbox(command: &str, cwd: &str) -> ToolUseHookInput { - ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: json!({"command": command}), - cwd: cwd.into(), - ..Default::default() - } - } - fn extract_wrapped_command(result: &serde_json::Value) -> &str { result .get("command") @@ -531,8 +554,8 @@ mod tests { #[test] fn test_wrap_bash_basic_command() { - let input = bash_input_for_sandbox("ls -la", "/home/user/project"); - let result = wrap_bash_with_sandbox(&input); + let tool_input = json!({"command": "ls -la"}); + let result = wrap_bash_with_sandbox("Bash", &tool_input, "/home/user/project"); assert!(result.is_some()); let wrapped = result.unwrap(); let cmd = extract_wrapped_command(&wrapped); @@ -549,12 +572,8 @@ mod tests { #[test] fn test_wrap_bash_returns_none_for_read_tool() { - let input = ToolUseHookInput { - tool_name: "Read".into(), - tool_input: json!({"file_path": "/tmp/test.txt"}), - ..Default::default() - }; - let result = wrap_bash_with_sandbox(&input); + let tool_input = json!({"file_path": "/tmp/test.txt"}); + let result = wrap_bash_with_sandbox("Read", &tool_input, "/tmp"); assert!(result.is_none()); } diff --git a/clash/src/policy/format.rs b/clash/src/policy/format.rs index 7be833a..cbd5733 100644 --- a/clash/src/policy/format.rs +++ b/clash/src/policy/format.rs @@ -1,10 +1,12 @@ //! Human-readable formatting for policy IR types. //! -//! Provides display rendering for [`CompiledPolicy`], [`Node`], [`Decision`], +//! Provides display rendering for [`CompiledPolicy`], [`Node`], [`MatchVerdict`], //! [`Observable`], and [`Pattern`] — kept separate from the IR definitions so //! `match_tree.rs` stays focused on types and evaluation. -use crate::policy::match_tree::{CompiledPolicy, Decision, Node, Observable, Pattern, SandboxRef}; +use crate::policy::match_tree::{ + CompiledPolicy, MatchVerdict, Node, Observable, Pattern, SandboxRef, +}; // --------------------------------------------------------------------------- // Public entry points @@ -154,15 +156,15 @@ fn format_node_flat(node: &Node, path: &mut Vec, lines: &mut Vec // Primitive renderers (pub for use in tui and other display code) // --------------------------------------------------------------------------- -/// Render a [`Decision`] leaf as a short string (e.g. `"allow"`, `"deny"`, +/// Render a [`MatchVerdict`] leaf as a short string (e.g. `"allow"`, `"deny"`, /// `"allow [sandbox: dev]"`). -pub fn format_decision(d: &Decision) -> String { +pub fn format_decision(d: &MatchVerdict) -> String { match d { - Decision::Allow(Some(SandboxRef(name))) => format!("allow [sandbox: {name}]"), - Decision::Allow(None) => "allow".to_string(), - Decision::Deny => "deny".to_string(), - Decision::Ask(Some(SandboxRef(name))) => format!("ask [sandbox: {name}]"), - Decision::Ask(None) => "ask".to_string(), + MatchVerdict::Allow(Some(SandboxRef(name))) => format!("allow [sandbox: {name}]"), + MatchVerdict::Allow(None) => "allow".to_string(), + MatchVerdict::Deny => "deny".to_string(), + MatchVerdict::Ask(Some(SandboxRef(name))) => format!("ask [sandbox: {name}]"), + MatchVerdict::Ask(None) => "ask".to_string(), } } diff --git a/clash/src/policy/ir.rs b/clash/src/policy/ir.rs index e36c207..2726f38 100644 --- a/clash/src/policy/ir.rs +++ b/clash/src/policy/ir.rs @@ -9,7 +9,7 @@ use crate::policy::sandbox_types::SandboxPolicy; /// The result of evaluating a policy. #[derive(Debug, Clone)] -pub struct PolicyDecision { +pub struct PolicyEvaluation { pub effect: Effect, pub reason: Option, /// Structured trace of how this decision was reached. @@ -19,7 +19,7 @@ pub struct PolicyDecision { pub sandbox_name: Option, } -impl PolicyDecision { +impl PolicyEvaluation { /// Render the decision trace as a list of human-readable strings. pub fn explanation(&self) -> Vec { self.trace.render() diff --git a/clash/src/policy/manifest_edit.rs b/clash/src/policy/manifest_edit.rs index 7916f93..850084f 100644 --- a/clash/src/policy/manifest_edit.rs +++ b/clash/src/policy/manifest_edit.rs @@ -3,7 +3,7 @@ //! Provides `upsert_rule` (add-or-replace) and `remove_rule` for CLI-driven //! policy mutation. After every mutation the tree is compacted. -use crate::policy::match_tree::{Decision, Node, Observable, Pattern, PolicyManifest}; +use crate::policy::match_tree::{MatchVerdict, Node, Observable, Pattern, PolicyManifest}; /// Result of an upsert operation. #[derive(Debug, PartialEq, Eq)] @@ -121,7 +121,7 @@ fn children_are_all_decisions(children: &[Node]) -> bool { } /// Extract the leaf decision from a node tree (DFS to the first Decision). -fn leaf_decision(node: &Node) -> Option<&Decision> { +fn leaf_decision(node: &Node) -> Option<&MatchVerdict> { match node { Node::Decision(d) => Some(d), Node::Condition { children, .. } => children.iter().find_map(leaf_decision), @@ -129,7 +129,7 @@ fn leaf_decision(node: &Node) -> Option<&Decision> { } /// Replace the leaf decision(s) in a node tree. -fn replace_leaf_decision(node: &mut Node, new_decision: Option<&Decision>) { +fn replace_leaf_decision(node: &mut Node, new_decision: Option<&MatchVerdict>) { let Some(new_decision) = new_decision else { return; }; @@ -147,7 +147,7 @@ fn replace_leaf_decision(node: &mut Node, new_decision: Option<&Decision>) { /// /// Constructs a `Node` tree representing a match chain, e.g.: /// `tool_name=Bash → positional_arg(0)=gh → positional_arg(1)=pr → positional_arg(2)=create → decision` -pub fn build_exec_rule(bin: &str, args: &[&str], decision: Decision) -> Node { +pub fn build_exec_rule(bin: &str, args: &[&str], decision: MatchVerdict) -> Node { // Build the chain from the leaf (decision) upward. let mut current = Node::Decision(decision); @@ -187,7 +187,7 @@ pub fn build_exec_rule(bin: &str, args: &[&str], decision: Decision) -> Node { } /// Build a tool-name rule (e.g. allow/deny a specific tool like "Read", "Write"). -pub fn build_tool_rule(tool_name: &str, decision: Decision) -> Node { +pub fn build_tool_rule(tool_name: &str, decision: MatchVerdict) -> Node { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(crate::policy::match_tree::Value::Literal(tool_name.into())), @@ -219,7 +219,7 @@ mod tests { #[test] fn upsert_inserts_new_rule() { let mut manifest = empty_manifest(); - let node = build_exec_rule("grep", &[], Decision::Allow(None)); + let node = build_exec_rule("grep", &[], MatchVerdict::Allow(None)); let result = upsert_rule(&mut manifest, node); assert_eq!(result, UpsertResult::Inserted); assert_eq!(manifest.policy.tree.len(), 1); @@ -228,12 +228,12 @@ mod tests { #[test] fn upsert_replaces_same_chain() { let mut manifest = empty_manifest(); - let allow = build_exec_rule("grep", &[], Decision::Allow(None)); + let allow = build_exec_rule("grep", &[], MatchVerdict::Allow(None)); upsert_rule(&mut manifest, allow); assert_eq!(manifest.policy.tree.len(), 1); // Now deny the same chain — should replace, not add. - let deny = build_exec_rule("grep", &[], Decision::Deny); + let deny = build_exec_rule("grep", &[], MatchVerdict::Deny); let result = upsert_rule(&mut manifest, deny); assert_eq!(result, UpsertResult::Replaced); // After compact, still 1 root node. @@ -241,7 +241,7 @@ mod tests { // The leaf should now be deny. let leaf = leaf_decision(&manifest.policy.tree[0]); - assert!(matches!(leaf, Some(Decision::Deny))); + assert!(matches!(leaf, Some(MatchVerdict::Deny))); } #[test] @@ -249,9 +249,12 @@ mod tests { let mut manifest = empty_manifest(); upsert_rule( &mut manifest, - build_exec_rule("grep", &[], Decision::Allow(None)), + build_exec_rule("grep", &[], MatchVerdict::Allow(None)), + ); + upsert_rule( + &mut manifest, + build_exec_rule("rm", &[], MatchVerdict::Deny), ); - upsert_rule(&mut manifest, build_exec_rule("rm", &[], Decision::Deny)); // Both should exist (compacted under a shared Bash parent). let total_rules = count_leaf_decisions(&manifest.policy.tree); assert_eq!(total_rules, 2); @@ -262,9 +265,9 @@ mod tests { let mut manifest = empty_manifest(); upsert_rule( &mut manifest, - build_exec_rule("grep", &[], Decision::Allow(None)), + build_exec_rule("grep", &[], MatchVerdict::Allow(None)), ); - let target = build_exec_rule("grep", &[], Decision::Allow(None)); + let target = build_exec_rule("grep", &[], MatchVerdict::Allow(None)); assert!(remove_rule(&mut manifest, &target)); assert!(manifest.policy.tree.is_empty()); } @@ -274,32 +277,32 @@ mod tests { let mut manifest = empty_manifest(); upsert_rule( &mut manifest, - build_exec_rule("grep", &[], Decision::Allow(None)), + build_exec_rule("grep", &[], MatchVerdict::Allow(None)), ); - let target = build_exec_rule("rm", &[], Decision::Allow(None)); + let target = build_exec_rule("rm", &[], MatchVerdict::Allow(None)); assert!(!remove_rule(&mut manifest, &target)); } #[test] fn tool_rule_upsert() { let mut manifest = empty_manifest(); - let node = build_tool_rule("WebSearch", Decision::Deny); + let node = build_tool_rule("WebSearch", MatchVerdict::Deny); let result = upsert_rule(&mut manifest, node); assert_eq!(result, UpsertResult::Inserted); // Replace with allow. - let node2 = build_tool_rule("WebSearch", Decision::Allow(None)); + let node2 = build_tool_rule("WebSearch", MatchVerdict::Allow(None)); let result2 = upsert_rule(&mut manifest, node2); assert_eq!(result2, UpsertResult::Replaced); let leaf = leaf_decision(&manifest.policy.tree[0]); - assert!(matches!(leaf, Some(Decision::Allow(None)))); + assert!(matches!(leaf, Some(MatchVerdict::Allow(None)))); } #[test] fn exec_rule_with_args() { let mut manifest = empty_manifest(); - let node = build_exec_rule("gh", &["pr", "create"], Decision::Allow(None)); + let node = build_exec_rule("gh", &["pr", "create"], MatchVerdict::Allow(None)); upsert_rule(&mut manifest, node); // Should produce: Bash → gh → pr → create → allow @@ -307,11 +310,11 @@ mod tests { assert_eq!(total, 1); // Same chain with different decision replaces it. - let deny = build_exec_rule("gh", &["pr", "create"], Decision::Deny); + let deny = build_exec_rule("gh", &["pr", "create"], MatchVerdict::Deny); let result = upsert_rule(&mut manifest, deny); assert_eq!(result, UpsertResult::Replaced); let leaf = leaf_decision(&manifest.policy.tree[0]); - assert!(matches!(leaf, Some(Decision::Deny))); + assert!(matches!(leaf, Some(MatchVerdict::Deny))); } #[test] @@ -319,11 +322,11 @@ mod tests { let mut manifest = empty_manifest(); upsert_rule( &mut manifest, - build_exec_rule("gh", &["pr", "create"], Decision::Allow(None)), + build_exec_rule("gh", &["pr", "create"], MatchVerdict::Allow(None)), ); upsert_rule( &mut manifest, - build_exec_rule("gh", &["pr", "merge"], Decision::Deny), + build_exec_rule("gh", &["pr", "merge"], MatchVerdict::Deny), ); let total = count_leaf_decisions(&manifest.policy.tree); assert_eq!(total, 2); diff --git a/clash/src/policy/match_tree.rs b/clash/src/policy/match_tree.rs index 3b2962b..bbcbc0a 100644 --- a/clash/src/policy/match_tree.rs +++ b/clash/src/policy/match_tree.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use std::sync::Arc; use crate::policy::Effect; -use crate::policy::ir::{DecisionTrace, PolicyDecision, RuleMatch, RuleSkip}; +use crate::policy::ir::{DecisionTrace, PolicyEvaluation, RuleMatch, RuleSkip}; use crate::policy::sandbox_types::SandboxPolicy; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -203,13 +203,13 @@ impl Observable { pub struct SandboxRef(pub String); // --------------------------------------------------------------------------- -// Decision +// MatchVerdict // --------------------------------------------------------------------------- /// A leaf decision in the match tree. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum Decision { +pub enum MatchVerdict { /// Allow, optionally with a sandbox. Allow(Option), /// Deny. @@ -218,19 +218,19 @@ pub enum Decision { Ask(Option), } -impl Decision { +impl MatchVerdict { pub fn effect(&self) -> Effect { match self { - Decision::Allow(_) => Effect::Allow, - Decision::Deny => Effect::Deny, - Decision::Ask(_) => Effect::Ask, + MatchVerdict::Allow(_) => Effect::Allow, + MatchVerdict::Deny => Effect::Deny, + MatchVerdict::Ask(_) => Effect::Ask, } } pub fn sandbox_ref(&self) -> Option<&SandboxRef> { match self { - Decision::Allow(sb) | Decision::Ask(sb) => sb.as_ref(), - Decision::Deny => None, + MatchVerdict::Allow(sb) | MatchVerdict::Ask(sb) => sb.as_ref(), + MatchVerdict::Deny => None, } } } @@ -261,7 +261,7 @@ pub enum Node { terminal: bool, }, /// A leaf decision. - Decision(Decision), + Decision(MatchVerdict), } impl Node { @@ -622,7 +622,7 @@ pub struct TraceEntry { /// Evaluate the match tree against a query context. /// /// Returns the first decision found via DFS, or None if no branch matches. -pub fn eval(nodes: &[Node], ctx: &QueryContext) -> Option { +pub fn eval(nodes: &[Node], ctx: &QueryContext) -> Option { for node in nodes { match node { Node::Decision(d) => return Some(d.clone()), @@ -650,7 +650,7 @@ pub fn eval_traced( ctx: &QueryContext, trace: &mut EvalTrace, path: &mut Vec, -) -> Option { +) -> Option { for node in nodes { match node { Node::Decision(d) => return Some(d.clone()), @@ -799,7 +799,7 @@ fn matches_observable( impl CompiledPolicy { /// Evaluate this policy against a tool invocation. - pub fn evaluate(&self, tool_name: &str, tool_input: &serde_json::Value) -> PolicyDecision { + pub fn evaluate(&self, tool_name: &str, tool_input: &serde_json::Value) -> PolicyEvaluation { let ctx = QueryContext::from_tool(tool_name, tool_input); self.evaluate_ctx(&ctx) } @@ -810,7 +810,7 @@ impl CompiledPolicy { tool_name: &str, tool_input: &serde_json::Value, mode: Option<&str>, - ) -> PolicyDecision { + ) -> PolicyEvaluation { self.evaluate_with_context(tool_name, tool_input, mode, None) } @@ -821,7 +821,7 @@ impl CompiledPolicy { tool_input: &serde_json::Value, mode: Option<&str>, agent_name: Option<&str>, - ) -> PolicyDecision { + ) -> PolicyEvaluation { let mut ctx = QueryContext::from_tool(tool_name, tool_input); ctx.mode = mode.map(|m| m.to_string()); ctx.agent_name = agent_name.map(|a| a.to_string()); @@ -842,7 +842,7 @@ impl CompiledPolicy { } /// Evaluate this policy against a prepared query context. - pub fn evaluate_ctx(&self, ctx: &QueryContext) -> PolicyDecision { + pub fn evaluate_ctx(&self, ctx: &QueryContext) -> PolicyEvaluation { let mut trace = EvalTrace::default(); let mut path = Vec::new(); @@ -888,7 +888,7 @@ impl CompiledPolicy { None => format!("result: {effect}"), }; - PolicyDecision { + PolicyEvaluation { effect, reason: Some(resolution.clone()), trace: self.build_decision_trace(&trace, &resolution), @@ -899,7 +899,7 @@ impl CompiledPolicy { None => { let resolution = format!("no rules matched, default: {}", self.default_effect); - PolicyDecision { + PolicyEvaluation { effect: self.default_effect, reason: Some(resolution.clone()), trace: self.build_decision_trace(&trace, &resolution), @@ -1141,9 +1141,9 @@ mod tests { #[test] fn simple_decision() { - let nodes = vec![Node::Decision(Decision::Allow(None))]; + let nodes = vec![Node::Decision(MatchVerdict::Allow(None))]; let ctx = make_ctx("Bash", "echo hello"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Allow(None))); } #[test] @@ -1151,13 +1151,13 @@ mod tests { let nodes = vec![Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Bash".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, }]; let ctx = make_ctx("Bash", "echo hello"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Allow(None))); } #[test] @@ -1165,7 +1165,7 @@ mod tests { let nodes = vec![Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Read".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -1186,7 +1186,7 @@ mod tests { children: vec![Node::Condition { observe: Observable::PositionalArg(1), pattern: Pattern::Literal(Value::Literal("commit".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: true, @@ -1202,7 +1202,7 @@ mod tests { // Exact match: "git commit" → deny let ctx = make_ctx("Bash", "git commit"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Deny)); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Deny)); // Extra args: "git commit --amend" → no match (terminal blocks it) let ctx = make_ctx("Bash", "git commit --amend"); @@ -1219,14 +1219,14 @@ mod tests { let nodes = vec![Node::Condition { observe: Observable::PositionalArg(0), pattern: Pattern::Literal(Value::Literal("ls".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: true, }]; let ctx = make_ctx("Bash", "ls"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Allow(None))); let ctx = make_ctx("Bash", "ls -la"); assert_eq!(eval(&nodes, &ctx), None); @@ -1241,7 +1241,7 @@ mod tests { children: vec![Node::Condition { observe: Observable::PositionalArg(1), pattern: Pattern::Literal(Value::Literal("commit".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -1252,7 +1252,7 @@ mod tests { }]; let ctx = make_ctx("Bash", "git commit --amend"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Allow(None))); } #[test] @@ -1263,7 +1263,7 @@ mod tests { children: vec![Node::Condition { observe: Observable::PositionalArg(0), pattern: Pattern::Literal(Value::Literal("git".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -1273,7 +1273,7 @@ mod tests { terminal: false, }]; let ctx = make_ctx("Bash", "git push"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Allow(None))); } #[test] @@ -1284,7 +1284,7 @@ mod tests { children: vec![Node::Condition { observe: Observable::HasArg, pattern: Pattern::Literal(Value::Literal("--force".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, @@ -1294,7 +1294,7 @@ mod tests { terminal: false, }]; let ctx = make_ctx("Bash", "git push --force origin main"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Deny)); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Deny)); } #[test] @@ -1305,7 +1305,7 @@ mod tests { children: vec![Node::Condition { observe: Observable::HasArg, pattern: Pattern::Literal(Value::Literal("--force".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, @@ -1325,7 +1325,7 @@ mod tests { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Wildcard, - children: vec![Node::Decision(Decision::Ask(None))], + children: vec![Node::Decision(MatchVerdict::Ask(None))], doc: None, source: None, terminal: false, @@ -1333,7 +1333,7 @@ mod tests { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Bash".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -1342,7 +1342,7 @@ mod tests { let nodes = Node::compact(nodes); // Literal should be first after sorting let ctx = make_ctx("Bash", "echo hello"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Allow(None))); } #[test] @@ -1356,7 +1356,7 @@ mod tests { children: vec![Node::Condition { observe: Observable::PositionalArg(0), pattern: Pattern::Literal(Value::Literal("cargo".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -1368,7 +1368,7 @@ mod tests { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Wildcard, - children: vec![Node::Decision(Decision::Ask(None))], + children: vec![Node::Decision(MatchVerdict::Ask(None))], doc: None, source: None, terminal: false, @@ -1376,7 +1376,7 @@ mod tests { ]; // "git push" matches Bash but not cargo, so should backtrack to wildcard let ctx = make_ctx("Bash", "git push"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Ask(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Ask(None))); } #[test] @@ -1384,14 +1384,14 @@ mod tests { let nodes = vec![Node::Condition { observe: Observable::NestedField(vec!["file_path".into()]), pattern: Pattern::Regex(Arc::new(Regex::new(r".*\.rs$").unwrap())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, }]; let input = serde_json::json!({"file_path": "/src/main.rs"}); let ctx = QueryContext::from_tool("Edit", &input); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Allow(None))); } #[test] @@ -1399,13 +1399,13 @@ mod tests { let nodes = vec![Node::Condition { observe: Observable::PositionalArg(0), pattern: Pattern::Regex(Arc::new(Regex::new(r"^cargo").unwrap())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, }]; let ctx = make_ctx("Bash", "cargo-clippy check"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Allow(None))); } #[test] @@ -1416,14 +1416,14 @@ mod tests { Pattern::Literal(Value::Literal("cargo".into())), Pattern::Literal(Value::Literal("rustc".into())), ]), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, }]; let ctx = make_ctx("Bash", "rustc main.rs"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Allow(None))); let ctx = make_ctx("Bash", "gcc main.c"); assert_eq!(eval(&nodes, &ctx), None); @@ -1434,14 +1434,14 @@ mod tests { let nodes = vec![Node::Condition { observe: Observable::PositionalArg(0), pattern: Pattern::Not(Box::new(Pattern::Literal(Value::Literal("rm".into())))), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, }]; let ctx = make_ctx("Bash", "ls -la"); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None))); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Allow(None))); let ctx = make_ctx("Bash", "rm -rf /"); assert_eq!(eval(&nodes, &ctx), None); @@ -1451,7 +1451,7 @@ mod tests { fn sandbox_ref_validation() { let policy = CompiledPolicy { sandboxes: HashMap::new(), - tree: vec![Node::Decision(Decision::Allow(Some(SandboxRef( + tree: vec![Node::Decision(MatchVerdict::Allow(Some(SandboxRef( "missing".into(), ))))], default_effect: Effect::Deny, @@ -1476,7 +1476,7 @@ mod tests { ); let policy = CompiledPolicy { sandboxes, - tree: vec![Node::Decision(Decision::Allow(Some(SandboxRef( + tree: vec![Node::Decision(MatchVerdict::Allow(Some(SandboxRef( "cwd_access".into(), ))))], default_effect: Effect::Deny, @@ -1500,12 +1500,12 @@ mod tests { Node::Condition { observe: Observable::HasArg, pattern: Pattern::Literal(Value::Literal("--force".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, }, - Node::Decision(Decision::Allow(None)), + Node::Decision(MatchVerdict::Allow(None)), ], doc: None, source: None, @@ -1518,7 +1518,7 @@ mod tests { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Wildcard, - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -1547,7 +1547,7 @@ mod tests { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Wildcard, - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -1555,7 +1555,7 @@ mod tests { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Bash".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, @@ -1590,7 +1590,7 @@ mod tests { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Read".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -1598,7 +1598,7 @@ mod tests { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Bash".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, @@ -1610,7 +1610,7 @@ mod tests { let mut path = Vec::new(); let result = eval_traced(&nodes, &ctx, &mut trace, &mut path); - assert_eq!(result, Some(Decision::Deny)); + assert_eq!(result, Some(MatchVerdict::Deny)); assert_eq!(trace.skipped.len(), 1); // Read was skipped assert_eq!(trace.matched.len(), 1); // Bash matched } @@ -1633,14 +1633,14 @@ mod tests { let nodes = vec![Node::Condition { observe: Observable::NamedArg("file_path".into()), pattern: Pattern::Regex(Arc::new(Regex::new(r"\.env").unwrap())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, }]; let input = serde_json::json!({"file_path": "/project/.env"}); let ctx = QueryContext::from_tool("Write", &input); - assert_eq!(eval(&nodes, &ctx), Some(Decision::Deny)); + assert_eq!(eval(&nodes, &ctx), Some(MatchVerdict::Deny)); } #[test] @@ -1654,7 +1654,7 @@ mod tests { children: vec![Node::Condition { observe: Observable::PositionalArg(0), pattern: Pattern::Literal(Value::Literal("cargo".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -1699,7 +1699,7 @@ mod tests { tree: vec![Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Bash".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, diff --git a/clash/src/policy/mod.rs b/clash/src/policy/mod.rs index b8125e8..849179a 100644 --- a/clash/src/policy/mod.rs +++ b/clash/src/policy/mod.rs @@ -19,7 +19,7 @@ mod proptests; pub use compile::compile_multi_level_to_tree; pub use error::{CompileError, PolicyError, PolicyParseError}; -pub use ir::{DecisionTrace, PolicyDecision, RuleMatch, RuleSkip}; +pub use ir::{DecisionTrace, PolicyEvaluation, RuleMatch, RuleSkip}; pub use match_tree::{CompiledPolicy, IncludeEntry, PolicyManifest}; use std::fmt; diff --git a/clash/src/policy/proptests.rs b/clash/src/policy/proptests.rs index bf38e62..6d731e8 100644 --- a/clash/src/policy/proptests.rs +++ b/clash/src/policy/proptests.rs @@ -8,7 +8,7 @@ use regex::Regex; use super::Effect; use super::match_tree::{ - CompiledPolicy, Decision, Node, Observable, Pattern, QueryContext, Value, eval, + CompiledPolicy, MatchVerdict, Node, Observable, Pattern, QueryContext, Value, eval, }; // --------------------------------------------------------------------------- @@ -45,12 +45,12 @@ fn arb_effect() -> impl Strategy { prop_oneof![Just(Effect::Allow), Just(Effect::Deny), Just(Effect::Ask),] } -/// Generate a Decision from an Effect. -fn arb_decision() -> impl Strategy { +/// Generate a MatchVerdict from an Effect. +fn arb_decision() -> impl Strategy { arb_effect().prop_map(|e| match e { - Effect::Allow => Decision::Allow(None), - Effect::Deny => Decision::Deny, - Effect::Ask => Decision::Ask(None), + Effect::Allow => MatchVerdict::Allow(None), + Effect::Deny => MatchVerdict::Deny, + Effect::Ask => MatchVerdict::Ask(None), }) } @@ -128,7 +128,7 @@ proptest! { tree: vec![Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("__nonexistent_tool__".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, @@ -302,7 +302,7 @@ proptest! { let nodes = vec![Node::Condition { observe: Observable::ToolName, pattern: Pattern::Not(Box::new(Pattern::Wildcard)), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, diff --git a/clash/src/policy/test_eval.rs b/clash/src/policy/test_eval.rs index a9d76e7..f8b9cc9 100644 --- a/clash/src/policy/test_eval.rs +++ b/clash/src/policy/test_eval.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result}; use crate::policy::Effect; -use crate::policy::ir::PolicyDecision; +use crate::policy::ir::PolicyEvaluation; use crate::policy::match_tree::CompiledPolicy; /// The result of testing a tool invocation against a policy. @@ -17,7 +17,7 @@ pub struct TestResult { /// The resolved tool input JSON. pub tool_input: serde_json::Value, /// The policy decision. - pub decision: PolicyDecision, + pub decision: PolicyEvaluation, } impl TestResult { diff --git a/clash/src/policy_decision.rs b/clash/src/policy_decision.rs new file mode 100644 index 0000000..73b9630 --- /dev/null +++ b/clash/src/policy_decision.rs @@ -0,0 +1,134 @@ +//! The policy engine's output type — a decision about whether a tool call +//! should be allowed, denied, or require user confirmation. +//! +//! This is the boundary type between the policy engine ([`crate::permissions`]) +//! and the protocol output layer. It replaces the old `HookOutput`-based return +//! from `check_permission`. + +use crate::policy::Effect; + +/// A policy decision about a tool invocation. +#[derive(Debug, Clone)] +pub enum PolicyDecision { + /// Tool call is allowed. Optionally rewrites the tool input (e.g. for sandbox wrapping). + Allow { + reason: String, + context: Option, + updated_input: Option, + }, + /// Tool call is denied. The agent receives the reason and context. + Deny { + reason: String, + context: Option, + }, + /// Tool call requires user confirmation. Falls through to the agent's native permission UI. + Ask { + reason: String, + context: Option, + }, + /// Pass through — no policy opinion. Let the agent's native permission system decide. + Pass, +} + +impl PolicyDecision { + /// Returns `true` if this is a `Deny` decision. + pub fn is_deny(&self) -> bool { + matches!(self, PolicyDecision::Deny { .. }) + } + + /// Returns `true` if this is an `Ask` decision. + pub fn is_ask(&self) -> bool { + matches!(self, PolicyDecision::Ask { .. }) + } + + /// Returns `true` if this is an `Allow` decision. + pub fn is_allow(&self) -> bool { + matches!(self, PolicyDecision::Allow { .. }) + } + + /// Map to the policy [`Effect`], or `None` for `Pass`. + pub fn effect(&self) -> Option { + match self { + PolicyDecision::Allow { .. } => Some(Effect::Allow), + PolicyDecision::Deny { .. } => Some(Effect::Deny), + PolicyDecision::Ask { .. } => Some(Effect::Ask), + PolicyDecision::Pass => None, + } + } + + /// Extract the reason string, if present. + pub fn reason(&self) -> Option<&str> { + match self { + PolicyDecision::Allow { reason, .. } + | PolicyDecision::Deny { reason, .. } + | PolicyDecision::Ask { reason, .. } => Some(reason), + PolicyDecision::Pass => None, + } + } + + /// Extract the additional context, if present. + pub fn context(&self) -> Option<&str> { + match self { + PolicyDecision::Allow { context, .. } + | PolicyDecision::Deny { context, .. } + | PolicyDecision::Ask { context, .. } => context.as_deref(), + PolicyDecision::Pass => None, + } + } + + /// Extract the updated_input, if this is an Allow with rewritten input. + pub fn updated_input(&self) -> Option<&serde_json::Value> { + match self { + PolicyDecision::Allow { updated_input, .. } => updated_input.as_ref(), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_allow_effect() { + let d = PolicyDecision::Allow { + reason: "ok".into(), + context: None, + updated_input: None, + }; + assert_eq!(d.effect(), Some(Effect::Allow)); + assert!(d.is_allow()); + assert!(!d.is_deny()); + assert!(!d.is_ask()); + assert_eq!(d.reason(), Some("ok")); + } + + #[test] + fn test_deny_effect() { + let d = PolicyDecision::Deny { + reason: "nope".into(), + context: Some("ctx".into()), + }; + assert_eq!(d.effect(), Some(Effect::Deny)); + assert!(d.is_deny()); + assert_eq!(d.context(), Some("ctx")); + } + + #[test] + fn test_ask_effect() { + let d = PolicyDecision::Ask { + reason: "confirm".into(), + context: None, + }; + assert_eq!(d.effect(), Some(Effect::Ask)); + assert!(d.is_ask()); + } + + #[test] + fn test_pass_effect() { + let d = PolicyDecision::Pass; + assert_eq!(d.effect(), None); + assert_eq!(d.reason(), None); + assert_eq!(d.context(), None); + } +} diff --git a/clash/src/sandbox_hints/audit_source.rs b/clash/src/sandbox_hints/audit_source.rs index 4960d9b..e20707d 100644 --- a/clash/src/sandbox_hints/audit_source.rs +++ b/clash/src/sandbox_hints/audit_source.rs @@ -9,8 +9,9 @@ use std::collections::BTreeSet; use tracing::info; +use clash_hooks::ToolEvent; + use crate::audit; -use crate::hooks::ToolUseHookInput; use crate::policy::sandbox_types::SandboxPolicy; use super::formatter::BlockedPath; @@ -23,15 +24,15 @@ use super::{ /// `clash sandbox exec` captures violations from the macOS unified log after /// the sandboxed process exits and writes them to the session `audit.jsonl`. /// This function reads them back by `tool_use_id`. -pub(crate) fn read_audit_violations(input: &ToolUseHookInput) -> Vec { - if input.session_id.is_empty() { +pub(crate) fn read_audit_violations(input: &impl ToolEvent) -> Vec { + if input.session_id().is_empty() { return Vec::new(); } - let tool_use_id = match input.tool_use_id.as_deref() { + let tool_use_id = match input.tool_use_id() { Some(id) => id, None => return Vec::new(), }; - audit::read_sandbox_violations(&input.session_id, tool_use_id) + audit::read_sandbox_violations(input.session_id(), tool_use_id) } /// Convert audit-derived violations into `BlockedPath` entries. diff --git a/clash/src/sandbox_hints/mod.rs b/clash/src/sandbox_hints/mod.rs index 75ff95f..512c334 100644 --- a/clash/src/sandbox_hints/mod.rs +++ b/clash/src/sandbox_hints/mod.rs @@ -24,7 +24,8 @@ use std::path::Path; use tracing::{Level, info, instrument}; -use crate::hooks::ToolUseHookInput; +use clash_hooks::{HookEventCommon, ToolEvent}; + use crate::network_hints::extract_response_text; use crate::policy::sandbox_types::{Cap, SandboxPolicy}; use crate::settings::ClashSettings; @@ -63,11 +64,11 @@ const NOISE_PATH_PREFIXES: &[&str] = &["/dev/dtrace", "/dev/dtracehelper", "/dev /// if `log show` didn't capture anything). #[instrument(level = Level::TRACE, skip(input, settings))] pub fn check_for_sandbox_fs_hint( - input: &ToolUseHookInput, + input: &clash_hooks::event::PostToolUse, settings: &ClashSettings, ) -> Option { // Only check Bash tool responses - if input.tool_name != "Bash" { + if input.tool_name() != "Bash" { return None; } @@ -91,7 +92,7 @@ pub fn check_for_sandbox_fs_hint( // Source 2: Parse stderr for filesystem error patterns (fallback). // Note: don't use `?` on tool_response — we still want to proceed if we // have audit violations even when the response is missing. - let response_text = input.tool_response.as_ref().and_then(extract_response_text); + let response_text = input.tool_response().and_then(extract_response_text); let stderr_has_errors = response_text.as_ref().is_some_and(|t| contains_fs_error(t)); info!( @@ -112,7 +113,7 @@ pub fn check_for_sandbox_fs_hint( // Audit-derived paths are high-confidence (kernel-verified denials). if has_audit { - blocked_paths.extend(paths_from_audit(&audit_violations, &sandbox, &input.cwd)); + blocked_paths.extend(paths_from_audit(&audit_violations, &sandbox, input.cwd())); } // Stderr heuristic paths fill in gaps (e.g., paths the audit missed, @@ -124,7 +125,7 @@ pub fn check_for_sandbox_fs_hint( paths = ?extracted_paths, "check_for_sandbox_fs_hint: paths extracted from stderr" ); - let stderr_blocked = extract_blocked_paths(text, &sandbox, &input.cwd); + let stderr_blocked = extract_blocked_paths(text, &sandbox, input.cwd()); info!( stderr_blocked_count = stderr_blocked.len(), "check_for_sandbox_fs_hint: stderr paths confirmed as sandbox violations" @@ -164,12 +165,12 @@ pub fn check_for_sandbox_fs_hint( /// 2. Fall back to extracting the `--sandbox` JSON from the rewritten command /// string (works when PostToolUse gets the `clash sandbox exec ...` wrapper). fn resolve_sandbox_policy( - input: &ToolUseHookInput, + input: &impl ToolEvent, settings: &ClashSettings, ) -> Option { // Path 1: re-evaluate against the decision tree. if let Some(tree) = settings.policy_tree() { - let decision = tree.evaluate(&input.tool_name, &input.tool_input); + let decision = tree.evaluate(input.tool_name(), input.tool_input_raw()); if let Some(sandbox) = decision.sandbox { info!("resolve_sandbox_policy: found sandbox via decision tree re-evaluation"); return Some(sandbox); @@ -180,7 +181,7 @@ fn resolve_sandbox_policy( } // Path 2: extract --sandbox JSON from the rewritten command string. - let command = input.tool_input.get("command")?.as_str()?; + let command = input.tool_input_raw().get("command")?.as_str()?; if !command.contains("sandbox exec") || !command.contains("--sandbox") { info!( command_prefix = &command[..command.len().min(80)], @@ -291,6 +292,48 @@ mod tests { contains_fs_error, extract_paths_from_errors, is_likely_sandbox_violation, }; + fn post_tool_use_event( + tool_name: &str, + tool_input: serde_json::Value, + tool_response: serde_json::Value, + ) -> clash_hooks::event::PostToolUse { + let json = json!({ + "session_id": "test", + "transcript_path": "/tmp/t.jsonl", + "cwd": "/tmp", + "permission_mode": "default", + "hook_event_name": "PostToolUse", + "tool_name": tool_name, + "tool_input": tool_input, + "tool_use_id": "toolu_test", + "tool_response": tool_response, + }); + match clash_hooks::recv_from_value(json).unwrap() { + clash_hooks::HookEvent::PostToolUse(e) => e, + _ => panic!("expected PostToolUse"), + } + } + + fn post_tool_use_event_no_response( + tool_name: &str, + tool_input: serde_json::Value, + ) -> clash_hooks::event::PostToolUse { + let json = json!({ + "session_id": "test", + "transcript_path": "/tmp/t.jsonl", + "cwd": "/tmp", + "permission_mode": "default", + "hook_event_name": "PostToolUse", + "tool_name": tool_name, + "tool_input": tool_input, + "tool_use_id": "toolu_test", + }); + match clash_hooks::recv_from_value(json).unwrap() { + clash_hooks::HookEvent::PostToolUse(e) => e, + _ => panic!("expected PostToolUse"), + } + } + // --- contains_fs_error --- #[test] @@ -647,33 +690,21 @@ mod tests { #[test] fn test_check_returns_none_for_non_bash() { - let input = ToolUseHookInput { - tool_name: "Read".into(), - tool_response: Some(json!("operation not permitted")), - ..Default::default() - }; + let input = post_tool_use_event("Read", json!({}), json!("operation not permitted")); let settings = ClashSettings::default(); assert!(check_for_sandbox_fs_hint(&input, &settings).is_none()); } #[test] fn test_check_returns_none_without_response() { - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_response: None, - ..Default::default() - }; + let input = post_tool_use_event_no_response("Bash", json!({"command": "ls"})); let settings = ClashSettings::default(); assert!(check_for_sandbox_fs_hint(&input, &settings).is_none()); } #[test] fn test_check_returns_none_for_non_fs_error() { - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_response: Some(json!("file not found")), - ..Default::default() - }; + let input = post_tool_use_event("Bash", json!({"command": "ls"}), json!("file not found")); let settings = ClashSettings::default(); assert!(check_for_sandbox_fs_hint(&input, &settings).is_none()); } @@ -682,14 +713,11 @@ mod tests { fn test_check_returns_none_without_policy() { let settings = ClashSettings::default(); assert!(settings.decision_tree().is_none()); - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: json!({"command": "fly logs"}), - tool_response: Some(json!( - "open /Users/user/.fly/perms.123: operation not permitted" - )), - ..Default::default() - }; + let input = post_tool_use_event( + "Bash", + json!({"command": "fly logs"}), + json!("open /Users/user/.fly/perms.123: operation not permitted"), + ); assert!(check_for_sandbox_fs_hint(&input, &settings).is_none()); } @@ -706,15 +734,13 @@ mod tests { ]}} ]}"#, ); - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: json!({"command": "fly logs --app scour-rs"}), - tool_response: Some(json!( + let input = post_tool_use_event( + "Bash", + json!({"command": "fly logs --app scour-rs"}), + json!( "Error: failed ensuring config directory perms: open /Users/emschwartz/.fly/perms.3199984107: operation not permitted" - )), - cwd: "/tmp".into(), - ..Default::default() - }; + ), + ); let result = check_for_sandbox_fs_hint(&input, &settings); assert!( result.is_some(), @@ -738,15 +764,11 @@ mod tests { ]}} ]}"#, ); - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: json!({"command": "fly logs"}), - tool_response: Some(json!( - "open /Users/emschwartz/.fly/perms.123: operation not permitted" - )), - cwd: "/tmp".into(), - ..Default::default() - }; + let input = post_tool_use_event( + "Bash", + json!({"command": "fly logs"}), + json!("open /Users/emschwartz/.fly/perms.123: operation not permitted"), + ); let result = check_for_sandbox_fs_hint(&input, &settings); assert!( result.is_none(), @@ -769,15 +791,11 @@ mod tests { ]}} ]}"#, ); - let input = ToolUseHookInput { - tool_name: "Bash".into(), - tool_input: json!({"command": "fly logs --app scour-rs"}), - tool_response: Some(json!( - "open /Users/user/.fly/perms.123: operation not permitted" - )), - cwd: "/project".into(), - ..Default::default() - }; + let input = post_tool_use_event( + "Bash", + json!({"command": "fly logs --app scour-rs"}), + json!("open /Users/user/.fly/perms.123: operation not permitted"), + ); let result = check_for_sandbox_fs_hint(&input, &settings); assert!( result.is_some(), diff --git a/clash/src/test_utils.rs b/clash/src/test_utils.rs index 8c9b474..95b0417 100644 --- a/clash/src/test_utils.rs +++ b/clash/src/test_utils.rs @@ -3,8 +3,8 @@ //! Provides reusable builders for policies, hook events, and tool inputs //! so that test files do not need to reinvent boilerplate. -use crate::hooks::{HookOutput, HookSpecificOutput, ToolUseHookInput}; use crate::policy::Effect; +use crate::policy_decision::PolicyDecision; use crate::settings::ClashSettings; // --------------------------------------------------------------------------- @@ -45,33 +45,47 @@ pub fn grep_pattern(pattern: &str) -> serde_json::Value { // Hook event builders // --------------------------------------------------------------------------- -/// Build a [`ToolUseHookInput`] for a PreToolUse event. -pub fn pre_tool_use(tool_name: &str, tool_input: serde_json::Value) -> ToolUseHookInput { - ToolUseHookInput { - session_id: "test-session".into(), - transcript_path: "/tmp/transcript.jsonl".into(), - cwd: "/tmp".into(), - permission_mode: "default".into(), - hook_event_name: "PreToolUse".into(), - tool_name: tool_name.into(), - tool_input, - tool_use_id: Some("toolu_test".into()), - tool_response: None, - agent: None, - original_tool_name: None, +/// Build a [`clash_hooks::event::PreToolUse`] event for testing. +pub fn pre_tool_use( + tool_name: &str, + tool_input: serde_json::Value, +) -> clash_hooks::event::PreToolUse { + let json = serde_json::json!({ + "session_id": "test-session", + "transcript_path": "/tmp/transcript.jsonl", + "cwd": "/tmp", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": tool_name, + "tool_input": tool_input, + "tool_use_id": "toolu_test", + }); + match clash_hooks::recv_from_value(json).unwrap() { + clash_hooks::HookEvent::PreToolUse(e) => e, + _ => panic!("expected PreToolUse"), } } -/// Build a [`ToolUseHookInput`] for a PostToolUse event. +/// Build a [`clash_hooks::event::PostToolUse`] event for testing. pub fn post_tool_use( tool_name: &str, tool_input: serde_json::Value, tool_response: serde_json::Value, -) -> ToolUseHookInput { - ToolUseHookInput { - hook_event_name: "PostToolUse".into(), - tool_response: Some(tool_response), - ..pre_tool_use(tool_name, tool_input) +) -> clash_hooks::event::PostToolUse { + let json = serde_json::json!({ + "session_id": "test-session", + "transcript_path": "/tmp/transcript.jsonl", + "cwd": "/tmp", + "permission_mode": "default", + "hook_event_name": "PostToolUse", + "tool_name": tool_name, + "tool_input": tool_input, + "tool_use_id": "toolu_test", + "tool_response": tool_response, + }); + match clash_hooks::recv_from_value(json).unwrap() { + clash_hooks::HookEvent::PostToolUse(e) => e, + _ => panic!("expected PostToolUse"), } } @@ -238,37 +252,19 @@ impl TestEnvironment { // Decision extraction helpers // --------------------------------------------------------------------------- -/// Extract the permission decision [`Effect`] from a [`HookOutput`]. -pub fn get_effect(output: &HookOutput) -> Option { - match &output.hook_specific_output { - Some(HookSpecificOutput::PreToolUse(pre)) => { - pre.permission_decision - .as_ref() - .and_then(|rule| match rule { - claude_settings::PermissionRule::Allow => Some(Effect::Allow), - claude_settings::PermissionRule::Deny => Some(Effect::Deny), - claude_settings::PermissionRule::Ask => Some(Effect::Ask), - _ => None, - }) - } - _ => None, - } +/// Extract the permission decision [`Effect`] from a [`PolicyDecision`]. +pub fn get_effect(decision: &PolicyDecision) -> Option { + decision.effect() } -/// Extract the additional_context from a PreToolUse [`HookOutput`]. -pub fn get_context(output: &HookOutput) -> Option { - match &output.hook_specific_output { - Some(HookSpecificOutput::PreToolUse(pre)) => pre.additional_context.clone(), - _ => None, - } +/// Extract the additional_context from a [`PolicyDecision`]. +pub fn get_context(decision: &PolicyDecision) -> Option { + decision.context().map(String::from) } -/// Extract the permission_decision_reason from a PreToolUse [`HookOutput`]. -pub fn get_reason(output: &HookOutput) -> Option { - match &output.hook_specific_output { - Some(HookSpecificOutput::PreToolUse(pre)) => pre.permission_decision_reason.clone(), - _ => None, - } +/// Extract the reason from a [`PolicyDecision`]. +pub fn get_reason(decision: &PolicyDecision) -> Option { + decision.reason().map(String::from) } // --------------------------------------------------------------------------- @@ -293,8 +289,8 @@ pub fn get_reason(output: &HookOutput) -> Option { #[macro_export] macro_rules! assert_decision { ($settings:expr, $input:expr, $expected_effect:expr) => {{ - let result = - $crate::permissions::check_permission(&$input, &$settings).expect("check_permission"); + let result = $crate::permissions::check_permission(&$input, None, &$settings) + .expect("check_permission"); let effect = $crate::test_utils::get_effect(&result); assert_eq!( effect, @@ -332,8 +328,8 @@ mod tests { fn test_policy_builder_deny_all() { let settings = TestPolicy::deny_all().build(); let input = pre_tool_use("Bash", bash_command("ls")); - let result = - crate::permissions::check_permission(&input, &settings).expect("check_permission"); + let result = crate::permissions::check_permission(&input, None, &settings) + .expect("check_permission"); assert_eq!(get_effect(&result), Some(Effect::Deny)); } @@ -395,18 +391,19 @@ mod tests { #[test] fn test_pre_tool_use_builder() { - let input = pre_tool_use("Bash", bash_command("ls")); - assert_eq!(input.tool_name, "Bash"); - assert_eq!(input.hook_event_name, "PreToolUse"); - assert_eq!(input.tool_input["command"], "ls"); + use clash_hooks::ToolEvent; + let e = pre_tool_use("Bash", bash_command("ls")); + assert_eq!(e.tool_name(), "Bash"); + assert_eq!(e.tool_input_raw()["command"], "ls"); } #[test] fn test_post_tool_use_builder() { + use clash_hooks::ToolEvent; let response = serde_json::json!({"output": "file contents"}); - let input = post_tool_use("Read", read_file("/tmp/foo"), response.clone()); - assert_eq!(input.hook_event_name, "PostToolUse"); - assert_eq!(input.tool_response, Some(response)); + let e = post_tool_use("Read", read_file("/tmp/foo"), response.clone()); + assert_eq!(e.tool_name(), "Read"); + assert_eq!(e.tool_response().unwrap(), &response); } #[test] diff --git a/clash/src/trace.rs b/clash/src/trace.rs index e5f9425..451635c 100644 --- a/clash/src/trace.rs +++ b/clash/src/trace.rs @@ -24,7 +24,7 @@ use toolpath_claude::derive::{DeriveConfig, derive_path}; use toolpath_claude::types::ContentPart; /// A policy decision to record as a trace Step. -pub struct PolicyDecision { +pub struct TraceDecision { pub tool_use_id: String, pub tool_name: Option, pub effect: crate::policy::Effect, @@ -117,7 +117,7 @@ pub fn init_trace( /// Read new conversation entries, derive toolpath Steps, and append them to the trace. /// Optionally record a policy decision as an additional Step. -pub fn sync_trace(session_id: &str, decision: Option) -> anyhow::Result<()> { +pub fn sync_trace(session_id: &str, decision: Option) -> anyhow::Result<()> { let mut meta = load_meta(session_id)?; let (entries, new_offset) = @@ -659,7 +659,7 @@ mod tests { .unwrap(); sync_trace( &session_id, - Some(PolicyDecision { + Some(TraceDecision { tool_use_id: "tu-123".into(), tool_name: Some("Bash".into()), effect: crate::policy::Effect::Allow, @@ -719,7 +719,7 @@ mod tests { .unwrap(); sync_trace( &session_id, - Some(PolicyDecision { + Some(TraceDecision { tool_use_id: "tu-789".into(), tool_name: None, effect: crate::policy::Effect::Deny, @@ -821,7 +821,7 @@ mod tests { // Policy denies the tool use. sync_trace( &session_id, - Some(PolicyDecision { + Some(TraceDecision { tool_use_id: "tu-denied".into(), tool_name: Some("Bash".into()), effect: crate::policy::Effect::Deny, @@ -946,7 +946,7 @@ mod tests { // Policy evaluates the tool use. sync_trace( &session_id, - Some(PolicyDecision { + Some(TraceDecision { tool_use_id: "tu-bash-1".into(), tool_name: Some("Bash".into()), effect: crate::policy::Effect::Allow, @@ -991,7 +991,7 @@ mod tests { } sync_trace( &session_id, - Some(PolicyDecision { + Some(TraceDecision { tool_use_id: "tu-read-1".into(), tool_name: Some("Read".into()), effect: crate::policy::Effect::Allow, diff --git a/clash/src/trace_display.rs b/clash/src/trace_display.rs index f27a83d..a4d1ba7 100644 --- a/clash/src/trace_display.rs +++ b/clash/src/trace_display.rs @@ -7,7 +7,7 @@ use crate::policy::Effect; use crate::policy::format::format_condition; use crate::policy::match_tree::{ - CompiledPolicy, Decision, Node, Observable, Pattern, QueryContext, + CompiledPolicy, MatchVerdict, Node, Observable, Pattern, QueryContext, }; use crate::style; @@ -34,7 +34,7 @@ struct RuleBranchTrace { /// Per-condition trace entries. conditions: Vec, /// The decision at the leaf, if all conditions matched. - decision: Option, + decision: Option, /// Whether this rule was the winning (first-matched) rule. is_winner: bool, /// Whether this rule was skipped because a prior rule already won. @@ -118,7 +118,7 @@ fn trace_node( node: &Node, ctx: &QueryContext, conditions: &mut Vec, -) -> Option { +) -> Option { match node { Node::Decision(d) => Some(d.clone()), Node::Condition { @@ -262,7 +262,7 @@ fn collect_rule_path(node: &Node, parts: &mut Vec) { } } -fn find_first_decision(nodes: &[Node]) -> Option { +fn find_first_decision(nodes: &[Node]) -> Option { for node in nodes { match node { Node::Decision(d) => return Some(d.clone()), @@ -458,7 +458,7 @@ mod tests { children: vec![Node::Condition { observe: Observable::PositionalArg(0), pattern: Pattern::Literal(Value::Literal("git".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, @@ -485,7 +485,7 @@ mod tests { let policy = make_policy(vec![Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Read".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, @@ -509,7 +509,7 @@ mod tests { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Bash".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, @@ -517,7 +517,7 @@ mod tests { Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Bash".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -539,7 +539,7 @@ mod tests { let policy = make_policy(vec![Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Bash".into())), - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -562,7 +562,7 @@ mod tests { let policy = make_policy(vec![Node::Condition { observe: Observable::ToolName, pattern: Pattern::Literal(Value::Literal("Bash".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, @@ -583,7 +583,7 @@ mod tests { let policy = make_policy(vec![Node::Condition { observe: Observable::ToolName, pattern: Pattern::Wildcard, - children: vec![Node::Decision(Decision::Allow(None))], + children: vec![Node::Decision(MatchVerdict::Allow(None))], doc: None, source: None, terminal: false, @@ -603,7 +603,7 @@ mod tests { let policy = make_policy(vec![Node::Condition { observe: Observable::ToolName, pattern: Pattern::Regex(Arc::new(re)), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, @@ -629,7 +629,7 @@ mod tests { children: vec![Node::Condition { observe: Observable::PositionalArg(1), pattern: Pattern::Literal(Value::Literal("push".into())), - children: vec![Node::Decision(Decision::Deny)], + children: vec![Node::Decision(MatchVerdict::Deny)], doc: None, source: None, terminal: false, diff --git a/clash/src/tui/inline_form.rs b/clash/src/tui/inline_form.rs index b029e2e..bdb488d 100644 --- a/clash/src/tui/inline_form.rs +++ b/clash/src/tui/inline_form.rs @@ -15,7 +15,7 @@ use super::widgets::{ClickAction, ClickRegions, ModalHeight, ModalOverlay}; use crate::policy::manifest_edit; use crate::policy::match_tree::{ - Decision, IncludeEntry, Node, Observable, Pattern, PolicyManifest, SandboxRef, Value, + IncludeEntry, MatchVerdict, Node, Observable, Pattern, PolicyManifest, SandboxRef, Value, }; use crate::policy::sandbox_edit; use crate::policy::sandbox_types::{Cap, NetworkPolicy, PathMatch, RuleEffect}; @@ -850,17 +850,17 @@ impl FormState { fn read_decision_at_path(tree: &[Node], path: &[usize]) -> (usize, Option) { match Self::get_node_at_path(tree, path) { Some(Node::Decision(d)) => match d { - Decision::Allow(sb) => (0, sb.as_ref().map(|s| s.0.clone())), - Decision::Deny => (1, None), - Decision::Ask(sb) => (2, sb.as_ref().map(|s| s.0.clone())), + MatchVerdict::Allow(sb) => (0, sb.as_ref().map(|s| s.0.clone())), + MatchVerdict::Deny => (1, None), + MatchVerdict::Ask(sb) => (2, sb.as_ref().map(|s| s.0.clone())), }, // For inline leaves (Condition with single Decision child), read the child Some(Node::Condition { children, .. }) => { if let Some(Node::Decision(d)) = children.first() { match d { - Decision::Allow(sb) => (0, sb.as_ref().map(|s| s.0.clone())), - Decision::Deny => (1, None), - Decision::Ask(sb) => (2, sb.as_ref().map(|s| s.0.clone())), + MatchVerdict::Allow(sb) => (0, sb.as_ref().map(|s| s.0.clone())), + MatchVerdict::Deny => (1, None), + MatchVerdict::Ask(sb) => (2, sb.as_ref().map(|s| s.0.clone())), } } else { (0, None) @@ -980,9 +980,9 @@ impl FormState { } } let effect = match d { - Decision::Allow(_) => "allow", - Decision::Deny => "deny", - Decision::Ask(_) => "ask", + MatchVerdict::Allow(_) => "allow", + MatchVerdict::Deny => "deny", + MatchVerdict::Ask(_) => "ask", }; format!("Edit effect (currently {effect})") } @@ -999,9 +999,9 @@ impl FormState { let what = observable_short_desc(observe); let val = short_pattern_desc(pattern); let effect = match d { - Decision::Allow(_) => "allow", - Decision::Deny => "deny", - Decision::Ask(_) => "ask", + MatchVerdict::Allow(_) => "allow", + MatchVerdict::Deny => "deny", + MatchVerdict::Ask(_) => "ask", }; return format!("Edit: {what} = {val} (currently {effect})"); } @@ -1567,10 +1567,10 @@ impl FormState { }; let decision = match effect_idx { - 0 => Decision::Allow(sandbox_ref), - 1 => Decision::Deny, - 2 => Decision::Ask(sandbox_ref), - _ => Decision::Deny, + 0 => MatchVerdict::Allow(sandbox_ref), + 1 => MatchVerdict::Deny, + 2 => MatchVerdict::Ask(sandbox_ref), + _ => MatchVerdict::Deny, }; let node = if rule_type == 0 { @@ -1600,7 +1600,7 @@ impl FormState { } /// Build a tool-name rule node, supporting comma-separated names for AnyOf. - fn build_tool_node(tool_name: &str, decision: Decision) -> Node { + fn build_tool_node(tool_name: &str, decision: MatchVerdict) -> Node { let names: Vec<&str> = tool_name .split(',') .map(|s| s.trim()) @@ -1656,10 +1656,10 @@ impl FormState { }; let decision = match effect_idx { - 0 => Decision::Allow(sandbox_ref), - 1 => Decision::Deny, - 2 => Decision::Ask(sandbox_ref), - _ => Decision::Deny, + 0 => MatchVerdict::Allow(sandbox_ref), + 1 => MatchVerdict::Deny, + 2 => MatchVerdict::Ask(sandbox_ref), + _ => MatchVerdict::Deny, }; let new_node = if rule_type == 0 { @@ -1836,10 +1836,10 @@ impl FormState { None }; let new_decision = match effect_idx { - 0 => Decision::Allow(sandbox_ref), - 1 => Decision::Deny, - 2 => Decision::Ask(sandbox_ref), - _ => Decision::Deny, + 0 => MatchVerdict::Allow(sandbox_ref), + 1 => MatchVerdict::Deny, + 2 => MatchVerdict::Ask(sandbox_ref), + _ => MatchVerdict::Deny, }; let node = Self::get_node_at_path_mut(&mut manifest.policy.tree, path) @@ -1937,7 +1937,7 @@ impl FormState { Node::Condition { observe: observable, pattern, - children: vec![Node::Decision(Decision::Ask(None))], + children: vec![Node::Decision(MatchVerdict::Ask(None))], doc: None, source: None, terminal: false, @@ -1959,10 +1959,10 @@ impl FormState { None }; let decision = match effect_idx { - 0 => Decision::Allow(sandbox_ref), - 1 => Decision::Deny, - 2 => Decision::Ask(sandbox_ref), - _ => Decision::Deny, + 0 => MatchVerdict::Allow(sandbox_ref), + 1 => MatchVerdict::Deny, + 2 => MatchVerdict::Ask(sandbox_ref), + _ => MatchVerdict::Deny, }; Node::Decision(decision) }; @@ -2823,7 +2823,7 @@ mod tests { let mut manifest = empty_manifest(); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_tool_rule("Read", Decision::Allow(None)), + manifest_edit::build_tool_rule("Read", MatchVerdict::Allow(None)), ); // Create edit form for the root condition @@ -2876,7 +2876,7 @@ mod tests { // Create a condition with multiple children so it's expandable manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_exec_rule("gh", &["pr"], Decision::Allow(None)), + manifest_edit::build_exec_rule("gh", &["pr"], MatchVerdict::Allow(None)), ); // Add a decision child under root (Bash condition) @@ -2905,7 +2905,7 @@ mod tests { Node::Condition { children, .. } => { let has_deny = children .iter() - .any(|c| matches!(c, Node::Decision(Decision::Deny))); + .any(|c| matches!(c, Node::Decision(MatchVerdict::Deny))); assert!(has_deny, "Should have a Deny decision child"); } _ => panic!("Expected condition node"), @@ -3014,7 +3014,7 @@ mod tests { let mut manifest = empty_manifest(); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_tool_rule("Bash", Decision::Allow(None)), + manifest_edit::build_tool_rule("Bash", MatchVerdict::Allow(None)), ); // The tool rule creates Condition(tool_name=Bash) -> Decision(Allow) diff --git a/clash/src/tui/test_panel.rs b/clash/src/tui/test_panel.rs index f2707c1..3fd8416 100644 --- a/clash/src/tui/test_panel.rs +++ b/clash/src/tui/test_panel.rs @@ -583,7 +583,7 @@ mod tests { }; manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_tool_rule("Read", Decision::Allow(None)), + manifest_edit::build_tool_rule("Read", MatchVerdict::Allow(None)), ); manifest.policy } diff --git a/clash/src/tui/tree_view.rs b/clash/src/tui/tree_view.rs index 767652f..a67dc3f 100644 --- a/clash/src/tui/tree_view.rs +++ b/clash/src/tui/tree_view.rs @@ -11,7 +11,7 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use super::tea::{Action, Component, FormRequest}; use crate::policy::format::{format_condition, format_decision}; -use crate::policy::match_tree::{CompiledPolicy, Decision, Node, PolicyManifest}; +use crate::policy::match_tree::{CompiledPolicy, MatchVerdict, Node, PolicyManifest}; /// A flattened node in the tree for display purposes. pub struct FlatNode { @@ -27,7 +27,7 @@ pub struct FlatNode { /// Whether this node has children (for expand/collapse). pub has_children: bool, /// The decision at this node, if it's a leaf. - pub decision: Option, + pub decision: Option, /// Whether this is the synthetic root node. pub is_root: bool, /// Whether this node came from an include file (read-only). @@ -619,11 +619,11 @@ impl TreeView { } } -fn decision_style(decision: Option<&Decision>) -> Style { +fn decision_style(decision: Option<&MatchVerdict>) -> Style { match decision { - Some(Decision::Allow(_)) => Style::default().fg(Color::Green), - Some(Decision::Deny) => Style::default().fg(Color::Red), - Some(Decision::Ask(_)) => Style::default().fg(Color::Yellow), + Some(MatchVerdict::Allow(_)) => Style::default().fg(Color::Green), + Some(MatchVerdict::Deny) => Style::default().fg(Color::Red), + Some(MatchVerdict::Ask(_)) => Style::default().fg(Color::Yellow), None => Style::default().fg(Color::White), } } @@ -670,7 +670,7 @@ mod tests { let mut manifest = empty_manifest(); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_tool_rule("Read", Decision::Allow(None)), + manifest_edit::build_tool_rule("Read", MatchVerdict::Allow(None)), ); let view = TreeView::new(&manifest, &empty_included()); @@ -685,7 +685,7 @@ mod tests { let mut manifest = empty_manifest(); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_tool_rule("Read", Decision::Allow(None)), + manifest_edit::build_tool_rule("Read", MatchVerdict::Allow(None)), ); let mut view = TreeView::new(&manifest, &empty_included()); @@ -715,7 +715,7 @@ mod tests { let mut manifest = empty_manifest(); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_exec_rule("gh", &["pr"], Decision::Allow(None)), + manifest_edit::build_exec_rule("gh", &["pr"], MatchVerdict::Allow(None)), ); let mut view = TreeView::new(&manifest, &empty_included()); @@ -745,7 +745,7 @@ mod tests { let mut manifest = empty_manifest(); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_exec_rule("gh", &["pr"], Decision::Allow(None)), + manifest_edit::build_exec_rule("gh", &["pr"], MatchVerdict::Allow(None)), ); let mut view = TreeView::new(&manifest, &empty_included()); @@ -764,7 +764,7 @@ mod tests { let mut manifest = empty_manifest(); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_tool_rule("Read", Decision::Allow(None)), + manifest_edit::build_tool_rule("Read", MatchVerdict::Allow(None)), ); let mut view = TreeView::new(&manifest, &empty_included()); @@ -792,11 +792,11 @@ mod tests { let mut manifest = empty_manifest(); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_exec_rule("gh", &[], Decision::Allow(None)), + manifest_edit::build_exec_rule("gh", &[], MatchVerdict::Allow(None)), ); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_exec_rule("rm", &[], Decision::Deny), + manifest_edit::build_exec_rule("rm", &[], MatchVerdict::Deny), ); let mut view = TreeView::new(&manifest, &empty_included()); @@ -823,11 +823,11 @@ mod tests { let mut manifest = empty_manifest(); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_tool_rule("Read", Decision::Allow(None)), + manifest_edit::build_tool_rule("Read", MatchVerdict::Allow(None)), ); manifest_edit::upsert_rule( &mut manifest, - manifest_edit::build_tool_rule("Write", Decision::Deny), + manifest_edit::build_tool_rule("Write", MatchVerdict::Deny), ); let mut view = TreeView::new(&manifest, &empty_included()); diff --git a/clash/src/ui.rs b/clash/src/ui.rs index 8366cd9..cd0d8fc 100644 --- a/clash/src/ui.rs +++ b/clash/src/ui.rs @@ -1,16 +1,12 @@ //! Terminal output helpers — everything that talks to stdout/stderr. //! -//! This module owns all human-readable output. Command handlers call `ui::` -//! functions instead of `println!`/`eprintln!` directly, keeping display -//! logic centralized and consistent. -//! //! `style` handles *how things look* (colors, boldness). -//! `display` handles *pure data transforms* (JSON serialization, effect colorizing). -//! `ui` handles *communicating outcomes* — formatting and printing. +//! `display` handles *pure formatting* (decision rendering, JSON serialization). +//! `ui` handles *step reporting* (✓/✗/~/· glyphs), structural output (banners, +//! sections), and complex table rendering (sandbox matrix). use std::collections::HashMap; -use crate::policy::ir::PolicyDecision; use crate::policy::sandbox_types::SandboxPolicy; use crate::style; @@ -77,63 +73,6 @@ pub fn banner_section(title: &str) { // Policy display // --------------------------------------------------------------------------- -/// Print the "Input:" header block: tool name and arguments. -pub fn print_tool_header(title: &str, tool_name: &str, arguments: &serde_json::Value) { - println!("{}", style::bold(title)); - println!(" {} {}", style::cyan("tool:"), tool_name); - println!(" {} {}", style::cyan("arguments:"), arguments); -} - -/// Print a policy decision: effect, reason, matched/skipped rules, resolution. -pub fn print_decision(decision: &PolicyDecision) { - println!( - "{} {}", - style::bold("Decision:"), - style::effect(&decision.effect.to_string()) - ); - if let Some(ref reason) = decision.reason { - println!("{} {}", style::bold("Reason: "), reason); - } - println!(); - - if !decision.trace.matched_rules.is_empty() { - println!("{}", style::header("Matched rules:")); - for m in &decision.trace.matched_rules { - let eff = style::effect(&m.effect.to_string()); - println!(" [{}] {} -> {}", m.rule_index, m.description, eff); - } - println!(); - } - - if !decision.trace.skipped_rules.is_empty() { - println!("{}", style::dim("Skipped rules:")); - for s in &decision.trace.skipped_rules { - println!( - " {} {} {}", - style::dim(&format!("[{}]", s.rule_index)), - style::dim(&s.description), - style::dim(&format!("({})", s.reason)) - ); - } - println!(); - } - - println!( - "{} {}", - style::bold("Resolution:"), - style::effect(&decision.trace.final_resolution) - ); -} - -/// Print a sandbox policy summary (default caps, network, rules). -pub fn print_sandbox_summary(sandbox: &SandboxPolicy) { - println!(" {}: {}", style::cyan("default"), sandbox.default.short()); - println!(" {}: {:?}", style::cyan("network"), sandbox.network); - for rule in &sandbox.rules { - println!(" {:?} {} in {}", rule.effect, rule.caps.short(), rule.path); - } -} - /// Print sandboxes as a compact matrix: paths down the left, sandbox names across the top. pub fn print_sandbox_table(sandboxes: &HashMap) { use crate::policy::sandbox_types::{NetworkPolicy, RuleEffect}; diff --git a/clash_hooks/src/event.rs b/clash_hooks/src/event.rs index 5353c36..a494a77 100644 --- a/clash_hooks/src/event.rs +++ b/clash_hooks/src/event.rs @@ -1256,10 +1256,7 @@ mod tests { let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); assert_eq!(json["continue"], true); assert_eq!(json["hookSpecificOutput"]["hookEventName"], "PreToolUse"); - assert_eq!( - json["hookSpecificOutput"]["permissionDecision"], - "allow" - ); + assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "allow"); } #[test] @@ -1272,10 +1269,7 @@ mod tests { let mut buf = Vec::new(); crate::send_to(&response, &mut buf).unwrap(); let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); - assert_eq!( - json["hookSpecificOutput"]["permissionDecision"], - "deny" - ); + assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny"); assert_eq!( json["hookSpecificOutput"]["permissionDecisionReason"], "not allowed" @@ -1303,10 +1297,7 @@ mod tests { json["hookSpecificOutput"]["hookEventName"], "PermissionRequest" ); - assert_eq!( - json["hookSpecificOutput"]["decision"]["behavior"], - "allow" - ); + assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow"); } #[test] @@ -1326,10 +1317,7 @@ mod tests { let mut buf = Vec::new(); crate::send_to(&response, &mut buf).unwrap(); let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); - assert_eq!( - json["hookSpecificOutput"]["decision"]["behavior"], - "deny" - ); + assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny"); assert_eq!( json["hookSpecificOutput"]["decision"]["message"], "absolutely not" @@ -1347,10 +1335,7 @@ mod tests { let mut buf = Vec::new(); crate::send_to(&response, &mut buf).unwrap(); let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); - assert_eq!( - json["hookSpecificOutput"]["hookEventName"], - "SessionStart" - ); + assert_eq!(json["hookSpecificOutput"]["hookEventName"], "SessionStart"); assert_eq!( json["hookSpecificOutput"]["additionalContext"], "Hook is active" @@ -1443,9 +1428,7 @@ mod tests { }"#; let event = crate::recv_from(json.as_bytes()).unwrap(); let response = match event { - HookEvent::Elicitation(e) => { - e.accept(serde_json::json!({"api_key": "sk-123"})) - } + HookEvent::Elicitation(e) => e.accept(serde_json::json!({"api_key": "sk-123"})), _ => unreachable!(), }; let mut buf = Vec::new(); diff --git a/clash_hooks/src/lib.rs b/clash_hooks/src/lib.rs index 0067190..f73fd06 100644 --- a/clash_hooks/src/lib.rs +++ b/clash_hooks/src/lib.rs @@ -95,6 +95,17 @@ pub fn recv_from(reader: impl Read) -> Result { Ok(HookEvent::from_parsed(envelope.common, payload)) } +/// Parse a hook event from an already-deserialized JSON [`serde_json::Value`]. +/// +/// This is the key enabler for non-Claude agent protocols: normalize their +/// JSON to Claude convention, then feed it through this function for typed +/// deserialization. +pub fn recv_from_value(value: serde_json::Value) -> Result { + let envelope: wire::Envelope = serde_json::from_value(value)?; + let payload = wire::parse_payload(&envelope.common.hook_event_name, envelope.extra); + Ok(HookEvent::from_parsed(envelope.common, payload)) +} + /// Write a hook response to stdout. pub fn send(response: &Response) -> Result<(), Error> { send_to(response, std::io::stdout().lock()) @@ -134,8 +145,7 @@ pub fn main( ) -> ! { let result = (|| { let event = recv()?; - let response = f(event) - .map_err(|e| Error::Output(std::io::Error::new(std::io::ErrorKind::Other, e)))?; + let response = f(event).map_err(|e| Error::Output(std::io::Error::other(e)))?; send(&response)?; Ok::<(), Error>(()) })(); @@ -148,3 +158,85 @@ pub fn main( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recv_from_value_round_trips_pre_tool_use() { + let json = serde_json::json!({ + "session_id": "test-session", + "transcript_path": "/tmp/transcript.jsonl", + "cwd": "/home/user/project", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git status"}, + "tool_use_id": "toolu_01" + }); + let event = recv_from_value(json).unwrap(); + match event { + HookEvent::PreToolUse(e) => { + assert_eq!(e.session_id(), "test-session"); + assert_eq!(e.tool_name(), "Bash"); + assert_eq!(e.bash().unwrap().command, "git status"); + } + other => panic!("expected PreToolUse, got {other:?}"), + } + } + + #[test] + fn recv_from_value_round_trips_session_start() { + let json = serde_json::json!({ + "session_id": "s1", + "transcript_path": "/tmp/t.jsonl", + "cwd": "/tmp", + "hook_event_name": "SessionStart", + "source": "startup", + "model": "claude-sonnet-4-20250514" + }); + let event = recv_from_value(json).unwrap(); + match event { + HookEvent::SessionStart(e) => { + assert_eq!(e.session_id(), "s1"); + assert_eq!(e.source(), Some("startup")); + assert_eq!(e.model(), Some("claude-sonnet-4-20250514")); + } + other => panic!("expected SessionStart, got {other:?}"), + } + } + + #[test] + fn recv_from_value_matches_recv_from() { + let json = serde_json::json!({ + "session_id": "s", + "transcript_path": "t", + "cwd": "c", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": {"file_path": "/tmp/f.txt", "content": "hi"} + }); + // recv_from_value + let event = recv_from_value(json.clone()).unwrap(); + let response = match event { + HookEvent::PreToolUse(e) => e.allow(), + _ => unreachable!(), + }; + let mut buf_val = Vec::new(); + send_to(&response, &mut buf_val).unwrap(); + + // recv_from (via bytes) + let bytes = serde_json::to_vec(&json).unwrap(); + let event2 = recv_from(bytes.as_slice()).unwrap(); + let response2 = match event2 { + HookEvent::PreToolUse(e) => e.allow(), + _ => unreachable!(), + }; + let mut buf_from = Vec::new(); + send_to(&response2, &mut buf_from).unwrap(); + + assert_eq!(buf_val, buf_from); + } +} diff --git a/clash_hooks/tests/integration.rs b/clash_hooks/tests/integration.rs index 00c9a65..400b25d 100644 --- a/clash_hooks/tests/integration.rs +++ b/clash_hooks/tests/integration.rs @@ -4,13 +4,10 @@ //! verifies the event fields, builds a response, serializes with `send_to()`, //! and asserts the wire JSON matches Claude Code expectations. -use clash_hooks::{recv_from, send_to, HookEvent, HookEventCommon, ToolEvent}; +use clash_hooks::{HookEvent, HookEventCommon, ToolEvent, recv_from, send_to}; fn fixture(name: &str) -> Vec { - let path = format!( - "{}/tests/fixtures/{name}", - env!("CARGO_MANIFEST_DIR") - ); + let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")); std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read fixture {path}: {e}")) } @@ -58,7 +55,10 @@ fn test_roundtrip_pre_tool_use_write() { let json = response_json(&e.deny("not allowed")); assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny"); - assert_eq!(json["hookSpecificOutput"]["permissionDecisionReason"], "not allowed"); + assert_eq!( + json["hookSpecificOutput"]["permissionDecisionReason"], + "not allowed" + ); } other => panic!("expected PreToolUse, got {other:?}"), } @@ -75,7 +75,10 @@ fn test_roundtrip_post_tool_use() { assert_eq!(resp["stdout"], "test result: ok. 5 passed"); let json = response_json(&e.context("tests passed")); - assert_eq!(json["hookSpecificOutput"]["additionalContext"], "tests passed"); + assert_eq!( + json["hookSpecificOutput"]["additionalContext"], + "tests passed" + ); } other => panic!("expected PostToolUse, got {other:?}"), } @@ -91,7 +94,10 @@ fn test_roundtrip_post_tool_use_failure() { assert!(!e.is_interrupt()); let json = response_json(&e.context("command failed")); - assert_eq!(json["hookSpecificOutput"]["hookEventName"], "PostToolUseFailure"); + assert_eq!( + json["hookSpecificOutput"]["hookEventName"], + "PostToolUseFailure" + ); } other => panic!("expected PostToolUseFailure, got {other:?}"), } @@ -108,13 +114,19 @@ fn test_roundtrip_permission_request() { // Test approve let json = response_json(&e.approve()); - assert_eq!(json["hookSpecificOutput"]["hookEventName"], "PermissionRequest"); + assert_eq!( + json["hookSpecificOutput"]["hookEventName"], + "PermissionRequest" + ); assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow"); // Test deny let json = response_json(&e.deny("denied by policy")); assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny"); - assert_eq!(json["hookSpecificOutput"]["decision"]["message"], "denied by policy"); + assert_eq!( + json["hookSpecificOutput"]["decision"]["message"], + "denied by policy" + ); } other => panic!("expected PermissionRequest, got {other:?}"), } @@ -131,7 +143,10 @@ fn test_roundtrip_session_start() { let json = response_json(&e.context("clash is active")); assert_eq!(json["hookSpecificOutput"]["hookEventName"], "SessionStart"); - assert_eq!(json["hookSpecificOutput"]["additionalContext"], "clash is active"); + assert_eq!( + json["hookSpecificOutput"]["additionalContext"], + "clash is active" + ); } other => panic!("expected SessionStart, got {other:?}"), } @@ -273,7 +288,10 @@ fn test_roundtrip_config_change() { match event { HookEvent::ConfigChange(ref e) => { assert_eq!(e.source(), Some("project_settings")); - assert_eq!(e.file_path(), Some("/home/user/project/.claude/settings.json")); + assert_eq!( + e.file_path(), + Some("/home/user/project/.claude/settings.json") + ); let json = response_json(&e.block("config change blocked")); assert_eq!(json["decision"], "block"); @@ -288,7 +306,10 @@ fn test_roundtrip_pre_compact() { match event { HookEvent::PreCompact(ref e) => { assert_eq!(e.trigger(), Some("auto")); - assert_eq!(e.custom_instructions(), Some("Keep the test plan in context")); + assert_eq!( + e.custom_instructions(), + Some("Keep the test plan in context") + ); } other => panic!("expected PreCompact, got {other:?}"), } @@ -300,7 +321,10 @@ fn test_roundtrip_post_compact() { match event { HookEvent::PostCompact(ref e) => { assert_eq!(e.trigger(), Some("auto")); - assert_eq!(e.compact_summary(), Some("Summarized 50 messages into 5 key points")); + assert_eq!( + e.compact_summary(), + Some("Summarized 50 messages into 5 key points") + ); } other => panic!("expected PostCompact, got {other:?}"), } @@ -326,7 +350,10 @@ fn test_roundtrip_unknown_future() { assert_eq!(e.session_id(), "sess_abc123"); // Extra fields are preserved let extra = e.extra(); - assert_eq!(extra.get("new_field_1").and_then(|v| v.as_str()), Some("some_value")); + assert_eq!( + extra.get("new_field_1").and_then(|v| v.as_str()), + Some("some_value") + ); assert_eq!(extra.get("new_field_2").and_then(|v| v.as_i64()), Some(42)); } other => panic!("expected Unknown, got {other:?}"), @@ -523,6 +550,9 @@ fn test_typed_tool_input_is_cached() { // Call typed_tool_input twice — the second should return the cached ref. let first = e.typed_tool_input() as *const _; let second = e.typed_tool_input() as *const _; - assert_eq!(first, second, "typed_tool_input should return same cached reference"); + assert_eq!( + first, second, + "typed_tool_input should return same cached reference" + ); } }