diff --git a/Cargo.lock b/Cargo.lock index 48fc69b..8b17678 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,24 +28,133 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "errno" version = "0.3.14" @@ -53,7 +162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -62,6 +171,21 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs2" version = "0.4.3" @@ -72,6 +196,89 @@ dependencies = [ "winapi", ] +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -79,8 +286,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -89,12 +299,232 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" @@ -107,6 +537,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "lock_api" version = "0.4.14" @@ -122,12 +558,34 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.3" @@ -136,7 +594,7 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -168,12 +626,27 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -183,6 +656,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -198,6 +727,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -236,6 +791,68 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.5" @@ -246,9 +863,56 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -307,6 +971,35 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -317,12 +1010,40 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "3.0.4" @@ -334,6 +1055,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -341,10 +1082,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", ] [[package]] @@ -385,14 +1156,18 @@ version = "0.5.0" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "fs2", + "futures-util", "glob", "libc", "log", "parking_lot", "regex", + "reqwest", "rustix", "serde_json", + "sha2", "tempfile", "tinytools", "tokio", @@ -400,6 +1175,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.1" @@ -411,8 +1192,9 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -426,6 +1208,74 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -441,6 +1291,27 @@ name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" @@ -448,6 +1319,36 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -458,12 +1359,119 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -486,7 +1494,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -501,6 +1509,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -510,6 +1527,159 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "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]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/tinytools-agent/src/repair/test/json.rs b/crates/tinytools-agent/src/repair/test/json.rs index ac7ac0f..395a212 100644 --- a/crates/tinytools-agent/src/repair/test/json.rs +++ b/crates/tinytools-agent/src/repair/test/json.rs @@ -166,3 +166,32 @@ fn truly_unrecoverable_input_is_none() { "an open string cannot be guessed" ); } + +#[test] +fn strip_trailing_commas_keeps_layout_and_multibyte_text_intact() { + // Whitespace after the dropped comma survives, so indentation is preserved. + assert_eq!( + strip_trailing_commas("{ \"a\": 1, \"b\": [1, 2,\n] ,\n}"), + "{ \"a\": 1, \"b\": [1, 2\n] \n}" + ); + // Input with nothing to strip comes back byte-for-byte, including + // non-ASCII text inside and outside strings. + let src = "{\"name\": \"cafΓ© ζ—₯本 πŸ¦€\", \"tags\": [\"Γ©\", \"ΓΌ\"]}"; + assert_eq!(strip_trailing_commas(src), src); + // A comma inside a string is data, and non-ASCII neighbours do not + // disturb the one that is stripped. + assert_eq!( + strip_trailing_commas("{\"a\": \"Γ©,}\", \"b\": \"ζ—₯本\",}"), + "{\"a\": \"Γ©,}\", \"b\": \"ζ—₯本\"}" + ); +} + +#[test] +fn strip_trailing_commas_handles_nested_arrays_and_objects_and_string_commas() { + assert_eq!( + strip_trailing_commas("{ \"a\": 1, \"b\": [1, 2,], }"), + "{ \"a\": 1, \"b\": [1, 2] }" + ); + let src = "{ \"reason\": \"a, b, c,\" }"; + assert_eq!(strip_trailing_commas(src), src); +} diff --git a/crates/tinytools-std/Cargo.toml b/crates/tinytools-std/Cargo.toml index dc51279..6aced59 100644 --- a/crates/tinytools-std/Cargo.toml +++ b/crates/tinytools-std/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "Host-independent building blocks for agent tools: cross-agent file staleness tracking, SSRF-safe URL validation, and PATH probing." +description = "Host-independent building blocks for agent tools: filesystem, git and network tools behind host gates, cross-agent file staleness tracking, SSRF-safe URL validation, and PATH probing." documentation = "https://docs.rs/tinytools-std" readme = "README.md" publish = false @@ -13,6 +13,10 @@ publish = false [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +# `image_info` base64 data URIs. +base64 = "0.22" +# `curl` streams the response body chunk by chunk to disk. +futures-util = { version = "0.3", default-features = false } # Advisory inter-process `flock` around the MEMORY.md read-modify-write. fs2 = "0.4" # `glob_search` pattern matching. @@ -25,13 +29,19 @@ log = "0.4" parking_lot = "0.12" # `grep` pattern compilation. regex = { workspace = true } +# The network tools' HTTP client. rustls only: a host that wants another TLS +# backend adds it to its own `reqwest` dependency and cargo unifies the features. +# `stream` drives `curl`'s chunked download; `multipart` builds the `pushover` form. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "multipart"] } # Safe effective-credential executable checks for Unix PATH candidates. rustix = { version = "1", default-features = false, features = ["fs"] } serde_json = { workspace = true } +# `curl` reports the SHA-256 of what it downloaded. +sha2 = "0.10" tinytools = { path = "../tinytools", version = "0.5.0" } # `spawn_blocking` keeps synchronous system DNS resolution off async executor threads; # `fs`/`process`/`time` serve the filesystem tools. -tokio = { version = "1", default-features = false, features = ["rt", "sync", "fs", "process", "time"] } +tokio = { version = "1", default-features = false, features = ["rt", "sync", "fs", "process", "time", "io-util"] } tracing = { workspace = true } # `grep` and `glob_search` directory walks. walkdir = "2" diff --git a/crates/tinytools-std/README.md b/crates/tinytools-std/README.md index 1759cf3..e2631bc 100644 --- a/crates/tinytools-std/README.md +++ b/crates/tinytools-std/README.md @@ -6,7 +6,8 @@ Host-independent building blocks for agent tools, extracted from OpenHuman. | --- | --- | | `file_state` | Process-wide read/write stamps so parallel agents detect stale or partial reads before overwriting a file. The host decides whether the guard is on (`init_global(enabled)`); read tools pass `record_read` an `Instant` captured before their I/O. | | `url_guard` | URL validation with SSRF checks for outbound network tools. `validate_url_with_dns_check` returns a `ValidatedUrl` whose vetted `addrs` the caller must pin its HTTP client to (e.g. `reqwest`'s `resolve_to_addrs`); re-resolving the hostname reopens DNS rebinding. | -| `filesystem` | The file and repository tools: `file_read`, `file_write`, `edit_file`, `apply_patch`, `grep`, `glob`, `list_files`, `csv_export`, `read_diff`, `git_operations`, `run_linter`, `run_tests`, `update_memory_md`. Every tool that can touch a path takes an `Arc`: the tool does the I/O, the host's gate (autonomy, workspace boundary, approvals, action budget) decides whether it may. | +| `filesystem` | The file and repository tools: `file_read`, `file_write`, `edit_file`, `apply_patch`, `grep`, `glob`, `list_files`, `csv_export`, `read_diff`, `git_operations`, `run_linter`, `run_tests`, `update_memory_md`, `image_info`, `read_workspace_state`. Every tool that can touch a path takes an `Arc`: the tool does the I/O, the host's gate (autonomy, workspace boundary, approvals, action budget) decides whether it may. | +| `network` | The network tools: `http_request`, `web_fetch`, `curl`, `pushover`. Every tool takes an `Arc`: the tool does the I/O, the host's gate (autonomy, action budget, approval, privacy mode, proxy) decides whether it may and how. Two smaller seams keep host behavior out: `PaymentHook` answers a `402 Payment Required` for `http_request`, and `HtmlExtractor` converts pages to Markdown for `web_fetch`. Names, descriptions and schemas are pinned by `src/network/fixtures/`. | | `detect_tools` | `find_on_path` and the read-only `detect_tools` tool. | No enforcement of host policy lives here; the crate only supplies mechanisms. The `filesystem` tools' name, description and JSON Schema are pinned by the fixtures in `src/filesystem/fixtures/`. diff --git a/crates/tinytools-std/src/filesystem/contract_test.rs b/crates/tinytools-std/src/filesystem/contract_test.rs index 7e7d432..f86b22d 100644 --- a/crates/tinytools-std/src/filesystem/contract_test.rs +++ b/crates/tinytools-std/src/filesystem/contract_test.rs @@ -34,6 +34,7 @@ fn tools() -> Vec<(&'static str, Box)> { ), ("glob_search", Box::new(GlobTool::new(gate.clone()))), ("grep", Box::new(GrepTool::new(gate.clone()))), + ("image_info", Box::new(ImageInfoTool::new(gate.clone()))), ("list_files", Box::new(ListFilesTool::new(gate))), ("read_diff", Box::new(ReadDiffTool::new(workspace.clone()))), ( @@ -43,7 +44,11 @@ fn tools() -> Vec<(&'static str, Box)> { ("run_tests", Box::new(RunTestsTool::new(workspace.clone()))), ( "update_memory_md", - Box::new(UpdateMemoryMdTool::new(workspace)), + Box::new(UpdateMemoryMdTool::new(workspace.clone())), + ), + ( + "read_workspace_state", + Box::new(WorkspaceStateTool::new(workspace)), ), ] } @@ -68,11 +73,13 @@ fn fixture(name: &str) -> Value { "git_operations" => include_str!("fixtures/git_operations.json"), "glob_search" => include_str!("fixtures/glob_search.json"), "grep" => include_str!("fixtures/grep.json"), + "image_info" => include_str!("fixtures/image_info.json"), "list_files" => include_str!("fixtures/list_files.json"), "read_diff" => include_str!("fixtures/read_diff.json"), "run_linter" => include_str!("fixtures/run_linter.json"), "run_tests" => include_str!("fixtures/run_tests.json"), "update_memory_md" => include_str!("fixtures/update_memory_md.json"), + "read_workspace_state" => include_str!("fixtures/read_workspace_state.json"), other => panic!("no fixture for {other}"), }; serde_json::from_str(raw).expect("fixture is valid JSON") diff --git a/crates/tinytools-std/src/filesystem/fixtures/image_info.json b/crates/tinytools-std/src/filesystem/fixtures/image_info.json new file mode 100644 index 0000000..3125c3d --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/image_info.json @@ -0,0 +1,22 @@ +{ + "description": "Read image file metadata (format, dimensions, size) and optionally return base64-encoded data.", + "exposure": "Direct", + "name": "image_info", + "permission_level": "ReadOnly", + "schema": { + "properties": { + "include_base64": { + "description": "Include base64-encoded image data in output (default: false)", + "type": "boolean" + }, + "path": { + "description": "Path to the image file (absolute or relative to workspace)", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/read_workspace_state.json b/crates/tinytools-std/src/filesystem/fixtures/read_workspace_state.json new file mode 100644 index 0000000..962bb1b --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/read_workspace_state.json @@ -0,0 +1,21 @@ +{ + "description": "Get a read-only overview of the workspace: git status (modified/untracked files), recent commits, and top-level directory structure. Useful for understanding the current project state before planning tasks.", + "exposure": "Direct", + "name": "read_workspace_state", + "permission_level": "ReadOnly", + "schema": { + "properties": { + "include_tree": { + "default": true, + "description": "Include top-level directory tree (default: true).", + "type": "boolean" + }, + "recent_commits": { + "default": 5, + "description": "Number of recent commits to show (default: 5).", + "type": "integer" + } + }, + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/git_operations/config.rs b/crates/tinytools-std/src/filesystem/git_operations/config.rs index 1d27edd..ebc2b84 100644 --- a/crates/tinytools-std/src/filesystem/git_operations/config.rs +++ b/crates/tinytools-std/src/filesystem/git_operations/config.rs @@ -25,7 +25,7 @@ use std::path::Path; /// /// The entries are `section.key`, lowercased, with any subsection elided β€” /// `remote.origin.url` is checked as `remote.url`. -pub(super) const ALLOWED_REPO_CONFIG: &[&str] = &[ +pub(crate) const ALLOWED_REPO_CONFIG: &[&str] = &[ // What `git init` and `git clone` write, and nothing else. // // `core.worktree` is deliberately absent, unlike the read-only sibling @@ -120,7 +120,7 @@ pub(super) const ALLOWED_REPO_CONFIG: &[&str] = &[ /// /// `core.hooksPath` and `commit.gpgSign` are handled separately, in /// [`hardened_git`] β€” see there for why. -pub(super) const NEUTRALISED_CONFIG: &[&str] = &[ +pub(crate) const NEUTRALISED_CONFIG: &[&str] = &[ "core.fsmonitor=", "core.sshCommand=", "core.pager=cat", @@ -208,7 +208,7 @@ pub fn shell_git_env() -> std::collections::HashMap &mut tokio::process::Command { cmd.env("GIT_CONFIG_NOSYSTEM", "1") @@ -274,7 +274,7 @@ pub(super) fn suppress_ambient_git_config( /// sign. Overriding it here removes that decision from the repository /// entirely, the same way `core.editor=false` removes commit message /// editing from it in [`NEUTRALISED_CONFIG`] above. -pub(super) fn hardened_git(dir: &Path) -> tokio::process::Command { +pub(crate) fn hardened_git(dir: &Path) -> tokio::process::Command { let mut cmd = tokio::process::Command::new("git"); suppress_ambient_git_config(&mut cmd).current_dir(dir); for kv in NEUTRALISED_CONFIG { @@ -293,7 +293,7 @@ pub(super) fn hardened_git(dir: &Path) -> tokio::process::Command { /// `remote.origin.url` β†’ `remote.url`; `core.filemode` β†’ `core.filemode`. A /// subsection may itself contain dots (`includeIf.gitdir:~/x.y/.path`), so the /// first and last components are the reliable ones. -pub(super) fn normalise_config_key(key: &str) -> String { +pub(crate) fn normalise_config_key(key: &str) -> String { let key = key.to_ascii_lowercase(); match (key.find('.'), key.rfind('.')) { (Some(first), Some(last)) if first != last => { @@ -339,7 +339,7 @@ pub(super) fn normalise_config_key(key: &str) -> String { /// `run_git_command_in` and the repository probe in `execute_in_context`, /// which reaches this conclusion without ever getting as far as the guard β€” /// cannot word the same refusal differently. -pub(super) fn disallowed_config_refusal(dir: &Path, key: &str) -> String { +pub(crate) fn disallowed_config_refusal(dir: &Path, key: &str) -> String { format!( "refusing to run git in {}: its repository config sets `{key}`, which is \ not on the allowlist of configuration this tool will run under. \ @@ -350,7 +350,7 @@ pub(super) fn disallowed_config_refusal(dir: &Path, key: &str) -> String { ) } -pub(super) async fn first_disallowed_repo_config_key(dir: &Path) -> anyhow::Result> { +pub(crate) async fn first_disallowed_repo_config_key(dir: &Path) -> anyhow::Result> { let mut cmd = tokio::process::Command::new("git"); suppress_ambient_git_config(&mut cmd).current_dir(dir); let output = cmd.args(["config", "--list", "--null"]).output().await?; diff --git a/crates/tinytools-std/src/filesystem/git_operations/mod.rs b/crates/tinytools-std/src/filesystem/git_operations/mod.rs index 5c369d5..b07c1e2 100644 --- a/crates/tinytools-std/src/filesystem/git_operations/mod.rs +++ b/crates/tinytools-std/src/filesystem/git_operations/mod.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use tinytools::ToolRunContext; use tinytools::{Tool, ToolCallOptions, ToolResult}; -mod config; +pub(crate) mod config; mod render; pub use self::config::shell_git_env; diff --git a/crates/tinytools-std/src/filesystem/image_info/mod.rs b/crates/tinytools-std/src/filesystem/image_info/mod.rs new file mode 100644 index 0000000..4773495 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/image_info/mod.rs @@ -0,0 +1,210 @@ +use super::gate::FsGate; +use async_trait::async_trait; +use serde_json::json; +use std::fmt::Write; +use std::sync::Arc; +use tinytools::{Tool, ToolResult}; + +/// Maximum file size we will read and base64-encode (5 MB). +const MAX_IMAGE_BYTES: u64 = 5_242_880; + +/// Tool to read image metadata and optionally return base64-encoded data. +/// +/// Since providers are currently text-only, this tool extracts what it can +/// (file size, format, dimensions from header bytes) and provides base64 +/// data for future multimodal provider support. +#[derive(Debug)] +pub struct ImageInfoTool { + gate: Arc, +} + +impl ImageInfoTool { + /// An `image_info` tool that reads only what `gate` allows. + pub fn new(gate: Arc) -> Self { + Self { gate } + } + + /// Detect image format from first few bytes (magic numbers). + fn detect_format(bytes: &[u8]) -> &'static str { + if bytes.len() < 4 { + return "unknown"; + } + if bytes.starts_with(b"\x89PNG") { + "png" + } else if bytes.starts_with(b"\xFF\xD8\xFF") { + "jpeg" + } else if bytes.starts_with(b"GIF8") { + "gif" + } else if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" { + "webp" + } else if bytes.starts_with(b"BM") { + "bmp" + } else { + "unknown" + } + } + + /// Try to extract dimensions from image header bytes. + /// Returns (width, height) if detectable. + fn extract_dimensions(bytes: &[u8], format: &str) -> Option<(u32, u32)> { + match format { + "png" => { + // PNG IHDR chunk: bytes 16-19 = width, 20-23 = height (big-endian) + if bytes.len() >= 24 { + let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); + let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + Some((w, h)) + } else { + None + } + } + "gif" => { + // GIF: bytes 6-7 = width, 8-9 = height (little-endian) + if bytes.len() >= 10 { + let w = u32::from(u16::from_le_bytes([bytes[6], bytes[7]])); + let h = u32::from(u16::from_le_bytes([bytes[8], bytes[9]])); + Some((w, h)) + } else { + None + } + } + "bmp" => { + // BMP: bytes 18-21 = width, 22-25 = height (little-endian, signed) + if bytes.len() >= 26 { + let w = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]); + let h_raw = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]); + let h = h_raw.unsigned_abs(); + Some((w, h)) + } else { + None + } + } + "jpeg" => Self::jpeg_dimensions(bytes), + _ => None, + } + } + + /// Parse JPEG SOF markers to extract dimensions. + fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let mut i = 2; // skip SOI marker + while i + 1 < bytes.len() { + if bytes[i] != 0xFF { + return None; + } + let marker = bytes[i + 1]; + i += 2; + + // SOF0..SOF3 markers contain dimensions + if (0xC0..=0xC3).contains(&marker) { + if i + 7 <= bytes.len() { + let h = u32::from(u16::from_be_bytes([bytes[i + 3], bytes[i + 4]])); + let w = u32::from(u16::from_be_bytes([bytes[i + 5], bytes[i + 6]])); + return Some((w, h)); + } + return None; + } + + // Skip this segment + if i + 1 < bytes.len() { + let seg_len = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as usize; + if seg_len < 2 { + return None; // Malformed segment (valid segments have length >= 2) + } + i += seg_len; + } else { + return None; + } + } + None + } +} + +#[async_trait] +impl Tool for ImageInfoTool { + fn name(&self) -> &'static str { + "image_info" + } + + fn description(&self) -> &'static str { + "Read image file metadata (format, dimensions, size) and optionally return base64-encoded data." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the image file (absolute or relative to workspace)" + }, + "include_base64": { + "type": "boolean", + "description": "Include base64-encoded image data in output (default: false)" + } + }, + "required": ["path"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let path_str = args + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; + + let include_base64 = args + .get("include_base64") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let resolved = match self.gate.validate_path(path_str).await { + Ok(p) => p, + Err(msg) => return Ok(ToolResult::error(msg)), + }; + + let metadata = tokio::fs::metadata(&resolved) + .await + .map_err(|e| anyhow::anyhow!("Failed to read file metadata: {e}"))?; + + let file_size = metadata.len(); + + if file_size > MAX_IMAGE_BYTES { + return Ok(ToolResult::error(format!( + "Image too large: {file_size} bytes (max {MAX_IMAGE_BYTES} bytes)" + ))); + } + + let bytes = tokio::fs::read(&resolved) + .await + .map_err(|e| anyhow::anyhow!("Failed to read image file: {e}"))?; + + let format = Self::detect_format(&bytes); + let dimensions = Self::extract_dimensions(&bytes, format); + + let mut output = format!("File: {path_str}\nFormat: {format}\nSize: {file_size} bytes"); + + if let Some((w, h)) = dimensions { + let _ = write!(output, "\nDimensions: {w}x{h}"); + } + + if include_base64 { + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mime = match format { + "png" => "image/png", + "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "bmp" => "image/bmp", + _ => "application/octet-stream", + }; + let _ = write!(output, "\ndata:{mime};base64,{encoded}"); + } + + Ok(ToolResult::success(output)) + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinytools-std/src/filesystem/image_info/test.rs b/crates/tinytools-std/src/filesystem/image_info/test.rs new file mode 100644 index 0000000..12900fa --- /dev/null +++ b/crates/tinytools-std/src/filesystem/image_info/test.rs @@ -0,0 +1,342 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use super::*; +use crate::filesystem::test_support::{AutonomyLevel, TestGate}; + +fn test_security() -> Arc { + TestGate::with(std::env::temp_dir(), AutonomyLevel::Full, 1_000_000) +} + +#[test] +fn image_info_tool_name() { + let tool = ImageInfoTool::new(test_security()); + assert_eq!(tool.name(), "image_info"); +} + +#[test] +fn image_info_tool_description() { + let tool = ImageInfoTool::new(test_security()); + assert!(!tool.description().is_empty()); + assert!(tool.description().contains("image")); +} + +#[test] +fn image_info_tool_schema() { + let tool = ImageInfoTool::new(test_security()); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["path"].is_object()); + assert!(schema["properties"]["include_base64"].is_object()); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("path"))); +} + +#[test] +fn image_info_tool_spec() { + let tool = ImageInfoTool::new(test_security()); + let spec = tool.spec(); + assert_eq!(spec.name, "image_info"); + assert!(spec.parameters.is_object()); +} + +// ── Format detection ──────────────────────────────────────── + +#[test] +fn detect_png() { + let bytes = b"\x89PNG\r\n\x1a\n"; + assert_eq!(ImageInfoTool::detect_format(bytes), "png"); +} + +#[test] +fn detect_jpeg() { + let bytes = b"\xFF\xD8\xFF\xE0"; + assert_eq!(ImageInfoTool::detect_format(bytes), "jpeg"); +} + +#[test] +fn detect_gif() { + let bytes = b"GIF89a"; + assert_eq!(ImageInfoTool::detect_format(bytes), "gif"); +} + +#[test] +fn detect_webp() { + let bytes = b"RIFF\x00\x00\x00\x00WEBP"; + assert_eq!(ImageInfoTool::detect_format(bytes), "webp"); +} + +#[test] +fn detect_bmp() { + let bytes = b"BM\x00\x00"; + assert_eq!(ImageInfoTool::detect_format(bytes), "bmp"); +} + +#[test] +fn detect_unknown_short() { + let bytes = b"\x00\x01"; + assert_eq!(ImageInfoTool::detect_format(bytes), "unknown"); +} + +#[test] +fn detect_unknown_garbage() { + let bytes = b"this is not an image"; + assert_eq!(ImageInfoTool::detect_format(bytes), "unknown"); +} + +// ── Dimension extraction ──────────────────────────────────── + +#[test] +fn png_dimensions() { + // Minimal PNG IHDR: 8-byte signature + 4-byte length + 4-byte IHDR + 4-byte width + 4-byte height + let mut bytes = vec![ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x03, 0x20, // width: 800 + 0x00, 0x00, 0x02, 0x58, // height: 600 + ]; + bytes.extend_from_slice(&[0u8; 10]); // padding + let dims = ImageInfoTool::extract_dimensions(&bytes, "png"); + assert_eq!(dims, Some((800, 600))); +} + +#[test] +fn gif_dimensions() { + let bytes = [ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a + 0x40, 0x01, // width: 320 (LE) + 0xF0, 0x00, // height: 240 (LE) + ]; + let dims = ImageInfoTool::extract_dimensions(&bytes, "gif"); + assert_eq!(dims, Some((320, 240))); +} + +#[test] +fn bmp_dimensions() { + let mut bytes = vec![0u8; 26]; + bytes[0] = b'B'; + bytes[1] = b'M'; + // width at offset 18 (LE): 1024 + bytes[18] = 0x00; + bytes[19] = 0x04; + bytes[20] = 0x00; + bytes[21] = 0x00; + // height at offset 22 (LE): 768 + bytes[22] = 0x00; + bytes[23] = 0x03; + bytes[24] = 0x00; + bytes[25] = 0x00; + let dims = ImageInfoTool::extract_dimensions(&bytes, "bmp"); + assert_eq!(dims, Some((1024, 768))); +} + +#[test] +fn jpeg_dimensions() { + // Minimal JPEG-like byte sequence with SOF0 marker + let mut bytes: Vec = vec![ + 0xFF, 0xD8, // SOI + 0xFF, 0xE0, // APP0 marker + 0x00, 0x10, // APP0 length = 16 + ]; + bytes.extend_from_slice(&[0u8; 14]); // APP0 payload + bytes.extend_from_slice(&[ + 0xFF, 0xC0, // SOF0 marker + 0x00, 0x11, // SOF0 length + 0x08, // precision + 0x01, 0xE0, // height: 480 + 0x02, 0x80, // width: 640 + ]); + let dims = ImageInfoTool::extract_dimensions(&bytes, "jpeg"); + assert_eq!(dims, Some((640, 480))); +} + +#[test] +fn jpeg_malformed_zero_length_segment() { + // Zero-length segment should return None instead of looping forever + let bytes: Vec = vec![ + 0xFF, 0xD8, // SOI + 0xFF, 0xE0, // APP0 marker + 0x00, 0x00, // length = 0 (malformed) + ]; + let dims = ImageInfoTool::extract_dimensions(&bytes, "jpeg"); + assert!(dims.is_none()); +} + +#[test] +fn unknown_format_no_dimensions() { + let bytes = b"random data here"; + let dims = ImageInfoTool::extract_dimensions(bytes, "unknown"); + assert!(dims.is_none()); +} + +// ── Execute tests ─────────────────────────────────────────── + +#[tokio::test] +async fn execute_missing_path() { + let tool = ImageInfoTool::new(test_security()); + let result = tool.execute(json!({})).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn execute_nonexistent_file() { + let tool = ImageInfoTool::new(test_security()); + let result = tool + .execute(json!({"path": "nonexistent_image_xyz.png"})) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result.output().contains("not allowed") || result.output().contains("Failed to resolve"), + "unexpected error: {}", + result.output() + ); +} + +#[tokio::test] +async fn execute_real_file() { + // Create a minimal valid PNG + let dir = std::env::temp_dir().join("tinytools_image_info_test"); + let _ = tokio::fs::create_dir_all(&dir).await; + let png_path = dir.join("test.png"); + + // Minimal 1x1 red PNG (67 bytes) + let png_bytes: Vec = vec![ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x49, 0x48, 0x44, 0x52, // IHDR + 0x00, 0x00, 0x00, 0x01, // width: 1 + 0x00, 0x00, 0x00, 0x01, // height: 1 + 0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc. + 0x90, 0x77, 0x53, 0xDE, // CRC + 0x00, 0x00, 0x00, 0x0C, // IDAT length + 0x49, 0x44, 0x41, 0x54, // IDAT + 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, + 0x33, // CRC + 0x00, 0x00, 0x00, 0x00, // IEND length + 0x49, 0x45, 0x4E, 0x44, // IEND + 0xAE, 0x42, 0x60, 0x82, // CRC + ]; + tokio::fs::write(&png_path, &png_bytes).await.unwrap(); + + let tool = ImageInfoTool::new(test_security()); + let result = tool + .execute(json!({"path": png_path.to_string_lossy()})) + .await + .unwrap(); + assert!(!result.is_error); + assert!(result.output().contains("Format: png")); + assert!(result.output().contains("Dimensions: 1x1")); + assert!(!result.output().contains("data:")); + + // Clean up + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +/// A decompression-bomb-shaped PNG (a header claiming an enormous canvas, +/// backed by a file well under `MAX_IMAGE_BYTES`) must not blow up memory or +/// hang: `extract_dimensions` reads four fixed IHDR bytes and never inflates +/// pixel data, so the claimed 60000x60000 canvas is read as fast as any other +/// header and no allocation is proportional to it. +#[tokio::test] +async fn execute_reports_a_bomb_shaped_header_without_decoding_pixels() { + let dir = std::env::temp_dir().join("tinytools_image_info_bomb"); + let _ = tokio::fs::create_dir_all(&dir).await; + let png_path = dir.join("bomb.png"); + + let mut bytes = vec![ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0xEA, 0x60, // width: 60000 + 0x00, 0x00, 0xEA, 0x60, // height: 60000 + ]; + bytes.extend_from_slice(&[0u8; 10]); // pad past the 24 bytes IHDR parsing reads + tokio::fs::write(&png_path, &bytes).await.unwrap(); + + let tool = ImageInfoTool::new(test_security()); + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + tool.execute(json!({"path": png_path.to_string_lossy()})), + ) + .await + .expect("image-info execution must settle within two seconds") + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert!(result.output().contains("Dimensions: 60000x60000")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn execute_with_base64() { + let dir = std::env::temp_dir().join("tinytools_image_info_b64"); + let _ = tokio::fs::create_dir_all(&dir).await; + let png_path = dir.join("test_b64.png"); + + // Minimal 1x1 PNG + let png_bytes: Vec = vec![ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, + 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, + 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ]; + tokio::fs::write(&png_path, &png_bytes).await.unwrap(); + + let tool = ImageInfoTool::new(test_security()); + let result = tool + .execute(json!({"path": png_path.to_string_lossy(), "include_base64": true})) + .await + .unwrap(); + assert!(!result.is_error); + assert!(result.output().contains("data:image/png;base64,")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[test] +fn truncated_headers_yield_no_dimensions() { + for format in ["png", "gif", "bmp"] { + assert_eq!( + ImageInfoTool::extract_dimensions(&[0u8; 5], format), + None, + "{format}" + ); + } +} + +#[test] +fn jpeg_dimension_scan_rejects_bad_structure() { + // A non-0xFF byte where a marker should be. + assert_eq!( + ImageInfoTool::extract_dimensions(&[0xFF, 0xD8, 0x00, 0x00], "jpeg"), + None + ); + // SOF marker with too few bytes after it. + assert_eq!( + ImageInfoTool::extract_dimensions(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 0], "jpeg"), + None + ); + // A skippable marker with no length bytes left. + assert_eq!( + ImageInfoTool::extract_dimensions(&[0xFF, 0xD8, 0xFF, 0xE0], "jpeg"), + None + ); + // A well-formed segment that runs out before any SOF marker. + assert_eq!( + ImageInfoTool::extract_dimensions(&[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x02], "jpeg"), + None + ); +} + +#[tokio::test] +async fn execute_on_a_directory_reports_a_read_failure() { + let dir = std::env::temp_dir().join("tinytools_image_info_dir_test"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + let tool = ImageInfoTool::new(test_security()); + let err = tool + .execute(json!({"path": dir.to_string_lossy()})) + .await + .unwrap_err(); + assert!(err.to_string().contains("Failed to read image file")); +} diff --git a/crates/tinytools-std/src/filesystem/mod.rs b/crates/tinytools-std/src/filesystem/mod.rs index c63b712..1a1cb8a 100644 --- a/crates/tinytools-std/src/filesystem/mod.rs +++ b/crates/tinytools-std/src/filesystem/mod.rs @@ -1,5 +1,6 @@ //! Filesystem tools: read/write/edit/patch, grep/glob/list, diffs, CSV export, -//! git operations, linter and test runners, and the workspace memory index. +//! git operations, linter and test runners, the workspace memory index, a +//! read-only workspace overview and image metadata. //! //! Every tool that can touch a path takes an `Arc`: the tool //! performs the I/O, the host's gate decides whether it is allowed. The gate @@ -33,12 +34,14 @@ mod gate; mod git_operations; mod glob_search; mod grep; +mod image_info; mod list_files; mod read_diff; mod run_linter; mod run_tests; mod text; mod update_memory_md; +mod workspace_state; #[cfg(test)] mod gate_test; @@ -54,11 +57,13 @@ pub use gate::FsGate; pub use git_operations::{GitOperationsTool, shell_git_env}; pub use glob_search::GlobTool; pub use grep::GrepTool; +pub use image_info::ImageInfoTool; pub use list_files::ListFilesTool; pub use read_diff::ReadDiffTool; pub use run_linter::RunLinterTool; pub use run_tests::RunTestsTool; pub use update_memory_md::UpdateMemoryMdTool; +pub use workspace_state::WorkspaceStateTool; #[cfg(test)] mod contract_test; diff --git a/crates/tinytools-std/src/filesystem/workspace_state/mod.rs b/crates/tinytools-std/src/filesystem/workspace_state/mod.rs new file mode 100644 index 0000000..9b61c25 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/workspace_state/mod.rs @@ -0,0 +1,157 @@ +//! Tool: `read_workspace_state` β€” read-only workspace overview for Orchestrator/Planner. + +use super::git_operations::config::{ + disallowed_config_refusal, first_disallowed_repo_config_key, hardened_git, +}; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; +use tinytools::{PermissionLevel, Tool, ToolResult}; + +/// Returns a summary of the workspace: git status, file tree, recent commits. +#[derive(Debug)] +pub struct WorkspaceStateTool { + workspace_dir: PathBuf, +} + +impl WorkspaceStateTool { + /// A `read_workspace_state` tool reporting on `workspace_dir`. + #[must_use] + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } +} + +#[async_trait] +impl Tool for WorkspaceStateTool { + fn name(&self) -> &'static str { + "read_workspace_state" + } + + fn description(&self) -> &'static str { + "Get a read-only overview of the workspace: git status (modified/untracked files), \ + recent commits, and top-level directory structure. Useful for understanding the \ + current project state before planning tasks." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "include_tree": { + "type": "boolean", + "description": "Include top-level directory tree (default: true).", + "default": true + }, + "recent_commits": { + "type": "integer", + "description": "Number of recent commits to show (default: 5).", + "default": 5 + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let include_tree = args + .get("include_tree") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + let recent_commits = args + .get("recent_commits") + .and_then(serde_json::Value::as_u64) + .unwrap_or(5) as usize; + + tracing::debug!( + "[workspace_state] dir={}, include_tree={include_tree}, recent_commits={recent_commits}", + self.workspace_dir.display() + ); + + let mut output = String::new(); + let dir = &self.workspace_dir; + + // Git status + output.push_str("## Git Status\n"); + match run_git(dir, &["status", "--porcelain"]).await { + Ok(status) if status.trim().is_empty() => { + output.push_str("Clean working tree.\n"); + } + Ok(status) => { + output.push_str(&status); + } + Err(e) => { + output.push_str(&format!("(not a git repo or error: {e})\n")); + } + } + + // Recent commits + output.push_str(&format!("\n## Recent Commits (last {recent_commits})\n")); + let log_arg = format!("-{recent_commits}"); + match run_git(dir, &["log", &log_arg, "--oneline", "--no-decorate"]).await { + Ok(log) => output.push_str(&log), + Err(e) => output.push_str(&format!("(error: {e})\n")), + } + + // Directory tree (top-level only) + if include_tree { + output.push_str("\n## Directory Tree (top-level)\n"); + match tokio::fs::read_dir(dir).await { + Ok(mut entries) => { + let mut names = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with('.') { + let suffix = if entry.file_type().await.is_ok_and(|ft| ft.is_dir()) { + "/" + } else { + "" + }; + names.push(format!("{name}{suffix}")); + } + } + names.sort(); + for name in &names { + output.push_str(&format!(" {name}\n")); + } + } + Err(e) => output.push_str(&format!("(error reading dir: {e})\n")), + } + } + + tracing::debug!("[workspace_state] output length={}", output.len()); + Ok(ToolResult::success(output)) + } +} + +async fn run_git(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { + if let Some(key) = first_disallowed_repo_config_key(dir).await? { + // The refusal is otherwise only visible folded into the tool's own + // output, which is not greppable when an operator is asking why a + // workspace stopped reporting. Correlation fields: the directory and + // the key that caused it. + tracing::debug!( + "[workspace_state] refusing to run git: dir={}, disallowed_config_key={key}", + dir.display() + ); + anyhow::bail!("{}", disallowed_config_refusal(dir, &key)) + } + + let output = hardened_git(dir).args(args).output().await?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + anyhow::bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinytools-std/src/filesystem/workspace_state/test.rs b/crates/tinytools-std/src/filesystem/workspace_state/test.rs new file mode 100644 index 0000000..077448a --- /dev/null +++ b/crates/tinytools-std/src/filesystem/workspace_state/test.rs @@ -0,0 +1,289 @@ +use super::*; +use crate::filesystem::git_operations::config::normalise_config_key; +use serde_json::json; +use tempfile::TempDir; + +fn make_tool(dir: &TempDir) -> WorkspaceStateTool { + WorkspaceStateTool::new(dir.path().to_path_buf()) +} + +#[test] +fn name_is_correct() { + let tmp = TempDir::new().unwrap(); + assert_eq!(make_tool(&tmp).name(), "read_workspace_state"); +} + +#[test] +fn description_is_non_empty() { + let tmp = TempDir::new().unwrap(); + assert!(!make_tool(&tmp).description().is_empty()); +} + +#[test] +fn schema_is_object_type() { + let tmp = TempDir::new().unwrap(); + let schema = make_tool(&tmp).parameters_schema(); + assert_eq!(schema["type"], "object"); +} + +#[test] +fn permission_level_is_read_only() { + let tmp = TempDir::new().unwrap(); + assert_eq!( + make_tool(&tmp).permission_level(), + PermissionLevel::ReadOnly + ); +} + +#[tokio::test] +async fn output_contains_git_status_section() { + let tmp = TempDir::new().unwrap(); + let result = make_tool(&tmp).execute(json!({})).await.unwrap(); + assert!(!result.is_error); + assert!(result.output().contains("Git Status")); +} + +#[tokio::test] +async fn include_tree_false_omits_directory_tree() { + let tmp = TempDir::new().unwrap(); + let result = make_tool(&tmp) + .execute(json!({"include_tree": false})) + .await + .unwrap(); + assert!(!result.is_error); + assert!(!result.output().contains("Directory Tree")); +} + +/// Write a `core.fsmonitor` hook into `dir`'s repository config that +/// creates a marker file when git runs it, and return the marker's path. +/// +/// `git status` invokes `core.fsmonitor`, and the value is read from the +/// repository's own `.git/config` β€” a file inside the workspace, which is +/// where `file_write` puts things. The hook exits non-zero so git falls +/// back to a normal scan and `status` still succeeds; the point of the test +/// is whether the command ran at all, not what it returned. +fn plant_fsmonitor_hook(dir: &TempDir) -> std::path::PathBuf { + let hook = dir.path().join("hook.sh"); + let marker = dir.path().join("COMMAND_RAN"); + std::fs::write( + &hook, + format!("#!/bin/sh\ntouch {:?}\nexit 1\n", marker.to_string_lossy()), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + // Written with `git config` rather than by appending to the file: + // appending only lands in `[core]` while `[core]` happens to be the + // last section, which is true of a fresh `git init` and is not a + // property worth depending on. + let ok = std::process::Command::new("git") + .args(["config", "core.fsmonitor"]) + .arg(&hook) + .current_dir(dir.path()) + .status() + .unwrap() + .success(); + assert!(ok, "failed to plant the hook in the repository config"); + marker +} + +fn git_init(dir: &TempDir) { + // `.status().unwrap()` only unwraps the *spawn*: a non-zero exit would + // pass silently here and surface later as a confusing assertion about + // status output. Match `plant_fsmonitor_hook`, which already checks. + let ok = std::process::Command::new("git") + .args(["init", "-q", "."]) + .current_dir(dir.path()) + .status() + .unwrap() + .success(); + assert!(ok, "git init failed in the test workspace"); +} + +/// Set a repository config key with `git config`, asserting it took. +fn set_config(dir: &TempDir, key: &str, value: &str) { + let ok = std::process::Command::new("git") + .args(["config", key, value]) + .current_dir(dir.path()) + .status() + .unwrap() + .success(); + assert!(ok, "failed to set {key} in the test workspace"); +} + +/// Issue #459. The workspace this tool reads is the same directory the +/// agent's own file tools write to, so its `.git/config` is attacker- +/// controlled input. `core.fsmonitor` names a command that `git status` +/// executes. +/// +/// Revert either layer of the fix in `run_git` and this test fails by +/// finding the marker β€” verified, not assumed. +#[cfg(unix)] +#[tokio::test] +async fn repository_config_naming_a_command_does_not_get_to_run_it() { + let tmp = TempDir::new().unwrap(); + git_init(&tmp); + let marker = plant_fsmonitor_hook(&tmp); + + let result = make_tool(&tmp).execute(json!({})).await.unwrap(); + + assert!( + !marker.exists(), + "`git status` executed the command named by the workspace's own \ + repository config β€” this tool is a code-execution primitive" + ); + assert!( + result.output().contains("fsmonitor"), + "the refusal should name the key that caused it, got: {}", + result.output() + ); +} + +/// The allowlist has to leave an ordinary repository working, or the fix is +/// just a different way of breaking the tool. +#[cfg(unix)] +#[tokio::test] +async fn an_ordinary_repository_still_reports_status_and_log() { + let tmp = TempDir::new().unwrap(); + git_init(&tmp); + std::fs::write(tmp.path().join("tracked.txt"), "hi").unwrap(); + + let result = make_tool(&tmp).execute(json!({})).await.unwrap(); + let out = result.output(); + + assert!( + out.contains("tracked.txt"), + "a plain `git init` workspace must still report status, got: {out}" + ); + assert!( + !out.contains("not on the allowlist"), + "`git init`'s own config must not trip the allowlist, got: {out}" + ); +} + +/// The first draft of the allowlist refused any repository carrying an +/// ordinary setting like `core.autocrlf`, which is most of them on Windows +/// and many elsewhere β€” the tool would have reported nothing useful for a +/// large class of real workspaces. Raised by `CodeRabbit` on the PR. +#[cfg(unix)] +#[tokio::test] +async fn an_inert_setting_an_ordinary_repository_carries_is_allowed() { + let tmp = TempDir::new().unwrap(); + git_init(&tmp); + set_config(&tmp, "core.autocrlf", "input"); + set_config(&tmp, "gc.auto", "0"); + set_config(&tmp, "remote.origin.prune", "true"); + std::fs::write(tmp.path().join("tracked.txt"), "hi").unwrap(); + + let out = make_tool(&tmp).execute(json!({})).await.unwrap().output(); + + assert!( + out.contains("tracked.txt"), + "an ordinary repository must still report status, got: {out}" + ); + assert!(!out.contains("not on the allowlist"), "got: {out}"); +} + +/// The other half of the same question, and the answer is the opposite one. +/// `filter.lfs.clean` names a program, so an LFS working copy is refused β€” +/// fail-closed, and **intended** rather than an oversight. Pinned so that +/// widening the allowlist for ergonomics cannot quietly admit it. +#[cfg(unix)] +#[tokio::test] +async fn an_lfs_clone_is_refused_because_its_filter_names_a_program() { + let tmp = TempDir::new().unwrap(); + git_init(&tmp); + // What `git lfs install` writes. `required` is inert and allowed; the + // three programs are not. + set_config(&tmp, "filter.lfs.required", "true"); + set_config(&tmp, "filter.lfs.clean", "git-lfs clean -- %f"); + + let out = make_tool(&tmp).execute(json!({})).await.unwrap().output(); + + assert!( + out.contains("filter.lfs.clean"), + "the refusal must name the key that caused it, got: {out}" + ); +} + +/// `credential.helper` reads like a preference and is command-valued: a +/// value beginning `!` is run as a shell command. `CodeRabbit`'s review +/// listed it among the inert keys to allow; it is not one, and allowlisting +/// it would have reopened the hole this PR closes. +#[cfg(unix)] +#[tokio::test] +async fn credential_helper_is_refused_despite_looking_like_a_preference() { + let tmp = TempDir::new().unwrap(); + git_init(&tmp); + set_config(&tmp, "credential.helper", "!echo pwned"); + + let out = make_tool(&tmp).execute(json!({})).await.unwrap().output(); + + assert!( + out.contains("credential.helper"), + "a command-valued key must be refused however inert it reads, got: {out}" + ); +} + +#[test] +fn a_subsection_is_elided_so_one_entry_covers_every_remote() { + assert_eq!(normalise_config_key("remote.origin.url"), "remote.url"); + assert_eq!(normalise_config_key("remote.a.b.c.url"), "remote.url"); + assert_eq!(normalise_config_key("core.fileMode"), "core.filemode"); + assert_eq!(normalise_config_key("core.fsmonitor"), "core.fsmonitor"); +} + +#[tokio::test] +async fn lists_non_hidden_files_in_tree() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("readme.txt"), "hi").unwrap(); + std::fs::write(tmp.path().join(".hidden"), "skip").unwrap(); + let result = make_tool(&tmp) + .execute(json!({"include_tree": true, "recent_commits": 0})) + .await + .unwrap(); + assert!(!result.is_error); + let out = result.output(); + assert!(out.contains("readme.txt")); + assert!(!out.contains(".hidden")); +} + +/// The hardening now comes from `git_operations`' policy, which is stricter +/// than the copy this tool used to carry: `core.worktree` redirects the tree +/// git operates on and is not on the allowlist. +#[cfg(unix)] +#[tokio::test] +async fn core_worktree_is_refused_like_any_other_unrecognised_key() { + let tmp = TempDir::new().unwrap(); + git_init(&tmp); + set_config(&tmp, "core.worktree", "/tmp"); + + let out = make_tool(&tmp).execute(json!({})).await.unwrap().output(); + + assert!(out.contains("core.worktree"), "got: {out}"); + assert!(out.contains("not on the allowlist"), "got: {out}"); +} + +/// `git config --local` never shows a worktree-scoped key; the shared check +/// reads the merged view, so a hook path set there is still caught. +#[cfg(unix)] +#[tokio::test] +async fn a_worktree_scoped_command_key_is_refused() { + let tmp = TempDir::new().unwrap(); + git_init(&tmp); + set_config(&tmp, "extensions.worktreeConfig", "true"); + let ok = std::process::Command::new("git") + .args(["config", "--worktree", "core.fsmonitor", "/bin/true"]) + .current_dir(tmp.path()) + .status() + .unwrap() + .success(); + assert!(ok, "failed to set a worktree-scoped key"); + + let out = make_tool(&tmp).execute(json!({})).await.unwrap().output(); + + assert!(out.contains("fsmonitor"), "got: {out}"); +} diff --git a/crates/tinytools-std/src/lib.rs b/crates/tinytools-std/src/lib.rs index f9be29c..8505638 100644 --- a/crates/tinytools-std/src/lib.rs +++ b/crates/tinytools-std/src/lib.rs @@ -8,6 +8,8 @@ //! sibling agent's edit is noticed before a stale overwrite. //! - [`filesystem`] β€” file read/write/edit/patch, search, git and check-runner //! tools, gated by a host-implemented [`filesystem::FsGate`]. +//! - [`network`] β€” `http_request`, `web_fetch`, `curl` and `pushover`, gated by a +//! host-implemented [`network::NetGate`]. //! - [`url_guard`] β€” URL validation with SSRF checks, plus DNS resolution //! that returns the vetted addresses for the caller to pin its connection to. //! - [`detect_tools`] β€” `PATH` probing and the read-only `detect_tools` tool. @@ -32,4 +34,5 @@ pub mod detect_tools; pub mod file_state; pub mod filesystem; +pub mod network; pub mod url_guard; diff --git a/crates/tinytools-std/src/network/contract_test.rs b/crates/tinytools-std/src/network/contract_test.rs new file mode 100644 index 0000000..33ac31f --- /dev/null +++ b/crates/tinytools-std/src/network/contract_test.rs @@ -0,0 +1,126 @@ +//! Wire-contract fixtures: the name, description, permission level, exposure +//! and JSON Schema each network tool advertises to the model. +//! +//! These are what a model is prompted with, and what existing transcripts were +//! produced against, so they are pinned against literal fixture files rather +//! than re-derived from the code under test. A change here is a change to the +//! tool's public contract and must be made on purpose. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use std::path::PathBuf; +use std::sync::Arc; + +use serde_json::{Value, json}; +use tinytools::Tool; + +use super::test_support::{AutonomyLevel, DEFAULT_LIMITS, TestHtml, TestNetGate}; +use super::*; + +fn tools() -> Vec<(&'static str, Box)> { + let gate = TestNetGate::supervised(); + vec![ + ( + "curl", + Box::new(CurlTool::new( + gate.clone(), + vec![], + PathBuf::from("."), + "downloads".into(), + 1024, + 30, + )) as Box, + ), + ( + "http_request", + Box::new(HttpRequestTool::new( + gate.clone(), + vec![], + 1024, + 30, + DEFAULT_LIMITS, + )), + ), + ( + "pushover", + Box::new(PushoverTool::new(gate.clone(), PathBuf::from("."))), + ), + ( + "web_fetch", + Box::new(WebFetchTool::new( + gate, + vec![], + None, + None, + DEFAULT_LIMITS, + Arc::new(TestHtml), + )), + ), + ] +} + +fn describe(tool: &dyn Tool) -> Value { + json!({ + "name": tool.name(), + "description": tool.description(), + "permission_level": format!("{:?}", tool.permission_level()), + "exposure": format!("{:?}", tool.exposure()), + "schema": tool.parameters_schema(), + }) +} + +fn fixture(name: &str) -> Value { + let raw = match name { + "curl" => include_str!("fixtures/curl.json"), + "http_request" => include_str!("fixtures/http_request.json"), + "pushover" => include_str!("fixtures/pushover.json"), + "web_fetch" => include_str!("fixtures/web_fetch.json"), + other => panic!("no fixture for {other}"), + }; + serde_json::from_str(raw).expect("fixture is valid JSON") +} + +#[test] +fn every_tool_advertises_exactly_its_pinned_contract() { + for (fixture_name, tool) in tools() { + assert_eq!( + describe(tool.as_ref()), + fixture(fixture_name), + "contract drift in `{fixture_name}`" + ); + } +} + +#[test] +fn web_fetch_with_a_host_property_extends_only_the_properties() { + let tool = WebFetchTool::new( + TestNetGate::supervised(), + vec![], + None, + None, + DEFAULT_LIMITS, + Arc::new(TestHtml), + ) + .with_schema_property("summary_focus", json!({"type": "string"})); + let mut expected = fixture("web_fetch"); + expected["schema"]["properties"]["summary_focus"] = json!({"type": "string"}); + assert_eq!(describe(&tool), expected); +} + +#[test] +fn only_the_approval_gated_tools_route_through_approval() { + let args = json!({"url": "https://example.com"}); + for (autonomy, prompts) in [ + (AutonomyLevel::Supervised, true), + (AutonomyLevel::Full, false), + (AutonomyLevel::ReadOnly, false), + ] { + let gate = TestNetGate::with(autonomy, 100); + let curl = CurlTool::new(gate.clone(), vec![], ".".into(), "d".into(), 1, 1); + let http = HttpRequestTool::new(gate.clone(), vec![], 1, 1, DEFAULT_LIMITS); + let fetch = WebFetchTool::new(gate, vec![], None, None, DEFAULT_LIMITS, Arc::new(TestHtml)); + assert_eq!(curl.external_effect_with_args(&args), prompts); + assert_eq!(http.external_effect_with_args(&args), prompts); + assert!(!fetch.external_effect_with_args(&args)); + } +} diff --git a/crates/tinytools-std/src/network/curl.rs b/crates/tinytools-std/src/network/curl.rs new file mode 100644 index 0000000..7da5d7b --- /dev/null +++ b/crates/tinytools-std/src/network/curl.rs @@ -0,0 +1,401 @@ +//! `curl` β€” download files from the web to a path under the workspace. +//! +//! Distinct from `http_request`: instead of returning the body inline +//! (size-capped), `curl` streams to disk with a hard byte ceiling. Same +//! SSRF/allowlist guards (shared via `url_guard`), shares +//! `http_request.allowed_domains` so there is one allowlist to reason +//! about. + +use super::gate::{NetGate, host_of}; +use crate::url_guard::{normalize_allowed_domains, validate_url_with_dns_check}; +use async_trait::async_trait; +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tinytools::{PermissionLevel, Tool, ToolResult}; +use tokio::fs; +use tokio::io::AsyncWriteExt; + +/// Downloads a file from an http(s) URL into the workspace. +#[derive(Debug)] +pub struct CurlTool { + gate: Arc, + allowed_domains: Vec, + workspace_dir: PathBuf, + dest_subdir: String, + max_download_bytes: u64, + timeout_secs: u64, +} + +impl CurlTool { + /// A `curl` tool over `gate`, downloading under `/`. + pub fn new( + gate: Arc, + allowed_domains: Vec, + workspace_dir: PathBuf, + dest_subdir: String, + max_download_bytes: u64, + timeout_secs: u64, + ) -> Self { + Self { + gate, + allowed_domains: normalize_allowed_domains(allowed_domains), + workspace_dir, + dest_subdir: sanitize_dest_subdir(&dest_subdir), + max_download_bytes, + timeout_secs, + } + } + + /// Resolve a user-supplied dest path to an absolute path inside + /// `/`. Rejects absolute paths, `..` + /// segments, and any other escape attempts. + fn resolve_dest(&self, dest: &str) -> anyhow::Result { + let trimmed = dest.trim(); + if trimmed.is_empty() { + anyhow::bail!("dest_path cannot be empty"); + } + + let p = Path::new(trimmed); + if p.is_absolute() { + anyhow::bail!("dest_path must be relative β€” got absolute path"); + } + + for component in p.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + Component::ParentDir => { + anyhow::bail!("dest_path may not contain '..'"); + } + Component::Prefix(_) | Component::RootDir => { + anyhow::bail!("dest_path must be relative"); + } + } + } + + let root = self.workspace_dir.join(&self.dest_subdir); + let resolved = root.join(p); + + // Belt-and-braces: ensure the resolved path still lives under root. + // Lexical check is sufficient because we already rejected `..`. + if !resolved.starts_with(&root) { + anyhow::bail!("dest_path resolves outside the downloads root"); + } + + Ok(resolved) + } + + async fn validate_url(&self, raw_url: &str) -> anyhow::Result { + validate_url_with_dns_check(raw_url, &self.allowed_domains) + .await + .map(|v| v.url) + } + + fn default_filename_from_url(url: &str) -> String { + let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest); + let path_part = after_scheme.split_once('/').map_or("", |(_, p)| p); + let last = path_part + .split('?') + .next() + .unwrap_or("") + .rsplit('/') + .next() + .unwrap_or(""); + let cleaned: String = last + .chars() + .filter(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_')) + .collect(); + if cleaned.is_empty() { + "download.bin".into() + } else { + cleaned + } + } +} + +#[async_trait] +impl Tool for CurlTool { + fn exposure(&self) -> tinytools::ToolExposure { + tinytools::ToolExposure::Deferred + } + + fn name(&self) -> &'static str { + "curl" + } + + fn description(&self) -> &'static str { + "Download a file from an http(s) URL into the workspace. The body is streamed to disk \ + with a hard byte ceiling. Same allowlist as `http_request`. Returns the saved path, \ + bytes written, content-type, and SHA-256 of the file." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "HTTP or HTTPS URL of the file to download" + }, + "dest_path": { + "type": "string", + "description": "Destination path relative to the downloads root inside the workspace. No '..' or absolute paths. If omitted, the filename is inferred from the URL." + }, + "headers": { + "type": "object", + "description": "Optional HTTP headers (e.g. {\"Authorization\": \"Bearer …\"})", + "default": {} + } + }, + "required": ["url"] + }) + } + + /// Downloading from the network is the always-ask `Network` bucket β€” it + /// prompts the human in both ask-before-edit and Full; read-only is blocked + /// in `execute`. + fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { + self.gate.network_needs_approval() + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let url = args + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?; + + let dest_arg = args.get("dest_path").and_then(|v| v.as_str()); + let headers_val = args + .get("headers") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + + if !self.gate.can_act() { + tracing::debug!(target: "[curl]", url = %url, "blocked: autonomy read-only"); + return Ok(ToolResult::error( + "[policy-blocked] Action blocked: autonomy is read-only", + )); + } + if !self.gate.record_action() { + tracing::debug!(target: "[curl]", url = %url, "blocked: rate limit"); + return Ok(ToolResult::error("Action blocked: rate limit exceeded")); + } + + // Local-only enforcement (privacy epic S7, #4441): mirror the read-only + // `can_act()` deny above β€” under LocalOnly, refuse the download before + // URL validation / DNS so nothing leaves the device. + { + let host = host_of(url); + if let Some(msg) = self.gate.local_only_block(&host) { + // Log only the host, never the full URL: a raw URL can carry + // secrets in its query string (pre-signed links, tokens). The + // gate helper already logs `desc.service` (host only) too. + tracing::debug!(target: "[curl]", host = %host, "blocked: local-only privacy mode"); + return Ok(ToolResult::error(msg)); + } + } + + let url = match self.validate_url(url).await { + Ok(v) => v, + Err(e) => { + tracing::debug!(target: "[curl]", url = %url, reason = %e, "url validation failed"); + return Ok(ToolResult::error(e.to_string())); + } + }; + + // Egress spine (privacy epic S2/S7, #4436/#4441): a curl download + // contacts an external host β€” disclose the destination (and that custom + // headers ride along) before the request. Enforcement already ran at the + // top of `execute`; this is the observe-only disclosure for permitted + // downloads. + { + let host = host_of(&url); + let has_headers = headers_val.as_object().is_some_and(|h| !h.is_empty()); + self.gate.disclose(&host, false, has_headers); + } + + let dest = match dest_arg { + Some(d) => d.to_string(), + None => Self::default_filename_from_url(&url), + }; + let dest_path = match self.resolve_dest(&dest) { + Ok(p) => p, + Err(e) => { + tracing::debug!(target: "[curl]", url = %url, dest = %dest, reason = %e, "dest_path rejected"); + return Ok(ToolResult::error(e.to_string())); + } + }; + + if let Some(parent) = dest_path.parent() + && let Err(e) = fs::create_dir_all(parent).await + { + tracing::error!(target: "[curl]", url = %url, dest = %dest_path.display(), reason = %e, "create_dir_all failed"); + return Ok(ToolResult::error(format!( + "Failed to create destination directory: {e}" + ))); + } + + let builder = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .connect_timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()); + let builder = self.gate.prepare_client("tool.curl", builder); + let client = match builder.build() { + Ok(c) => c, + Err(e) => { + tracing::error!(target: "[curl]", reason = %e, "HTTP client build failed"); + return Ok(ToolResult::error(format!("HTTP client build failed: {e}"))); + } + }; + + let mut request = client.get(&url); + if let Some(obj) = headers_val.as_object() { + for (k, v) in obj { + if let Some(s) = v.as_str() { + request = request.header(k, s); + } + } + } + + tracing::debug!(target: "[curl]", url = %url, dest = %dest_path.display(), "starting download"); + + let response = match request.send().await { + Ok(r) => r, + Err(e) => { + tracing::error!(target: "[curl]", url = %url, reason = %e, "request send failed"); + return Ok(ToolResult::error(format!("Request failed: {e}"))); + } + }; + + let status = response.status(); + if !status.is_success() { + tracing::debug!(target: "[curl]", url = %url, status = %status.as_u16(), "non-success HTTP status"); + return Ok(ToolResult::error(format!( + "HTTP {} {}", + status.as_u16(), + status.canonical_reason().unwrap_or("Unknown") + ))); + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + let mut file = match fs::File::create(&dest_path).await { + Ok(f) => f, + Err(e) => { + tracing::error!(target: "[curl]", dest = %dest_path.display(), reason = %e, "fs::File::create failed"); + return Ok(ToolResult::error(format!( + "Failed to create destination file: {e}" + ))); + } + }; + + let mut hasher = Sha256::new(); + let mut bytes_written: u64 = 0; + let mut stream = response.bytes_stream(); + + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + drop(file); + if let Err(rm) = fs::remove_file(&dest_path).await { + tracing::debug!(target: "[curl]", dest = %dest_path.display(), reason = %rm, "cleanup remove_file failed"); + } + tracing::error!(target: "[curl]", url = %url, bytes_written, reason = %e, "stream error"); + return Ok(ToolResult::error(format!("Stream error: {e}"))); + } + }; + + if bytes_written.saturating_add(chunk.len() as u64) > self.max_download_bytes { + let _ = file.flush().await; + drop(file); + if let Err(rm) = fs::remove_file(&dest_path).await { + tracing::debug!(target: "[curl]", dest = %dest_path.display(), reason = %rm, "cleanup remove_file failed"); + } + tracing::error!(target: "[curl]", url = %url, bytes_written, max = self.max_download_bytes, "size cap exceeded β€” download aborted"); + return Ok(ToolResult::error(format!( + "Download exceeded max_download_bytes ({} bytes)", + self.max_download_bytes + ))); + } + + if let Err(e) = file.write_all(&chunk).await { + drop(file); + if let Err(rm) = fs::remove_file(&dest_path).await { + tracing::debug!(target: "[curl]", dest = %dest_path.display(), reason = %rm, "cleanup remove_file failed"); + } + tracing::error!(target: "[curl]", dest = %dest_path.display(), bytes_written, reason = %e, "write_all failed"); + return Ok(ToolResult::error(format!("Write failed: {e}"))); + } + hasher.update(&chunk); + bytes_written += chunk.len() as u64; + } + + if let Err(e) = file.flush().await { + drop(file); + if let Err(rm) = fs::remove_file(&dest_path).await { + tracing::debug!(target: "[curl]", dest = %dest_path.display(), reason = %rm, "cleanup remove_file failed"); + } + tracing::error!(target: "[curl]", dest = %dest_path.display(), bytes_written, reason = %e, "flush failed"); + return Ok(ToolResult::error(format!("Flush failed: {e}"))); + } + + let sha256 = format!("{:x}", hasher.finalize()); + + tracing::debug!( + target: "[curl]", + url = %url, + dest = %dest_path.display(), + bytes = bytes_written, + content_type = %content_type, + sha256 = %sha256, + "download complete" + ); + + let payload = serde_json::json!({ + "path": dest_path.display().to_string(), + "bytes_written": bytes_written, + "content_type": content_type, + "sha256": sha256, + }); + Ok(ToolResult::success(payload.to_string())) + } +} + +/// Sanitize the configured `dest_subdir` so a malicious or misconfigured +/// `[curl].dest_subdir` cannot escape the workspace via absolute paths +/// or `..` segments. Drops disallowed components rather than panicking; +/// falls back to `"downloads"` if everything is filtered out. +fn sanitize_dest_subdir(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return "downloads".into(); + } + let p = Path::new(trimmed); + let mut buf = PathBuf::new(); + for component in p.components() { + // Drop everything else: absolute roots, prefixes, parent dirs, cur dirs. + if let Component::Normal(c) = component { + buf.push(c); + } + } + if buf.as_os_str().is_empty() { + return "downloads".into(); + } + buf.to_string_lossy().into_owned() +} + +#[cfg(test)] +#[path = "curl_test.rs"] +mod tests; diff --git a/crates/tinytools-std/src/network/curl_test.rs b/crates/tinytools-std/src/network/curl_test.rs new file mode 100644 index 0000000..2443f35 --- /dev/null +++ b/crates/tinytools-std/src/network/curl_test.rs @@ -0,0 +1,324 @@ +use super::*; +use crate::network::test_support::{AutonomyLevel, TestNetGate}; +use tempfile::TempDir; +fn slash_norm(s: String) -> String { + s.replace('\\', "/") +} + +fn tool(tmp: &TempDir, allow: Vec<&str>) -> CurlTool { + CurlTool::new( + TestNetGate::supervised(), + allow.into_iter().map(String::from).collect(), + tmp.path().to_path_buf(), + "downloads".into(), + 1024 * 1024, + 30, + ) +} + +#[test] +fn sanitize_dest_subdir_strips_absolute_paths() { + assert_eq!( + slash_norm(sanitize_dest_subdir("/etc/passwd")), + "etc/passwd" + ); + assert_eq!(sanitize_dest_subdir("//foo"), "foo"); +} + +#[test] +fn sanitize_dest_subdir_strips_parent_segments() { + assert_eq!(sanitize_dest_subdir("../../etc"), "etc"); + assert_eq!(slash_norm(sanitize_dest_subdir("a/../b")), "a/b"); +} + +#[test] +fn sanitize_dest_subdir_falls_back_to_downloads() { + assert_eq!(sanitize_dest_subdir(""), "downloads"); + assert_eq!(sanitize_dest_subdir(" "), "downloads"); + assert_eq!(sanitize_dest_subdir(".."), "downloads"); + assert_eq!(sanitize_dest_subdir("/"), "downloads"); +} + +#[test] +fn sanitize_dest_subdir_keeps_normal_paths() { + assert_eq!(sanitize_dest_subdir("downloads"), "downloads"); + assert_eq!( + slash_norm(sanitize_dest_subdir("artifacts/build")), + "artifacts/build" + ); +} + +#[test] +fn new_sanitizes_malicious_dest_subdir() { + let tmp = TempDir::new().unwrap(); + let t = CurlTool::new( + TestNetGate::supervised(), + vec!["example.com".into()], + tmp.path().to_path_buf(), + "../../etc".into(), + 1024, + 30, + ); + let resolved = t.resolve_dest("file.txt").unwrap(); + // Sanitizer reduced "../../etc" to "etc"; resolution must stay under workspace. + assert!(resolved.starts_with(tmp.path().join("etc"))); + assert!(resolved.starts_with(tmp.path())); +} + +#[test] +fn resolve_dest_normal() { + let tmp = TempDir::new().unwrap(); + let t = tool(&tmp, vec!["example.com"]); + let p = t.resolve_dest("foo/bar.txt").unwrap(); + assert!(p.starts_with(tmp.path().join("downloads"))); + assert!(p.ends_with("foo/bar.txt")); +} + +#[test] +fn resolve_dest_rejects_absolute() { + let tmp = TempDir::new().unwrap(); + let t = tool(&tmp, vec!["example.com"]); + let err = t.resolve_dest("/etc/passwd").unwrap_err().to_string(); + assert!(err.contains("relative")); +} + +#[test] +fn resolve_dest_rejects_parent_dir() { + let tmp = TempDir::new().unwrap(); + let t = tool(&tmp, vec!["example.com"]); + let err = t.resolve_dest("../etc/passwd").unwrap_err().to_string(); + assert!(err.contains("..")); +} + +#[test] +fn resolve_dest_rejects_nested_parent_dir() { + let tmp = TempDir::new().unwrap(); + let t = tool(&tmp, vec!["example.com"]); + let err = t.resolve_dest("a/../../b").unwrap_err().to_string(); + assert!(err.contains("..")); +} + +#[test] +fn resolve_dest_rejects_empty() { + let tmp = TempDir::new().unwrap(); + let t = tool(&tmp, vec!["example.com"]); + assert!(t.resolve_dest("").is_err()); + assert!(t.resolve_dest(" ").is_err()); +} + +#[tokio::test] +async fn validate_url_rejects_disallowed_domain() { + let tmp = TempDir::new().unwrap(); + let t = tool(&tmp, vec!["example.com"]); + let err = t + .validate_url("https://evil.test/archive.tar.gz") + .await + .unwrap_err() + .to_string(); + assert!(err.contains("allowed websites")); +} + +#[test] +fn default_filename_from_url_basic() { + assert_eq!( + CurlTool::default_filename_from_url("https://example.com/foo/bar.zip"), + "bar.zip" + ); +} + +#[test] +fn default_filename_from_url_query_stripped() { + assert_eq!( + CurlTool::default_filename_from_url("https://example.com/file.tar.gz?token=x"), + "file.tar.gz" + ); +} + +#[test] +fn default_filename_from_url_root_falls_back() { + assert_eq!( + CurlTool::default_filename_from_url("https://example.com/"), + "download.bin" + ); +} + +#[tokio::test] +async fn execute_blocks_when_rate_limited() { + let tmp = TempDir::new().unwrap(); + let t = CurlTool::new( + TestNetGate::with(AutonomyLevel::Supervised, 0), + vec!["example.com".into()], + tmp.path().into(), + "downloads".into(), + 1024, + 30, + ); + let result = t + .execute(serde_json::json!({"url": "https://example.com/x"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("rate limit")); +} + +#[tokio::test] +async fn execute_blocked_under_local_only_privacy_mode() { + // Privacy epic S7 (#4441): under LocalOnly the download is refused with a + // `[policy-blocked]` result before URL validation / network. + let tmp = TempDir::new().unwrap(); + let t = CurlTool::new( + TestNetGate::local_only(), + vec!["example.com".into()], + tmp.path().to_path_buf(), + "downloads".into(), + 1024 * 1024, + 30, + ); + let result = t + .execute(serde_json::json!({"url": "https://example.com/x"})) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result.output().contains("[policy-blocked]"), + "got: {}", + result.output() + ); + assert!( + result.output().contains("Local-only"), + "got: {}", + result.output() + ); +} + +/// Live integration smoke: downloads example.com (a tiny, stable +/// public page). Gated behind `OPENHUMAN_CURL_LIVE_TEST=1` so CI / +/// offline runs don't depend on the network. +#[tokio::test] +async fn live_download_example_com() { + if std::env::var("OPENHUMAN_CURL_LIVE_TEST").ok().as_deref() != Some("1") { + return; + } + let tmp = TempDir::new().unwrap(); + let t = tool(&tmp, vec!["example.com"]); + let result = t + .execute(serde_json::json!({ + "url": "https://example.com/", + "dest_path": "example.html" + })) + .await + .unwrap(); + assert!(!result.is_error, "live curl errored: {}", result.output()); + let payload: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); + let bytes = payload["bytes_written"].as_u64().unwrap(); + assert!(bytes > 100, "unexpectedly small download: {bytes} bytes"); + let path = payload["path"].as_str().unwrap(); + let content = std::fs::read_to_string(path).unwrap(); + assert!(content.to_lowercase().contains("example domain")); +} + +#[tokio::test] +async fn execute_rejects_allowlist_miss() { + let tmp = TempDir::new().unwrap(); + let t = tool(&tmp, vec!["example.com"]); + let result = t + .execute(serde_json::json!({"url": "https://other.example.org/x"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("allowed websites")); +} + +/// The downloads root cannot be created (a plain file already occupies its +/// path) β€” `create_dir_all` must be reported as a tool error, never a panic, +/// and no network request is attempted before that check runs. The URL uses +/// a public IPv4 literal (never actually contacted β€” `create_dir_all` fails +/// and returns before any HTTP client is built) so `validate_url_with_dns_check` +/// takes its IP-literal short-circuit and this test needs no live DNS. +#[tokio::test] +async fn execute_reports_a_create_dir_all_failure_rather_than_panicking() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("downloads"), b"not a directory").unwrap(); + let t = tool(&tmp, vec!["8.8.8.8"]); + let result = t + .execute(serde_json::json!({"url": "https://8.8.8.8/x", "dest_path": "sub/file.txt"})) + .await + .unwrap(); + assert!( + result.is_error, + "an unwritable downloads root must be reported, not panic: {}", + result.output() + ); + assert!( + result + .output() + .contains("Failed to create destination directory"), + "must fail at the create_dir_all step specifically, not e.g. DNS resolution: {}", + result.output() + ); +} + +#[tokio::test] +async fn execute_blocks_readonly_mode() { + let tmp = TempDir::new().unwrap(); + let t = CurlTool::new( + TestNetGate::with(AutonomyLevel::ReadOnly, 100), + vec!["example.com".into()], + tmp.path().into(), + "downloads".into(), + 1024, + 30, + ); + let result = t + .execute(serde_json::json!({"url": "https://example.com/x"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("read-only")); +} + +#[test] +fn approval_follows_the_gate() { + let tmp = TempDir::new().unwrap(); + let args = serde_json::json!({"url": "https://example.com/x"}); + for (autonomy, prompts) in [ + (AutonomyLevel::Supervised, true), + (AutonomyLevel::Full, false), + ] { + let t = CurlTool::new( + TestNetGate::with(autonomy, 100), + vec!["example.com".into()], + tmp.path().into(), + "downloads".into(), + 1024, + 30, + ); + assert_eq!(t.external_effect_with_args(&args), prompts); + } +} + +#[tokio::test] +async fn a_download_discloses_its_destination_and_headers_before_the_request() { + let tmp = TempDir::new().unwrap(); + let gate = TestNetGate::supervised(); + // A public IPv4 literal short-circuits DNS; `create_dir_all` then fails + // (a file occupies the downloads root), so nothing is ever contacted. + std::fs::write(tmp.path().join("downloads"), b"not a directory").unwrap(); + let t = CurlTool::new( + gate.clone(), + vec!["8.8.8.8".into()], + tmp.path().into(), + "downloads".into(), + 1024, + 30, + ); + let result = t + .execute(serde_json::json!({ + "url": "https://8.8.8.8/x", + "headers": {"Authorization": "Bearer t"} + })) + .await + .unwrap(); + assert!(result.is_error); + assert_eq!(gate.disclosed(), vec![("8.8.8.8".to_string(), false, true)]); +} diff --git a/crates/tinytools-std/src/network/fixtures/curl.json b/crates/tinytools-std/src/network/fixtures/curl.json new file mode 100644 index 0000000..0cfc585 --- /dev/null +++ b/crates/tinytools-std/src/network/fixtures/curl.json @@ -0,0 +1,27 @@ +{ + "description": "Download a file from an http(s) URL into the workspace. The body is streamed to disk with a hard byte ceiling. Same allowlist as `http_request`. Returns the saved path, bytes written, content-type, and SHA-256 of the file.", + "exposure": "Deferred", + "name": "curl", + "permission_level": "Write", + "schema": { + "properties": { + "dest_path": { + "description": "Destination path relative to the downloads root inside the workspace. No '..' or absolute paths. If omitted, the filename is inferred from the URL.", + "type": "string" + }, + "headers": { + "default": {}, + "description": "Optional HTTP headers (e.g. {\"Authorization\": \"Bearer …\"})", + "type": "object" + }, + "url": { + "description": "HTTP or HTTPS URL of the file to download", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/network/fixtures/http_request.json b/crates/tinytools-std/src/network/fixtures/http_request.json new file mode 100644 index 0000000..c09b9e9 --- /dev/null +++ b/crates/tinytools-std/src/network/fixtures/http_request.json @@ -0,0 +1,32 @@ +{ + "description": "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits.", + "exposure": "Direct", + "name": "http_request", + "permission_level": "Write", + "schema": { + "properties": { + "body": { + "description": "Optional request body (for POST, PUT, PATCH requests)", + "type": "string" + }, + "headers": { + "default": {}, + "description": "Optional HTTP headers as key-value pairs (e.g., {\"Authorization\": \"Bearer token\", \"Content-Type\": \"application/json\"})", + "type": "object" + }, + "method": { + "default": "GET", + "description": "HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)", + "type": "string" + }, + "url": { + "description": "HTTP or HTTPS URL to request", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/network/fixtures/pushover.json b/crates/tinytools-std/src/network/fixtures/pushover.json new file mode 100644 index 0000000..7cea38c --- /dev/null +++ b/crates/tinytools-std/src/network/fixtures/pushover.json @@ -0,0 +1,30 @@ +{ + "description": "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file.", + "exposure": "Direct", + "name": "pushover", + "permission_level": "ReadOnly", + "schema": { + "properties": { + "message": { + "description": "The notification message to send", + "type": "string" + }, + "priority": { + "description": "Message priority: -2 (lowest/silent), -1 (low/no sound), 0 (normal), 1 (high), 2 (emergency/repeating)", + "type": "integer" + }, + "sound": { + "description": "Notification sound override (e.g., 'pushover', 'bike', 'bugle', 'cashregister', etc.)", + "type": "string" + }, + "title": { + "description": "Optional notification title", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/network/fixtures/web_fetch.json b/crates/tinytools-std/src/network/fixtures/web_fetch.json new file mode 100644 index 0000000..18a24c8 --- /dev/null +++ b/crates/tinytools-std/src/network/fixtures/web_fetch.json @@ -0,0 +1,27 @@ +{ + "description": "GET a URL and read the page. HTML returns as Markdown (links kept, scripts dropped); `raw: true` for the body as sent. For POST or custom headers use `http_request`.", + "exposure": "Direct", + "name": "web_fetch", + "permission_level": "ReadOnly", + "schema": { + "properties": { + "max_bytes": { + "description": "Truncate body at this many bytes (default 1_000_000).", + "minimum": 1, + "type": "integer" + }, + "raw": { + "description": "Return the body as sent.", + "type": "boolean" + }, + "url": { + "description": "Absolute http(s) URL.", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/network/gate.rs b/crates/tinytools-std/src/network/gate.rs new file mode 100644 index 0000000..49d38d3 --- /dev/null +++ b/crates/tinytools-std/src/network/gate.rs @@ -0,0 +1,96 @@ +//! The host's say over every network tool call. +//! +//! The tools in this module reach the internet, but they do not decide whether +//! they are *allowed* to. Whether the agent may act at all, how many actions it +//! has left this hour, whether an outbound request needs a human's approval, +//! whether a privacy mode forbids leaving the device, and how the process-wide +//! proxy is applied to a client all belong to the host whose threat model and +//! configuration they depend on. A tool asks a [`NetGate`] instead. +//! +//! Like [`crate::filesystem::FsGate`], the trait carries no decision *types* of +//! its own, only booleans, strings and the `reqwest` builder the tool was +//! already holding, so a host maps its own policy onto it without translating +//! a vocabulary. + +/// The host policy a network tool consults before it acts. +/// +/// Implementations must be cheap to call: the tools ask on every invocation. +/// All methods take `&self`; an implementation that counts actions keeps that +/// state behind interior mutability. +pub trait NetGate: std::fmt::Debug + Send + Sync { + /// Whether the host permits the agent to act at all right now. + /// + /// `false` blocks the tool before it touches the network. + fn can_act(&self) -> bool; + + /// Whether the action budget is already spent, without consuming any of it. + fn is_rate_limited(&self) -> bool; + + /// Consume one unit of the action budget. + /// + /// Returns `false` when the call is over budget and must be refused. + fn record_action(&self) -> bool; + + /// Whether an outbound request must be confirmed by a human before it runs. + /// + /// Drives the tools' `external_effect_with_args` flag, which routes the + /// call through the host's approval flow. + fn network_needs_approval(&self) -> bool; + + /// The refusal message when the host's privacy mode forbids contacting + /// `host`, or `None` when the request may proceed. + /// + /// Asked before URL validation and DNS, so a refused request leaks nothing, + /// not even a lookup. + fn local_only_block(&self, host: &str) -> Option; + + /// Tell the host that a request is about to leave the device for `host`. + /// + /// Observe-only: called after enforcement has already let the request + /// through. `has_body` is true when a request body rides along and + /// `has_headers` when caller-supplied headers do. + fn disclose(&self, host: &str, has_body: bool, has_headers: bool); + + /// Apply the host's process-wide proxy settings for `service` to a client + /// builder the tool has already configured. + fn prepare_client( + &self, + service: &str, + builder: reqwest::ClientBuilder, + ) -> reqwest::ClientBuilder; + + /// A ready-made client for `service` with the given total and connect + /// timeouts, proxied and TLS-configured the way the host does it. + fn timeout_client( + &self, + service: &str, + timeout_secs: u64, + connect_timeout_secs: u64, + ) -> reqwest::Client; +} + +/// The host of `url`, or `"unknown"` when it does not parse or has none. +/// +/// The value handed to [`NetGate::local_only_block`] and +/// [`NetGate::disclose`]. +pub(crate) fn host_of(url: &str) -> String { + reqwest::Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// Response-size and timeout limits a tool falls back to when it is +/// constructed with a `0` (or absent) value. +/// +/// A `0` byte cap truncates every body to nothing and a `0` second timeout +/// fails every request instantly, so neither is ever a meaningful limit; it +/// only shows up when a stale configuration slips through. The host owns what +/// the sensible default is and passes it in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HttpLimits { + /// Response-body cap, in bytes. + pub max_response_size: usize, + /// Whole-request timeout, in seconds. + pub timeout_secs: u64, +} diff --git a/crates/tinytools-std/src/network/http_request.rs b/crates/tinytools-std/src/network/http_request.rs new file mode 100644 index 0000000..655f070 --- /dev/null +++ b/crates/tinytools-std/src/network/http_request.rs @@ -0,0 +1,429 @@ +use super::gate::{HttpLimits, NetGate, host_of}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; +use tinytools::{PermissionLevel, Tool, ToolResult}; + +use crate::url_guard::{normalize_allowed_domains, validate_url_with_dns_check}; + +/// What a [`PaymentHook`] hands back to retry a `402 Payment Required`. +pub struct PaymentAttempt { + /// Extra request headers that carry the payment (added to the original set). + pub headers: Vec<(String, String)>, + /// Called once with how the retried request came out, so the host can + /// settle whatever ledger entry it opened when it produced `headers`. + pub settle: Box, +} + +impl std::fmt::Debug for PaymentAttempt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PaymentAttempt") + .field("headers", &self.headers.len()) + .finish_non_exhaustive() + } +} + +/// How the request retried with payment headers came out. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOutcome { + /// HTTP status of the retried response. + pub status: u16, + /// Whether that status is a success. + pub success: bool, + /// The retried response's `PAYMENT-RESPONSE` header, verbatim, when it had one. + pub payment_response: Option, +} + +/// A host's answer to a `402 Payment Required` that names a payment scheme. +/// +/// The tool sees the 402, asks the hook to pay, and retries once with the +/// headers it returns. What paying means (a wallet, a ledger, a budget) is +/// entirely the host's. +#[async_trait] +pub trait PaymentHook: std::fmt::Debug + Send + Sync { + /// Produce the payment headers for a 402 from `url`. + /// + /// `response_headers` are the 402 response's headers, which carry the + /// payment requirements. + /// + /// # Errors + /// + /// The message returned to the model as the tool error. + async fn pay( + &self, + url: &str, + response_headers: &reqwest::header::HeaderMap, + ) -> Result; +} + +/// HTTP request tool for API interactions. +/// Supports GET, POST, PUT, DELETE methods with configurable security. +#[derive(Debug)] +pub struct HttpRequestTool { + gate: Arc, + allowed_domains: Vec, + max_response_size: usize, + timeout_secs: u64, + payment: Option>, +} + +impl HttpRequestTool { + /// An `http_request` tool over `gate`. + /// + /// A `0` `max_response_size` or `timeout_secs` falls back to `defaults`. + pub fn new( + gate: Arc, + allowed_domains: Vec, + max_response_size: usize, + timeout_secs: u64, + defaults: HttpLimits, + ) -> Self { + // Treat `0` as "use default": a 0-byte cap or 0-second timeout is never + // a meaningful limit, only a footgun (see migration 5β†’6). Pull the + // fallbacks from `HttpRequestConfig::default()` so the tool, the schema + // default, and the migration share one source and can't drift. A `0` + // here means a stale/invalid config slipped past the migration, so + // surface it with a stable, grep-friendly, non-sensitive log line. + let max_response_size = if max_response_size == 0 { + log::warn!( + "[tool.http_request] coercing invalid limit field=max_response_size \ + from=0 to={} (stale/invalid config β€” see migration 5β†’6)", + defaults.max_response_size + ); + defaults.max_response_size + } else { + max_response_size + }; + let timeout_secs = if timeout_secs == 0 { + log::warn!( + "[tool.http_request] coercing invalid limit field=timeout_secs \ + from=0 to={} (stale/invalid config β€” see migration 5β†’6)", + defaults.timeout_secs + ); + defaults.timeout_secs + } else { + timeout_secs + }; + Self { + gate, + allowed_domains: normalize_allowed_domains(allowed_domains), + max_response_size, + timeout_secs, + payment: None, + } + } + + /// Answer a `402 Payment Required` through `hook` instead of returning it unpaid. + #[must_use] + pub fn with_payment_hook(mut self, hook: Arc) -> Self { + self.payment = Some(hook); + self + } + + async fn validate_url(&self, raw_url: &str) -> anyhow::Result { + validate_url_with_dns_check(raw_url, &self.allowed_domains) + .await + .map(|v| v.url) + } + + fn validate_method(&self, method: &str) -> anyhow::Result { + match method.to_uppercase().as_str() { + "GET" => Ok(reqwest::Method::GET), + "POST" => Ok(reqwest::Method::POST), + "PUT" => Ok(reqwest::Method::PUT), + "DELETE" => Ok(reqwest::Method::DELETE), + "PATCH" => Ok(reqwest::Method::PATCH), + "HEAD" => Ok(reqwest::Method::HEAD), + "OPTIONS" => Ok(reqwest::Method::OPTIONS), + _ => anyhow::bail!( + "Unsupported HTTP method: {method}. Supported: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" + ), + } + } + + fn parse_headers(&self, headers: &serde_json::Value) -> Vec<(String, String)> { + let mut result = Vec::new(); + if let Some(obj) = headers.as_object() { + for (key, value) in obj { + if let Some(str_val) = value.as_str() { + result.push((key.clone(), str_val.to_string())); + } + } + } + result + } + + #[allow(dead_code)] + fn redact_headers_for_display(headers: &[(String, String)]) -> Vec<(String, String)> { + headers + .iter() + .map(|(key, value)| { + let lower = key.to_lowercase(); + let is_sensitive = lower.contains("authorization") + || lower.contains("api-key") + || lower.contains("apikey") + || lower.contains("token") + || lower.contains("secret"); + if is_sensitive { + (key.clone(), "***REDACTED***".into()) + } else { + (key.clone(), value.clone()) + } + }) + .collect() + } + + async fn execute_request( + &self, + url: &str, + method: reqwest::Method, + headers: Vec<(String, String)>, + body: Option<&str>, + ) -> anyhow::Result { + let builder = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .connect_timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()); + let builder = self.gate.prepare_client("tool.http_request", builder); + let client = builder.build()?; + + let mut request = client.request(method, url); + + for (key, value) in headers { + request = request.header(&key, &value); + } + + if let Some(body_str) = body { + request = request.body(body_str.to_string()); + } + + Ok(request.send().await?) + } + + /// Retry a `402 Payment Required` once with the headers `hook` produced. + async fn handle_payment_required( + &self, + hook: &Arc, + initial_response: reqwest::Response, + url: &str, + method: reqwest::Method, + headers: Vec<(String, String)>, + body: Option<&str>, + ) -> Result { + log::debug!( + "[tool.http_request] 402 received with PAYMENT-REQUIRED, attempting x402 payment for {url}" + ); + + let attempt = hook.pay(url, initial_response.headers()).await?; + + let mut retry_headers = headers; + retry_headers.extend(attempt.headers); + + let response = self + .execute_request(url, method, retry_headers, body) + .await + .map_err(|e| format!("x402 retry request failed: {e}"))?; + + (attempt.settle)(PaymentOutcome { + status: response.status().as_u16(), + success: response.status().is_success(), + payment_response: response + .headers() + .get("PAYMENT-RESPONSE") + .and_then(|v| v.to_str().ok()) + .map(str::to_string), + }); + + Ok(response) + } + + async fn format_response(&self, response: reqwest::Response) -> anyhow::Result { + let status = response.status(); + let status_code = status.as_u16(); + + let response_headers = response.headers().iter(); + let headers_text = response_headers + .map(|(k, _)| { + let is_sensitive = k.as_str().to_lowercase().contains("set-cookie"); + if is_sensitive { + format!("{}: ***REDACTED***", k.as_str()) + } else { + format!("{}: {:?}", k.as_str(), k.as_str()) + } + }) + .collect::>() + .join(", "); + + let response_text = match response.text().await { + Ok(text) => self.truncate_response(&text), + Err(e) => format!("[Failed to read response body: {e}]"), + }; + + let output = format!( + "Status: {} {}\nResponse Headers: {}\n\nResponse Body:\n{}", + status_code, + status.canonical_reason().unwrap_or("Unknown"), + headers_text, + response_text + ); + + if status.is_success() { + Ok(ToolResult::success(output)) + } else { + Ok(ToolResult::error(format!("HTTP {status_code}"))) + } + } + + fn truncate_response(&self, text: &str) -> String { + if text.len() > self.max_response_size { + let mut truncated = text + .chars() + .take(self.max_response_size) + .collect::(); + truncated.push_str("\n\n... [Response truncated due to size limit] ..."); + truncated + } else { + text.to_string() + } + } +} + +#[async_trait] +impl Tool for HttpRequestTool { + fn name(&self) -> &'static str { + "http_request" + } + + fn description(&self) -> &'static str { + "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. \ + Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "HTTP or HTTPS URL to request" + }, + "method": { + "type": "string", + "description": "HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)", + "default": "GET" + }, + "headers": { + "type": "object", + "description": "Optional HTTP headers as key-value pairs (e.g., {\"Authorization\": \"Bearer token\", \"Content-Type\": \"application/json\"})", + "default": {} + }, + "body": { + "type": "string", + "description": "Optional request body (for POST, PUT, PATCH requests)" + } + }, + "required": ["url"] + }) + } + + /// Rich HTTP semantics (methods, headers, request bodies, and x402 retry) + /// are the same Network-class risk as `curl`: read-only autonomy is blocked + /// in `execute`, and supervised/full tiers route through ApprovalGate. + fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { + self.gate.network_needs_approval() + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let url = args + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?; + + let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET"); + let headers_val = args.get("headers").cloned().unwrap_or(json!({})); + let body = args.get("body").and_then(|v| v.as_str()); + + if !self.gate.can_act() { + return Ok(ToolResult::error( + "[policy-blocked] Action blocked: autonomy is read-only", + )); + } + + if !self.gate.record_action() { + return Ok(ToolResult::error("Action blocked: rate limit exceeded")); + } + + // Local-only enforcement (privacy epic S7, #4441): mirror the read-only + // `can_act()` deny above β€” under LocalOnly, refuse the outbound request + // before URL validation / DNS so nothing (not even a DNS lookup for the + // host) leaves the device. The post-validation `emit_external_transfer` + // below stays the S2 disclosure point for permitted requests. + { + let host = host_of(url); + if let Some(msg) = self.gate.local_only_block(&host) { + return Ok(ToolResult::error(msg)); + } + } + + let url = match self.validate_url(url).await { + Ok(v) => v, + Err(e) => return Ok(ToolResult::error(e.to_string())), + }; + + // Egress spine (privacy epic S2, #4436): an agent-driven HTTP request to + // an allowlisted host leaves the device β€” disclose the destination and + // everything that rides with it (body + custom headers) before the + // round-trip. + { + let has_headers = headers_val.as_object().is_some_and(|h| !h.is_empty()); + self.gate + .disclose(&host_of(&url), body.is_some(), has_headers); + } + + let method = match self.validate_method(method_str) { + Ok(m) => m, + Err(e) => return Ok(ToolResult::error(e.to_string())), + }; + + let request_headers = self.parse_headers(&headers_val); + + let response = match self + .execute_request(&url, method.clone(), request_headers.clone(), body) + .await + { + Ok(r) => r, + Err(e) => return Ok(ToolResult::error(format!("HTTP request failed: {e}"))), + }; + + // Payment-required: if the server returns 402 with a PAYMENT-REQUIRED + // header and the host installed a hook, attempt to pay and retry. With + // no hook a 402 passes through unpaid. + let response = match &self.payment { + Some(hook) + if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED + && (response.headers().get("PAYMENT-REQUIRED").is_some() + || response.headers().get("X-PAYMENT-REQUIRED").is_some()) => + { + match self + .handle_payment_required(hook, response, &url, method, request_headers, body) + .await + { + Ok(paid_response) => paid_response, + Err(msg) => return Ok(ToolResult::error(msg)), + } + } + _ => response, + }; + + self.format_response(response).await + } +} + +#[cfg(test)] +#[path = "http_request_test.rs"] +mod tests; diff --git a/crates/tinytools-std/src/network/http_request_test.rs b/crates/tinytools-std/src/network/http_request_test.rs new file mode 100644 index 0000000..520cc33 --- /dev/null +++ b/crates/tinytools-std/src/network/http_request_test.rs @@ -0,0 +1,546 @@ +use super::*; +use crate::network::test_support::{AutonomyLevel, DEFAULT_LIMITS, TestNetGate}; + +fn test_tool(allowed_domains: Vec<&str>) -> HttpRequestTool { + let security = TestNetGate::supervised(); + HttpRequestTool::new( + security, + allowed_domains.into_iter().map(String::from).collect(), + 1_000_000, + 30, + DEFAULT_LIMITS, + ) +} + +#[test] +fn zero_limits_fall_back_to_defaults() { + // Stale configs (or a bad write) can pass 0; a 0-second timeout fails + // every request and a 0-byte cap truncates every body. The constructor + // must coerce both to the module defaults β€” never let 0 reach reqwest. + let security = TestNetGate::supervised(); + let tool = HttpRequestTool::new( + security, + vec!["example.com".to_string()], + 0, + 0, + DEFAULT_LIMITS, + ); + let defaults = DEFAULT_LIMITS; + assert_eq!(tool.max_response_size, defaults.max_response_size); + assert_eq!(tool.timeout_secs, defaults.timeout_secs); + assert_ne!(tool.timeout_secs, 0); + assert_ne!(tool.max_response_size, 0); +} + +#[test] +fn nonzero_limits_are_preserved() { + let security = TestNetGate::supervised(); + let tool = HttpRequestTool::new( + security, + vec!["example.com".to_string()], + 2048, + 12, + DEFAULT_LIMITS, + ); + assert_eq!(tool.max_response_size, 2048); + assert_eq!(tool.timeout_secs, 12); +} + +#[test] +fn validate_accepts_valid_methods() { + let tool = test_tool(vec!["example.com"]); + assert!(tool.validate_method("GET").is_ok()); + assert!(tool.validate_method("POST").is_ok()); + assert!(tool.validate_method("PUT").is_ok()); + assert!(tool.validate_method("DELETE").is_ok()); + assert!(tool.validate_method("PATCH").is_ok()); + assert!(tool.validate_method("HEAD").is_ok()); + assert!(tool.validate_method("OPTIONS").is_ok()); +} + +#[test] +fn validate_rejects_invalid_method() { + let tool = test_tool(vec!["example.com"]); + let err = tool.validate_method("INVALID").unwrap_err().to_string(); + assert!(err.contains("Unsupported HTTP method")); +} + +#[tokio::test] +async fn validate_url_rejects_disallowed_domain() { + let tool = test_tool(vec!["example.com"]); + let err = tool + .validate_url("https://evil.test/path") + .await + .unwrap_err() + .to_string(); + assert!(err.contains("allowed websites")); +} + +#[tokio::test] +async fn execute_blocks_readonly_mode() { + let security = TestNetGate::with(AutonomyLevel::ReadOnly, 100); + let tool = HttpRequestTool::new( + security, + vec!["example.com".into()], + 1_000_000, + 30, + DEFAULT_LIMITS, + ); + let result = tool + .execute(json!({"url": "https://example.com"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("read-only")); +} + +#[tokio::test] +async fn execute_blocks_when_rate_limited() { + let security = TestNetGate::with(AutonomyLevel::Supervised, 0); + let tool = HttpRequestTool::new( + security, + vec!["example.com".into()], + 1_000_000, + 30, + DEFAULT_LIMITS, + ); + let result = tool + .execute(json!({"url": "https://example.com"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("rate limit")); +} + +#[tokio::test] +async fn execute_blocked_under_local_only_privacy_mode() { + // Privacy epic S7 (#4441): under LocalOnly the request is refused with a + // `[policy-blocked]` result before URL validation / network. + let tool = HttpRequestTool::new( + TestNetGate::local_only(), + vec!["example.com".into()], + 1_000_000, + 30, + DEFAULT_LIMITS, + ); + let result = tool + .execute(json!({"url": "https://example.com"})) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result.output().contains("[policy-blocked]"), + "got: {}", + result.output() + ); + assert!( + result.output().contains("Local-only"), + "got: {}", + result.output() + ); +} + +#[test] +fn truncate_response_within_limit() { + let tool = test_tool(vec!["example.com"]); + let text = "hello world"; + assert_eq!(tool.truncate_response(text), "hello world"); +} + +#[test] +fn truncate_response_over_limit() { + let tool = HttpRequestTool::new( + TestNetGate::supervised(), + vec!["example.com".into()], + 10, + 30, + DEFAULT_LIMITS, + ); + let text = "hello world this is long"; + let truncated = tool.truncate_response(text); + assert!(truncated.len() <= 10 + 60); + assert!(truncated.contains("[Response truncated")); +} + +#[test] +fn parse_headers_preserves_original_values() { + let tool = test_tool(vec!["example.com"]); + let headers = json!({ + "Authorization": "Bearer secret", + "Content-Type": "application/json", + "X-API-Key": "my-key" + }); + let parsed = tool.parse_headers(&headers); + assert_eq!(parsed.len(), 3); + assert!( + parsed + .iter() + .any(|(k, v)| k == "Authorization" && v == "Bearer secret") + ); + assert!( + parsed + .iter() + .any(|(k, v)| k == "X-API-Key" && v == "my-key") + ); + assert!( + parsed + .iter() + .any(|(k, v)| k == "Content-Type" && v == "application/json") + ); +} + +#[test] +fn redact_headers_for_display_redacts_sensitive() { + let headers = vec![ + ("Authorization".into(), "Bearer secret".into()), + ("Content-Type".into(), "application/json".into()), + ("X-API-Key".into(), "my-key".into()), + ("X-Secret-Token".into(), "tok-123".into()), + ]; + let redacted = HttpRequestTool::redact_headers_for_display(&headers); + assert_eq!(redacted.len(), 4); + assert!( + redacted + .iter() + .any(|(k, v)| k == "Authorization" && v == "***REDACTED***") + ); + assert!( + redacted + .iter() + .any(|(k, v)| k == "X-API-Key" && v == "***REDACTED***") + ); + assert!( + redacted + .iter() + .any(|(k, v)| k == "X-Secret-Token" && v == "***REDACTED***") + ); + assert!( + redacted + .iter() + .any(|(k, v)| k == "Content-Type" && v == "application/json") + ); +} + +#[test] +fn redact_headers_does_not_alter_original() { + let headers = vec![("Authorization".into(), "Bearer real-token".into())]; + let _ = HttpRequestTool::redact_headers_for_display(&headers); + assert_eq!(headers[0].1, "Bearer real-token"); +} + +#[test] +fn redirect_policy_is_none() { + let tool = test_tool(vec!["example.com"]); + assert_eq!(tool.name(), "http_request"); +} + +#[test] +fn supervised_http_request_is_external_effect_for_approval_gate() { + let tool = test_tool(vec!["example.com"]); + assert_eq!(tool.permission_level(), PermissionLevel::Write); + assert!(tool.external_effect_with_args(&json!({ + "url": "https://example.com/api", + "method": "POST", + "headers": { "Authorization": "Bearer token" }, + "body": "{}" + }))); +} + +#[test] +fn readonly_http_request_is_not_external_effect_because_execute_blocks() { + let security = TestNetGate::with(AutonomyLevel::ReadOnly, 100); + let tool = HttpRequestTool::new( + security, + vec!["example.com".into()], + 1_000_000, + 30, + DEFAULT_LIMITS, + ); + assert!(!tool.external_effect_with_args(&json!({ + "url": "https://example.com/api", + "method": "GET" + }))); +} + +#[tokio::test] +async fn a_request_discloses_body_and_header_presence_to_the_gate() { + // A public IPv4 literal skips DNS; the disallowed method fails after + // disclosure, so nothing is ever contacted. + let gate = TestNetGate::supervised(); + let tool = HttpRequestTool::new( + gate.clone(), + vec!["8.8.8.8".into()], + 1_000_000, + 30, + DEFAULT_LIMITS, + ); + let result = tool + .execute(json!({ + "url": "https://8.8.8.8/x", + "method": "TRACE", + "headers": {"Authorization": "Bearer t"}, + "body": "{}" + })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Unsupported HTTP method")); + assert_eq!(gate.disclosed(), vec![("8.8.8.8".to_string(), true, true)]); +} + +#[tokio::test] +async fn a_bare_request_discloses_neither_body_nor_headers() { + let gate = TestNetGate::supervised(); + let tool = HttpRequestTool::new( + gate.clone(), + vec!["8.8.8.8".into()], + 1_000_000, + 30, + DEFAULT_LIMITS, + ); + let _ = tool + .execute(json!({"url": "https://8.8.8.8/x", "method": "TRACE"})) + .await + .unwrap(); + assert_eq!( + gate.disclosed(), + vec![("8.8.8.8".to_string(), false, false)] + ); +} + +#[test] +fn the_host_of_an_unparseable_url_is_unknown() { + assert_eq!(super::super::gate::host_of("not a url"), "unknown"); + assert_eq!( + super::super::gate::host_of("https://api.example.com/v1"), + "api.example.com" + ); +} + +/// Serves one canned HTTP response per accepted connection, in order, and +/// records each request's raw head. Returns the bound address. +async fn serve( + responses: Vec, +) -> (std::net::SocketAddr, Arc>>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let log = Arc::clone(&seen); + tokio::spawn(async move { + for response in responses { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 8192]; + let n = socket.read(&mut buf).await.unwrap(); + log.lock() + .unwrap() + .push(String::from_utf8_lossy(&buf[..n]).to_string()); + socket.write_all(response.as_bytes()).await.unwrap(); + let _ = socket.shutdown().await; + } + }); + (addr, seen) +} + +#[derive(Debug)] +struct RecordingHook { + settled: Arc>>, + fail: bool, +} + +#[async_trait] +impl PaymentHook for RecordingHook { + async fn pay( + &self, + _url: &str, + response_headers: &reqwest::header::HeaderMap, + ) -> Result { + if self.fail { + return Err("x402 payment failed: no wallet".into()); + } + assert!(response_headers.get("PAYMENT-REQUIRED").is_some()); + let settled = Arc::clone(&self.settled); + Ok(PaymentAttempt { + headers: vec![("PAYMENT-SIGNATURE".into(), "sig".into())], + settle: Box::new(move |outcome| settled.lock().unwrap().push(outcome)), + }) + } +} + +const PAYMENT_REQUIRED: &str = "HTTP/1.1 402 Payment Required\r\nPAYMENT-REQUIRED: abc\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + +// Loopback is refused by the SSRF guard, so these drive the private request +// path directly rather than `execute`. +#[tokio::test] +async fn a_402_is_retried_once_with_the_hooks_headers_and_settled() { + let ok = "HTTP/1.1 200 OK\r\nPAYMENT-RESPONSE: resp\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; + let (addr, seen) = serve(vec![PAYMENT_REQUIRED.to_string(), ok.to_string()]).await; + let settled = Arc::new(std::sync::Mutex::new(Vec::new())); + let hook: Arc = Arc::new(RecordingHook { + settled: Arc::clone(&settled), + fail: false, + }); + let tool = test_tool(vec![]); + let url = format!("http://{addr}/paid"); + let first = tool + .execute_request( + &url, + reqwest::Method::GET, + vec![("X-A".into(), "1".into())], + None, + ) + .await + .unwrap(); + assert_eq!(first.status(), reqwest::StatusCode::PAYMENT_REQUIRED); + + let paid = tool + .handle_payment_required( + &hook, + first, + &url, + reqwest::Method::GET, + vec![("X-A".into(), "1".into())], + None, + ) + .await + .unwrap(); + assert!(paid.status().is_success()); + + let requests = seen.lock().unwrap().clone(); + assert_eq!(requests.len(), 2); + assert!( + !requests[0] + .to_ascii_lowercase() + .contains("payment-signature") + ); + assert!( + requests[1] + .to_ascii_lowercase() + .contains("payment-signature: sig") + ); + assert!(requests[1].to_ascii_lowercase().contains("x-a: 1")); + assert_eq!( + *settled.lock().unwrap(), + vec![PaymentOutcome { + status: 200, + success: true, + payment_response: Some("resp".into()), + }] + ); +} + +#[tokio::test] +async fn a_hook_failure_is_returned_as_the_error_without_a_retry() { + let (addr, seen) = serve(vec![PAYMENT_REQUIRED.to_string()]).await; + let hook: Arc = Arc::new(RecordingHook { + settled: Arc::default(), + fail: true, + }); + let tool = test_tool(vec![]); + let url = format!("http://{addr}/paid"); + let first = tool + .execute_request(&url, reqwest::Method::GET, vec![], None) + .await + .unwrap(); + let err = tool + .handle_payment_required(&hook, first, &url, reqwest::Method::GET, vec![], None) + .await + .unwrap_err(); + assert_eq!(err, "x402 payment failed: no wallet"); + assert_eq!(seen.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn a_body_is_sent_and_a_success_response_is_formatted_with_redacted_cookies() { + let ok = "HTTP/1.1 200 OK\r\nSet-Cookie: session=abc\r\nX-Other: 1\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello"; + let (addr, seen) = serve(vec![ok.to_string()]).await; + let tool = test_tool(vec![]); + let response = tool + .execute_request( + &format!("http://{addr}/post"), + reqwest::Method::POST, + vec![], + Some("payload"), + ) + .await + .unwrap(); + let result = tool.format_response(response).await.unwrap(); + assert!(!result.is_error); + let text = result.text(); + assert!(text.contains("hello"), "{text}"); + assert!(text.contains("set-cookie: ***REDACTED***"), "{text}"); + assert!(!text.contains("session=abc"), "{text}"); + assert!(seen.lock().unwrap()[0].ends_with("payload")); +} + +#[tokio::test] +async fn a_non_success_response_is_reported_as_an_error() { + let (addr, _) = serve(vec![ + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_string(), + ]) + .await; + let tool = test_tool(vec![]); + let response = tool + .execute_request( + &format!("http://{addr}/"), + reqwest::Method::GET, + vec![], + None, + ) + .await + .unwrap(); + let result = tool.format_response(response).await.unwrap(); + assert!(result.is_error); + assert!(result.text().contains("HTTP 500")); +} + +#[tokio::test] +async fn an_unreadable_body_is_reported_inline() { + // Promise more bytes than are sent, then close: reading the body fails. + let (addr, _) = serve(vec![ + "HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort".to_string(), + ]) + .await; + let tool = test_tool(vec![]); + let response = tool + .execute_request( + &format!("http://{addr}/"), + reqwest::Method::GET, + vec![], + None, + ) + .await + .unwrap(); + let result = tool.format_response(response).await.unwrap(); + assert!(result.text().contains("Failed to read response body")); +} + +#[tokio::test] +async fn a_missing_url_is_an_error() { + let tool = test_tool(vec![]); + let err = tool.execute(json!({})).await.unwrap_err(); + assert!(err.to_string().contains("Missing 'url'")); +} + +#[test] +fn a_payment_hook_can_be_installed_and_attempts_debug_without_headers() { + let hook: Arc = Arc::new(RecordingHook { + settled: Arc::default(), + fail: false, + }); + let tool = test_tool(vec![]).with_payment_hook(hook); + assert!(tool.payment.is_some()); + let attempt = PaymentAttempt { + headers: vec![("PAYMENT-SIGNATURE".into(), "secret".into())], + settle: Box::new(|_| {}), + }; + let shown = format!("{attempt:?}"); + assert!(shown.contains("PaymentAttempt") && !shown.contains("secret")); +} + +#[test] +fn the_test_gate_builds_a_client_with_the_requested_timeouts() { + let gate = TestNetGate::supervised(); + let _client = gate.timeout_client("svc", 5, 2); +} diff --git a/crates/tinytools-std/src/network/mod.rs b/crates/tinytools-std/src/network/mod.rs new file mode 100644 index 0000000..5453dcc --- /dev/null +++ b/crates/tinytools-std/src/network/mod.rs @@ -0,0 +1,49 @@ +//! Network tools: `http_request`, `web_fetch`, `curl` and `pushover`. +//! +//! Every tool takes an `Arc`: the tool performs the I/O, the +//! host's gate decides whether it may (autonomy, action budget, approval, +//! privacy mode) and how the process-wide proxy is applied. The gate trait +//! ([`NetGate`]) is the whole seam; nothing here knows what an autonomy level, +//! an approval prompt or an egress descriptor is. URL allowlisting and SSRF +//! checks are [`crate::url_guard`]'s. +//! +//! Two more seams keep host-specific behavior out of the tools: a +//! [`PaymentHook`] answers a `402 Payment Required` for `http_request`, and an +//! [`HtmlExtractor`] turns pages into Markdown for `web_fetch`. + +// These tools moved here verbatim from a host crate; their behavior, messages +// and control flow are pinned by tests and by the fixtures in `fixtures/`, so +// the purely stylistic pedantic lints below are allowed rather than reshaping +// working code (long `execute` bodies, `usize as f64` size labels, and so on). +#![allow( + clippy::too_many_lines, + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::similar_names, + clippy::used_underscore_binding, + clippy::manual_let_else, + clippy::format_push_string, + clippy::unwrap_used, + clippy::unused_self, + clippy::unused_async, + clippy::needless_pass_by_value, + clippy::doc_markdown, + clippy::unnecessary_wraps +)] + +mod curl; +mod gate; +mod http_request; +mod pushover; +mod web_fetch; + +#[cfg(test)] +mod contract_test; +#[cfg(test)] +mod test_support; + +pub use curl::CurlTool; +pub use gate::{HttpLimits, NetGate}; +pub use http_request::{HttpRequestTool, PaymentAttempt, PaymentHook, PaymentOutcome}; +pub use pushover::PushoverTool; +pub use web_fetch::{HtmlExtractor, WebFetchTool}; diff --git a/crates/tinytools-std/src/network/pushover.rs b/crates/tinytools-std/src/network/pushover.rs new file mode 100644 index 0000000..7e331ba --- /dev/null +++ b/crates/tinytools-std/src/network/pushover.rs @@ -0,0 +1,217 @@ +use super::gate::NetGate; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; +use std::sync::Arc; +use tinytools::{Tool, ToolResult}; + +const PUSHOVER_API_URL: &str = "https://api.pushover.net/1/messages.json"; +const PUSHOVER_REQUEST_TIMEOUT_SECS: u64 = 15; + +/// Sends a Pushover notification using credentials from the workspace `.env`. +#[derive(Debug)] +pub struct PushoverTool { + gate: Arc, + workspace_dir: PathBuf, +} + +impl PushoverTool { + /// A `pushover` tool over `gate`, reading `/.env`. + pub fn new(gate: Arc, workspace_dir: PathBuf) -> Self { + Self { + gate, + workspace_dir, + } + } + + fn parse_env_value(raw: &str) -> String { + let raw = raw.trim(); + + let unquoted = if raw.len() >= 2 + && ((raw.starts_with('"') && raw.ends_with('"')) + || (raw.starts_with('\'') && raw.ends_with('\''))) + { + &raw[1..raw.len() - 1] + } else { + raw + }; + + // Keep support for inline comments in unquoted values: + // KEY=value # comment + unquoted.split_once(" #").map_or_else( + || unquoted.trim().to_string(), + |(value, _)| value.trim().to_string(), + ) + } + + async fn get_credentials(&self) -> anyhow::Result<(String, String)> { + let env_path = self.workspace_dir.join(".env"); + let content = tokio::fs::read_to_string(&env_path) + .await + .map_err(|e| anyhow::anyhow!("Failed to read {}: {}", env_path.display(), e))?; + + let mut token = None; + let mut user_key = None; + + for line in content.lines() { + let line = line.trim(); + if line.starts_with('#') || line.is_empty() { + continue; + } + let line = line.strip_prefix("export ").map_or(line, str::trim); + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + let value = Self::parse_env_value(value); + + if key.eq_ignore_ascii_case("PUSHOVER_TOKEN") { + token = Some(value); + } else if key.eq_ignore_ascii_case("PUSHOVER_USER_KEY") { + user_key = Some(value); + } + } + } + + let token = token.ok_or_else(|| anyhow::anyhow!("PUSHOVER_TOKEN not found in .env"))?; + let user_key = + user_key.ok_or_else(|| anyhow::anyhow!("PUSHOVER_USER_KEY not found in .env"))?; + + Ok((token, user_key)) + } +} + +#[async_trait] +impl Tool for PushoverTool { + fn name(&self) -> &'static str { + "pushover" + } + + fn description(&self) -> &'static str { + "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The notification message to send" + }, + "title": { + "type": "string", + "description": "Optional notification title" + }, + "priority": { + "type": "integer", + "description": "Message priority: -2 (lowest/silent), -1 (low/no sound), 0 (normal), 1 (high), 2 (emergency/repeating)" + }, + "sound": { + "type": "string", + "description": "Notification sound override (e.g., 'pushover', 'bike', 'bugle', 'cashregister', etc.)" + } + }, + "required": ["message"] + }) + } + + // Intentionally NOT marked external_effect=true in v1. + // + // `execute()` below already enforces local policy via + // `gate.can_act()` (read-only autonomy block) and + // `gate.record_action()` (rate limit). The gate runs + // BEFORE `execute()`, so flipping external_effect would prompt + // the user even for calls that the policy will immediately + // reject β€” a dead-end approval flow. Leaving this tool at the + // trait default (false) means the local policy stays the + // single approval surface until the gate learns to consult + // policy before parking. + // + // Follow-up #1339-v3: thread the SecurityPolicy into the gate + // pre-check so external_effect can stay true here without + // generating ghost approvals. + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + if !self.gate.can_act() { + return Ok(ToolResult::error( + "[policy-blocked] Action blocked: autonomy is read-only", + )); + } + + if !self.gate.record_action() { + return Ok(ToolResult::error("Action blocked: rate limit exceeded")); + } + + let message = args + .get("message") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))? + .to_string(); + + let title = args.get("title").and_then(|v| v.as_str()).map(String::from); + + let priority = match args.get("priority").and_then(serde_json::Value::as_i64) { + Some(value) if (-2..=2).contains(&value) => Some(value), + Some(value) => { + return Ok(ToolResult::error(format!( + "Invalid 'priority': {value}. Expected integer in range -2..=2" + ))); + } + None => None, + }; + + let sound = args.get("sound").and_then(|v| v.as_str()).map(String::from); + + let (token, user_key) = self.get_credentials().await?; + + let mut form = reqwest::multipart::Form::new() + .text("token", token) + .text("user", user_key) + .text("message", message); + + if let Some(title) = title { + form = form.text("title", title); + } + + if let Some(priority) = priority { + form = form.text("priority", priority.to_string()); + } + + if let Some(sound) = sound { + form = form.text("sound", sound); + } + + let client = self + .gate + .timeout_client("tool.pushover", PUSHOVER_REQUEST_TIMEOUT_SECS, 10); + let response = client.post(PUSHOVER_API_URL).multipart(form).send().await?; + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if !status.is_success() { + return Ok(ToolResult::error(format!( + "Pushover API returned status {status}" + ))); + } + + let api_status = serde_json::from_str::(&body) + .ok() + .and_then(|json| json.get("status").and_then(serde_json::Value::as_i64)); + + if api_status == Some(1) { + Ok(ToolResult::success(format!( + "Pushover notification sent successfully. Response: {body}" + ))) + } else { + Ok(ToolResult::error( + "Pushover API returned an application-level error", + )) + } + } +} + +#[cfg(test)] +#[path = "pushover_test.rs"] +mod tests; diff --git a/crates/tinytools-std/src/network/pushover_test.rs b/crates/tinytools-std/src/network/pushover_test.rs new file mode 100644 index 0000000..29a1764 --- /dev/null +++ b/crates/tinytools-std/src/network/pushover_test.rs @@ -0,0 +1,209 @@ +use super::*; +use crate::network::test_support::{AutonomyLevel, TestNetGate}; +use std::fs; +use tempfile::TempDir; + +fn test_security(level: AutonomyLevel, max_actions_per_hour: usize) -> Arc { + TestNetGate::with(level, max_actions_per_hour) +} + +#[test] +fn pushover_tool_name() { + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + PathBuf::from("/tmp"), + ); + assert_eq!(tool.name(), "pushover"); +} + +#[test] +fn pushover_tool_description() { + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + PathBuf::from("/tmp"), + ); + assert!(!tool.description().is_empty()); +} + +#[test] +fn pushover_tool_has_parameters_schema() { + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + PathBuf::from("/tmp"), + ); + let schema = tool.parameters_schema(); + assert_eq!(schema["type"], "object"); + assert!(schema["properties"].get("message").is_some()); +} + +#[test] +fn pushover_tool_requires_message() { + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + PathBuf::from("/tmp"), + ); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&serde_json::Value::String("message".to_string()))); +} + +#[tokio::test] +async fn credentials_parsed_from_env_file() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write( + &env_path, + "PUSHOVER_TOKEN=testtoken123\nPUSHOVER_USER_KEY=userkey456\n", + ) + .unwrap(); + + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + tmp.path().to_path_buf(), + ); + let result = tool.get_credentials().await; + + assert!(result.is_ok()); + let (token, user_key) = result.unwrap(); + assert_eq!(token, "testtoken123"); + assert_eq!(user_key, "userkey456"); +} + +#[tokio::test] +async fn credentials_fail_without_env_file() { + let tmp = TempDir::new().unwrap(); + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + tmp.path().to_path_buf(), + ); + let result = tool.get_credentials().await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn credentials_fail_without_token() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write(&env_path, "PUSHOVER_USER_KEY=userkey456\n").unwrap(); + + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + tmp.path().to_path_buf(), + ); + let result = tool.get_credentials().await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn credentials_fail_without_user_key() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write(&env_path, "PUSHOVER_TOKEN=testtoken123\n").unwrap(); + + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + tmp.path().to_path_buf(), + ); + let result = tool.get_credentials().await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn credentials_ignore_comments() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write(&env_path, "# This is a comment\nPUSHOVER_TOKEN=realtoken\n# Another comment\nPUSHOVER_USER_KEY=realuser\n").unwrap(); + + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + tmp.path().to_path_buf(), + ); + let result = tool.get_credentials().await; + + assert!(result.is_ok()); + let (token, user_key) = result.unwrap(); + assert_eq!(token, "realtoken"); + assert_eq!(user_key, "realuser"); +} + +#[test] +fn pushover_tool_supports_priority() { + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + PathBuf::from("/tmp"), + ); + let schema = tool.parameters_schema(); + assert!(schema["properties"].get("priority").is_some()); +} + +#[test] +fn pushover_tool_supports_sound() { + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + PathBuf::from("/tmp"), + ); + let schema = tool.parameters_schema(); + assert!(schema["properties"].get("sound").is_some()); +} + +#[tokio::test] +async fn credentials_support_export_and_quoted_values() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write( + &env_path, + "export PUSHOVER_TOKEN=\"quotedtoken\"\nPUSHOVER_USER_KEY='quoteduser'\n", + ) + .unwrap(); + + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + tmp.path().to_path_buf(), + ); + let result = tool.get_credentials().await; + + assert!(result.is_ok()); + let (token, user_key) = result.unwrap(); + assert_eq!(token, "quotedtoken"); + assert_eq!(user_key, "quoteduser"); +} + +#[tokio::test] +async fn execute_blocks_readonly_mode() { + let tool = PushoverTool::new( + test_security(AutonomyLevel::ReadOnly, 100), + PathBuf::from("/tmp"), + ); + + let result = tool.execute(json!({"message": "hello"})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("read-only")); +} + +#[tokio::test] +async fn execute_blocks_rate_limit() { + let tool = PushoverTool::new(test_security(AutonomyLevel::Full, 0), PathBuf::from("/tmp")); + + let result = tool.execute(json!({"message": "hello"})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("rate limit")); +} + +#[tokio::test] +async fn execute_rejects_priority_out_of_range() { + let tool = PushoverTool::new( + test_security(AutonomyLevel::Full, 100), + PathBuf::from("/tmp"), + ); + + let result = tool + .execute(json!({"message": "hello", "priority": 5})) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("-2..=2")); +} diff --git a/crates/tinytools-std/src/network/test_support.rs b/crates/tinytools-std/src/network/test_support.rs new file mode 100644 index 0000000..9db6387 --- /dev/null +++ b/crates/tinytools-std/src/network/test_support.rs @@ -0,0 +1,161 @@ +//! A small, self-contained [`NetGate`] for the network tools' unit tests. +//! +//! It models just enough of a host policy to exercise the tools: an autonomy +//! tier, an action budget, an approval flag, a local-only switch and a record +//! of what the tool disclosed. It is *not* a copy of any host's policy; the +//! host's own semantics are tested in the host, through its adapter. + +#![allow( + clippy::expect_used, + clippy::unwrap_used, + missing_docs, + unreachable_pub +)] + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use super::gate::{HttpLimits, NetGate}; +use super::web_fetch::HtmlExtractor; + +/// Prefix the fake puts on refusals a real host would mark as policy-blocked. +pub const POLICY_BLOCKED_MARKER: &str = "[policy-blocked]"; + +/// Autonomy tier of the fake gate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AutonomyLevel { + /// Nothing may run. + ReadOnly, + /// Outbound requests ask for approval. + Supervised, + /// Outbound requests run unprompted. + Full, +} + +/// The defaults the tests hand to constructors that take [`HttpLimits`]. +pub const DEFAULT_LIMITS: HttpLimits = HttpLimits { + max_response_size: 1_000_000, + timeout_secs: 30, +}; + +/// A fake host policy. +#[derive(Debug)] +pub struct TestNetGate { + autonomy: AutonomyLevel, + max_actions: usize, + used: AtomicUsize, + local_only: bool, + disclosed: Mutex>, +} + +impl TestNetGate { + /// Supervised gate with an effectively unlimited budget. + pub fn supervised() -> Arc { + Self::with(AutonomyLevel::Supervised, 1_000_000) + } + + /// Gate with an explicit tier and action budget. + pub fn with(autonomy: AutonomyLevel, max_actions: usize) -> Arc { + Arc::new(Self { + autonomy, + max_actions, + used: AtomicUsize::new(0), + local_only: false, + disclosed: Mutex::new(Vec::new()), + }) + } + + /// Supervised gate whose privacy mode refuses every outbound request. + pub fn local_only() -> Arc { + Arc::new(Self { + autonomy: AutonomyLevel::Supervised, + max_actions: 1_000_000, + used: AtomicUsize::new(0), + local_only: true, + disclosed: Mutex::new(Vec::new()), + }) + } + + /// Everything the tool has disclosed so far: `(host, has_body, has_headers)`. + pub fn disclosed(&self) -> Vec<(String, bool, bool)> { + self.disclosed.lock().unwrap().clone() + } +} + +impl NetGate for TestNetGate { + fn can_act(&self) -> bool { + self.autonomy != AutonomyLevel::ReadOnly + } + + fn is_rate_limited(&self) -> bool { + self.used.load(Ordering::SeqCst) >= self.max_actions + } + + fn record_action(&self) -> bool { + self.used.fetch_add(1, Ordering::SeqCst) < self.max_actions + } + + fn network_needs_approval(&self) -> bool { + self.autonomy == AutonomyLevel::Supervised + } + + fn local_only_block(&self, host: &str) -> Option { + self.local_only.then(|| { + format!("{POLICY_BLOCKED_MARKER} Local-only privacy mode blocks contacting {host}.") + }) + } + + fn disclose(&self, host: &str, has_body: bool, has_headers: bool) { + self.disclosed + .lock() + .unwrap() + .push((host.to_string(), has_body, has_headers)); + } + + fn prepare_client( + &self, + _service: &str, + builder: reqwest::ClientBuilder, + ) -> reqwest::ClientBuilder { + builder + } + + fn timeout_client( + &self, + _service: &str, + timeout_secs: u64, + connect_timeout_secs: u64, + ) -> reqwest::Client { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs)) + .build() + .unwrap_or_default() + } +} + +/// An [`HtmlExtractor`] that recognises ` bool { + let head = body.trim_start().to_ascii_lowercase(); + head.starts_with(" String { + let mut out = String::new(); + let mut in_tag = false; + for ch in html.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + c if !in_tag => out.push(c), + _ => {} + } + } + out + } +} diff --git a/crates/tinytools-std/src/network/web_fetch.rs b/crates/tinytools-std/src/network/web_fetch.rs new file mode 100644 index 0000000..db08053 --- /dev/null +++ b/crates/tinytools-std/src/network/web_fetch.rs @@ -0,0 +1,337 @@ +//! `web_fetch` β€” fetch a URL and return its text body. +//! +//! Coding-harness baseline tool (issue #1205). Distinct from +//! `http_request` (full method/header surface) and `curl` (writes to +//! disk). `web_fetch` is the single-purpose "GET and read" primitive +//! the agent reaches for when researching: returns the response body +//! as text, capped, with a tiny preamble (status + final URL). + +use super::gate::{HttpLimits, NetGate, host_of}; +use crate::url_guard::{normalize_allowed_domains, validate_url_with_dns_check}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; +use tinytools::{PermissionLevel, Tool, ToolResult}; + +/// How `web_fetch` turns an HTML page into something a model should read. +/// +/// Content transforms are somebody else's specialty (the host wires in +/// whichever engine it has), so the tool asks instead of owning one. +pub trait HtmlExtractor: std::fmt::Debug + Send + Sync { + /// Whether `body`, which arrived with no usable `Content-Type`, is HTML. + fn looks_like_html(&self, body: &str) -> bool; + + /// Convert an HTML document to Markdown (links kept, scripts dropped). + fn to_markdown(&self, html: &str) -> String; +} + +/// Fetches a URL and returns its text body. +#[derive(Debug)] +pub struct WebFetchTool { + gate: Arc, + allowed_domains: Vec, + max_bytes: usize, + timeout_secs: u64, + html: Arc, + extra_properties: Vec<(String, serde_json::Value)>, +} + +impl WebFetchTool { + /// A `web_fetch` tool over `gate`. + /// + /// Both `None` and `Some(0)` for the limits mean "use `defaults`". + pub fn new( + gate: Arc, + allowed_domains: Vec, + max_bytes: Option, + timeout_secs: Option, + defaults: HttpLimits, + html: Arc, + ) -> Self { + // Treat both `None` and `Some(0)` as "use default": callers wire these + // from `[http_request]`, and a 0-byte cap truncates every body to + // nothing while a 0-second timeout fails every request instantly. + // Stale-zero configs are repaired on load (migration 5β†’6); this clamp + // is the always-on guard at the point of use. The fallbacks come from + // the host's `defaults` so the tool shares one source with the schema + + // migration (no cross-layer drift). `Some(0)` is a genuine + // misconfiguration, so log it (grep-friendly, no payload); a bare + // `None` is a normal "use default" call and stays quiet. + let max_bytes = match max_bytes { + Some(0) => { + log::warn!( + "[tool.web_fetch] coercing invalid limit field=max_bytes \ + from=0 to={} (stale/invalid config β€” see migration 5β†’6)", + defaults.max_response_size + ); + defaults.max_response_size + } + Some(n) => n, + None => defaults.max_response_size, + }; + let timeout_secs = match timeout_secs { + Some(0) => { + log::warn!( + "[tool.web_fetch] coercing invalid limit field=timeout_secs \ + from=0 to={} (stale/invalid config β€” see migration 5β†’6)", + defaults.timeout_secs + ); + defaults.timeout_secs + } + Some(n) => n, + None => defaults.timeout_secs, + }; + Self { + gate, + allowed_domains: normalize_allowed_domains(allowed_domains), + max_bytes, + timeout_secs, + html, + extra_properties: Vec::new(), + } + } + + /// Add a property to the advertised parameter schema (not `required`). + /// + /// The host uses this to opt the tool into arguments it interprets itself + /// before the call reaches the tool. + #[must_use] + pub fn with_schema_property(mut self, name: &str, schema: serde_json::Value) -> Self { + self.extra_properties.push((name.to_string(), schema)); + self + } +} + +#[async_trait] +impl Tool for WebFetchTool { + fn name(&self) -> &'static str { + "web_fetch" + } + + fn description(&self) -> &'static str { + "GET a URL and read the page. HTML returns as Markdown (links kept, \ + scripts dropped); `raw: true` for the body as sent. For POST or \ + custom headers use `http_request`." + } + + fn parameters_schema(&self) -> serde_json::Value { + let mut schema = json!({ + "type": "object", + "properties": { + "url": { "type": "string", "description": "Absolute http(s) URL." }, + "max_bytes": { + "type": "integer", + "description": "Truncate body at this many bytes (default 1_000_000).", + "minimum": 1 + }, + "raw": { + "type": "boolean", + "description": "Return the body as sent." + } + }, + "required": ["url"] + }); + if let Some(properties) = schema["properties"].as_object_mut() { + for (name, property) in &self.extra_properties { + properties.insert(name.clone(), property.clone()); + } + } + schema + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + /// Idempotent GET β€” safe to fan out across parallel `web_fetch` + /// calls. Targets that throttle aggressively are the user's + /// concern; we don't try to second-guess at the tool layer. + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + + /// How much of a page reaches the model in one result. + /// + /// Extraction does most of the work β€” after HTMLβ†’Markdown a long + /// documentation page is usually a few thousand chars β€” so this + /// bites only on genuinely large documents. What it no longer does + /// is throw the remainder away: `ToolOutputMiddleware` spills the + /// full extracted page to an artifact and returns the `file_read` + /// call that pages it, which is what this comment used to + /// recommend while the cap itself disabled the affordance. + /// + /// 24k rather than the old 50k because the point of reference + /// moved: 50k was a bound on raw markup, this is clean Markdown. + /// Hermes budgets 15,000 chars of extracted text for the same job; + /// Codex caps every tool result at ~10,000 tokens. + fn max_result_size_chars(&self) -> Option { + Some(24_000) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let raw_url = args + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?; + let max_bytes = args + .get("max_bytes") + .and_then(serde_json::Value::as_u64) + .map_or(self.max_bytes, |n| (n as usize).max(1)); + let raw_requested = args + .get("raw") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + if self.gate.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.gate.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + // Local-only enforcement (privacy epic S7, #4441): refuse the fetch under + // LocalOnly before URL validation / DNS. The post-validation + // `emit_external_transfer` below stays the S2 disclosure point. + { + let host = host_of(raw_url); + if let Some(msg) = self.gate.local_only_block(&host) { + return Ok(ToolResult::error(msg)); + } + } + + let url = match validate_url_with_dns_check(raw_url, &self.allowed_domains).await { + Ok(u) => u.url, + Err(e) => return Ok(ToolResult::error(format!("URL rejected: {e}"))), + }; + + // Egress spine (privacy epic S2, #4436): disclose the fetch destination + // before contacting the host. + self.gate.disclose(&host_of(&url), false, false); + + // Disable automatic redirect following: reqwest follows up to 10 + // redirects by default, and a redirect target may be on a host + // outside the allowed-domains list. We surface 3xx responses to + // the caller so they can decide whether to refetch the new URL. + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .redirect(reqwest::redirect::Policy::none()) + .build() + { + Ok(c) => c, + Err(e) => return Ok(ToolResult::error(format!("Failed to build client: {e}"))), + }; + + let resp = match client.get(&url).send().await { + Ok(r) => r, + Err(e) => return Ok(ToolResult::error(format!("Request failed: {e}"))), + }; + let status = resp.status(); + let final_url = resp.url().to_string(); + let location = resp + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let body = match resp.text().await { + Ok(b) => b, + Err(e) => return Ok(ToolResult::error(format!("Failed to read body: {e}"))), + }; + + if let Some(loc) = &location + && status.is_redirection() + { + return Ok(ToolResult::success(format!( + "status={} url={} location={loc}\n[redirect not followed β€” re-call web_fetch with the location URL if it's an allowed domain]", + status.as_u16(), + final_url + ))); + } + + let downloaded = body.len(); + let (body, byte_capped) = if downloaded > max_bytes { + let cut = floor_char_boundary(&body, max_bytes); + (body[..cut].to_string(), true) + } else { + (body, false) + }; + + // Markdown by default. A page's prose is a small fraction of its + // bytes; handing the raw document to the model (and to the payload + // summarizer behind it) is how one research turn came to cost + // 1,083,069 input tokens. The host's `HtmlExtractor` owns + // content transforms. + let converted = + !raw_requested && is_html(self.html.as_ref(), &body, content_type.as_deref()); + let content = if converted { + self.html.to_markdown(&body) + } else { + body + }; + + let extracted = content.len(); + let mut header = format!("status={} url={final_url}", status.as_u16()); + if converted { + header.push_str(" content=markdown"); + } + if byte_capped { + header.push_str(&format!(" download_capped_at={max_bytes}B")); + } + if converted && extracted < downloaded { + header.push_str(&format!(" extracted={extracted}B_of_{downloaded}B")); + } + header.push('\n'); + + // Full extracted content. Bounding it β€” the head/tail window, the + // spill to an artifact and the paging handle β€” belongs to + // `ToolOutputMiddleware`, which applies one rule to every tool. + // Codex enforces exactly this invariant at a single chokepoint + // (`context_manager/history.rs`), which is why a tool there cannot + // leak an unbounded payload however it misbehaves. + Ok(ToolResult::success(format!("{header}{content}"))) + } +} + +/// The largest index at or below `index` that is a char boundary of `s`. +fn floor_char_boundary(s: &str, index: usize) -> usize { + if index >= s.len() { + return s.len(); + } + let mut end = index; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + end +} + +/// Is this HTML? The server's own `Content-Type` is authoritative when it +/// says so; otherwise fall back to the host's content detection, which +/// already distinguishes HTML from JSON, diffs and code. +fn is_html(extractor: &dyn HtmlExtractor, body: &str, content_type: Option<&str>) -> bool { + if let Some(ct) = content_type { + let ct = ct.to_ascii_lowercase(); + let mime = ct.split(';').next().unwrap_or("").trim().to_string(); + // An explicit non-HTML type is a statement, not a guess: a JSON API + // that happens to embed markup must come back verbatim. + if !mime.is_empty() && mime != "text/html" && mime != "application/xhtml+xml" { + return false; + } + if !mime.is_empty() { + return true; + } + } + extractor.looks_like_html(body) +} + +#[cfg(test)] +#[path = "web_fetch_test.rs"] +mod tests; diff --git a/crates/tinytools-std/src/network/web_fetch_test.rs b/crates/tinytools-std/src/network/web_fetch_test.rs new file mode 100644 index 0000000..3e550a8 --- /dev/null +++ b/crates/tinytools-std/src/network/web_fetch_test.rs @@ -0,0 +1,233 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use super::*; +use crate::network::test_support::{DEFAULT_LIMITS, TestHtml, TestNetGate}; + +fn test_security() -> Arc { + TestNetGate::supervised() +} + +fn fetch( + gate: Arc, + allowed: Vec, + max: Option, + timeout: Option, +) -> WebFetchTool { + WebFetchTool::new( + gate, + allowed, + max, + timeout, + DEFAULT_LIMITS, + Arc::new(TestHtml), + ) +} + +#[test] +fn web_fetch_name_and_schema() { + let tool = fetch(test_security(), vec!["example.com".into()], None, None); + assert_eq!(tool.name(), "web_fetch"); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["url"].is_object()); + assert!( + schema["required"] + .as_array() + .unwrap() + .contains(&json!("url")) + ); +} + +#[test] +fn a_host_can_opt_the_schema_into_extra_optional_arguments() { + let tool = fetch(test_security(), vec!["example.com".into()], None, None).with_schema_property( + "summary_focus", + json!({"type": "string", "description": "What you need."}), + ); + let schema = tool.parameters_schema(); + assert_eq!(schema["properties"]["summary_focus"]["type"], "string"); + assert!( + !schema["required"] + .as_array() + .unwrap() + .contains(&json!("summary_focus")) + ); + // Without the opt-in the schema is exactly the base one. + let plain = fetch(test_security(), vec![], None, None).parameters_schema(); + assert!(plain["properties"].get("summary_focus").is_none()); +} + +#[test] +fn zero_and_none_limits_fall_back_to_defaults() { + // Callers wire these from `[http_request]`; a stale `Some(0)` is a + // 0-byte cap (empty bodies) and a 0-second timeout (instant failure). + // Both `None` and `Some(0)` must coerce to the shared schema defaults. + let defaults = DEFAULT_LIMITS; + let from_zero = fetch( + test_security(), + vec!["example.com".into()], + Some(0), + Some(0), + ); + assert_eq!(from_zero.max_bytes, defaults.max_response_size); + assert_eq!(from_zero.timeout_secs, defaults.timeout_secs); + assert_ne!(from_zero.timeout_secs, 0); + assert_ne!(from_zero.max_bytes, 0); + + let from_none = fetch(test_security(), vec!["example.com".into()], None, None); + assert_eq!(from_none.max_bytes, defaults.max_response_size); + assert_eq!(from_none.timeout_secs, defaults.timeout_secs); +} + +#[test] +fn nonzero_limits_are_preserved() { + let tool = fetch( + test_security(), + vec!["example.com".into()], + Some(4096), + Some(15), + ); + assert_eq!(tool.max_bytes, 4096); + assert_eq!(tool.timeout_secs, 15); +} + +#[tokio::test] +async fn web_fetch_rejects_disallowed_domain() { + let tool = fetch(test_security(), vec!["example.com".into()], None, None); + let result = tool + .execute(json!({ "url": "https://evil.test/path" })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("URL rejected")); +} + +#[tokio::test] +async fn web_fetch_rejects_invalid_url() { + let tool = fetch(test_security(), vec!["example.com".into()], None, None); + let result = tool.execute(json!({ "url": "not-a-url" })).await.unwrap(); + assert!(result.is_error); +} + +#[tokio::test] +async fn web_fetch_blocked_under_local_only_privacy_mode() { + // Privacy epic S7 (#4441): under LocalOnly the fetch is refused with a + // `[policy-blocked]` result before any URL validation / network. + let tool = fetch( + TestNetGate::local_only(), + vec!["example.com".into()], + None, + None, + ); + let result = tool + .execute(json!({ "url": "https://example.com/data" })) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result.output().contains("[policy-blocked]"), + "got: {}", + result.output() + ); + assert!( + result.output().contains("Local-only"), + "got: {}", + result.output() + ); +} + +#[test] +fn test_web_fetch_truncation_utf8() { + // Mock body with multi-byte char exactly at budget + let body = "Hello πŸ¦€ World"; // πŸ¦€ is at index 6-9 + let max_bytes = 8; + // Should truncate at index 6 + let cut = floor_char_boundary(body, max_bytes); + assert_eq!(cut, 6); + assert_eq!(&body[..cut], "Hello "); +} + +// --- content extraction ---------------------------------------------------- + +fn html(body: &str, content_type: Option<&str>) -> bool { + is_html(&TestHtml, body, content_type) +} + +#[test] +fn an_explicit_html_content_type_selects_markdown_conversion() { + assert!(html("

hi

", Some("text/html; charset=utf-8"))); + assert!(html("

hi

", Some("application/xhtml+xml"))); +} + +#[test] +fn an_explicit_non_html_content_type_is_taken_at_its_word() { + // A JSON API that happens to quote markup must come back verbatim β€” + // the server said what it sent, so we don't second-guess it by sniffing. + let body = r#"{"html": "

one

twotwo
"}"#; + assert!(!html(body, Some("application/json"))); + assert!(!html("

x

", Some("text/plain"))); +} + +#[test] +fn a_missing_content_type_falls_back_to_content_detection() { + assert!(html( + "

hi

", + None + )); + assert!(!html("# Just a README\n\nSome prose.\n", None)); +} + +#[test] +fn an_empty_content_type_does_not_veto_detection() { + assert!(html("x", Some(""))); +} + +#[test] +fn the_schema_offers_the_raw_escape_hatch() { + let tool = fetch(test_security(), vec![], None, None); + let schema = tool.parameters_schema(); + assert!( + schema["properties"].get("raw").is_some(), + "a caller must be able to opt out of conversion: {schema}" + ); + assert!( + tool.description().contains("Markdown"), + "the model needs to know what it will get back: {}", + tool.description() + ); +} + +#[test] +fn the_declared_cap_is_sized_for_extracted_markdown_not_raw_markup() { + let tool = fetch(test_security(), vec![], None, None); + let cap = tool + .max_result_size_chars() + .expect("web_fetch declares a cap"); + assert!( + (8_000..=32_000).contains(&cap), + "cap should sit in the same range as Hermes (15k chars) and Codex \ + (~10k tokens) budget for one result, got {cap}" + ); +} + +#[test] +fn html_is_converted_through_the_host_extractor_only_when_it_is_html() { + let body = "

hi

"; + assert!(html(body, None)); + assert_eq!(TestHtml.to_markdown(body), "hi"); +} + +#[tokio::test] +async fn execute_blocks_when_rate_limited() { + let tool = fetch( + TestNetGate::with(crate::network::test_support::AutonomyLevel::Supervised, 0), + vec!["example.com".into()], + None, + None, + ); + let result = tool + .execute(json!({ "url": "https://example.com/data" })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Rate limit exceeded")); +}