diff --git a/AGENTS.md b/AGENTS.md index 7fbdd4a1..986fc360 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,10 +61,9 @@ is sufficient, whether a timeout applies, whether an external effect needs approval — it belongs to the host, whose threat model and configuration the decision depends on. -`crates/tinytools-std` holds host-independent tool building blocks (cross-agent -file staleness tracking, SSRF-safe URL validation, PATH probing, and the -filesystem tools, which ask a host-implemented `FsGate` before they act). It is not part of the dependency-light vocabulary crate and may pull -in `tokio`, `parking_lot` and `log`. +This workspace contains shared tool definitions and call protocols only. +Concrete tools and host-independent building blocks belong in their owning +host or module repository. Add a crate by creating `crates//` — `members = ["crates/*"]` picks it up by existing. Inherit `version`, `edition`, `rust-version`, `license`, and diff --git a/Cargo.lock b/Cargo.lock index f55742fe..f71dfe6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,196 +29,94 @@ dependencies = [ ] [[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.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-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" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "const-oid" -version = "0.10.2" +name = "memchr" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "cpufeatures" -version = "0.3.1" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "crypto-common" -version = "0.2.2" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "hybrid-array", + "unicode-ident", ] [[package]] -name = "digest" -version = "0.11.3" +name = "quote" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "proc-macro2", ] [[package]] -name = "displaydoc" -version = "0.2.7" +name = "regex" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] -name = "errno" -version = "0.3.14" +name = "regex-automata" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ - "libc", - "windows-sys 0.61.2", + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] -name = "fastrand" -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" +name = "regex-syntax" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "fs2" -version = "0.4.3" +name = "serde" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ - "libc", - "winapi", + "serde_core", + "serde_derive", ] [[package]] -name = "futures-channel" -version = "0.3.34" +name = "serde_core" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ - "futures-core", + "serde_derive", ] [[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" +name = "serde_derive" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", @@ -226,1450 +124,104 @@ dependencies = [ ] [[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 = "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" -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]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "http" -version = "1.5.0" +name = "serde_json" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "bytes", "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[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 = "hybrid-array" -version = "0.4.15" +name = "syn" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ - "typenum", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "hyper" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +name = "tinytools" +version = "0.6.0" dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", + "anyhow", + "async-trait", + "serde", + "serde_json", "tokio", - "want", ] [[package]] -name = "hyper-rustls" -version = "0.27.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +name = "tinytools-agent" +version = "0.6.0" dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", + "regex", + "serde", + "serde_json", + "tinytools", + "tracing", ] [[package]] -name = "hyper-util" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +name = "tinytools-jev" +version = "0.6.0" dependencies = [ - "base64 0.23.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "httparse", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", + "async-trait", + "tinytools", "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" +name = "tokio" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "icu_normalizer", - "icu_properties", + "pin-project-lite", + "tokio-macros", ] [[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" +name = "tokio-macros" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "linux-raw-sys" -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" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "scopeguard", + "pin-project-lite", + "tracing-core", ] [[package]] -name = "log" -version = "0.4.34" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" [[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" -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.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "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.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" -dependencies = [ - "proc-macro2", - "quote", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "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]] -name = "tinytools" -version = "0.6.0" -dependencies = [ - "anyhow", - "async-trait", - "serde", - "serde_json", - "tokio", -] - -[[package]] -name = "tinytools-agent" -version = "0.6.0" -dependencies = [ - "regex", - "serde", - "serde_json", - "tinytools", - "tracing", -] - -[[package]] -name = "tinytools-jev" -version = "0.6.0" -dependencies = [ - "async-trait", - "tinytools", - "tokio", - "tracing", -] - -[[package]] -name = "tinytools-std" -version = "0.6.0" -dependencies = [ - "anyhow", - "async-trait", - "base64 0.23.1", - "fs2", - "futures-util", - "glob", - "libc", - "log", - "parking_lot", - "regex", - "reqwest", - "rustix", - "serde_json", - "sha2", - "tempfile", - "tinytools", - "tokio", - "tracing", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -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" -version = "1.0.24" +name = "unicode-ident" +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 = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "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.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-link" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -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/README.md b/README.md index 4bc3603c..37b41a3d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # TinyTools -The vocabulary an agent tool is written against: the `Tool` trait, the -`ToolResult` it returns, and the classifications a host enforces around a call. +TinyTools defines the vocabulary an agent tool is written against: the `Tool` +trait, the `ToolResult` it returns, and the classifications a host enforces +around a call. It contains definitions only; tool implementations live in +their owning host or module. ```rust use tinytools::{Tool, ToolResult}; @@ -26,7 +28,8 @@ impl Tool for Echo { } ``` -That is a complete tool. Everything else in the trait has a default. +This shows the shape of a tool implementation. Everything else in the trait +has a default. `Tool` is async, so implementing it needs the `async-trait` shim above and beyond `tinytools` itself — this crate depends on it internally but does not @@ -80,6 +83,10 @@ transport. ## What is deliberately not here +**No tool implementations.** This workspace defines shared tool contracts, +types, policies, and call protocols. Concrete tools belong in their owning +host or module. + **No enforcement.** Nothing in this crate checks a `PermissionLevel`, applies a `ToolTimeout`, or decides whether an `external_effect` needs approval. A tool *describes* itself and a host *decides*, because the decision depends on that diff --git a/crates/tinytools-std/Cargo.toml b/crates/tinytools-std/Cargo.toml deleted file mode 100644 index b3264cd2..00000000 --- a/crates/tinytools-std/Cargo.toml +++ /dev/null @@ -1,54 +0,0 @@ -[package] -name = "tinytools-std" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -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 - -[dependencies] -anyhow = { workspace = true } -async-trait = { workspace = true } -# `image_info` base64 data URIs. -base64 = "0.23" -# `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. -glob = "0.3" -# `O_NOFOLLOW` when opening the workspace lock file (Unix only). -libc = "0.2" -# Structured diagnostics for DNS validation and SSRF refusals. -log = "0.4" -# Async path locks must remain non-blocking while callers hold them across I/O. -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", "process"] } -serde_json = { workspace = true } -# `curl` reports the SHA-256 of what it downloaded. -sha2 = "0.11" -tinytools = { path = "../tinytools", version = "0.6.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", "io-util", "macros"] } -tracing = { workspace = true } -# `grep` and `glob_search` directory walks. -walkdir = "2" - -[dev-dependencies] -tempfile = "3" -tokio = { workspace = true, features = ["test-util"] } - -[lints] -workspace = true diff --git a/crates/tinytools-std/README.md b/crates/tinytools-std/README.md deleted file mode 100644 index 4e2c7d07..00000000 --- a/crates/tinytools-std/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# tinytools-std - -Host-independent building blocks for agent tools, extracted from OpenHuman. - -| Module | What it is | -| --- | --- | -| `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`, `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. `FsGate::paths_string_allowed` is the batch form of the string-level path check (default: the single-path check mapped over the slice); `glob` asks it once per search, and a wrong-length answer refuses every hit. See `docs/specs/fsgate-batch-path-check.md`. | -| `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`. `WebFetchTool::new_async` accepts a fallible `AsyncHtmlExtractor` for transforms supplied by a remote service or loadable module; detection and extraction are awaited with the configured request timeout applied to each operation, and failures propagate without a local retry. Both constructors extract before applying the output cap, and skip extraction for raw or explicitly non-HTML responses. 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/detect_tools/mod.rs b/crates/tinytools-std/src/detect_tools/mod.rs deleted file mode 100644 index d05ff6f7..00000000 --- a/crates/tinytools-std/src/detect_tools/mod.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! Tool: `detect_tools` — report which developer toolchains are installed on PATH. -//! -//! Lets the agent ground its plans in what the host actually has rather than -//! assuming. Read-only: it only scans `$PATH` for executables (no subprocesses, -//! no writes), so it is safe in every access mode. - -use async_trait::async_trait; -use serde_json::json; -use std::path::PathBuf; -use tinytools::{PermissionLevel, Tool, ToolResult}; - -/// Common developer tools probed when the caller doesn't specify a list. -const DEFAULT_CANDIDATES: &[&str] = &[ - "node", "npm", "npx", "pnpm", "yarn", "bun", "deno", "python3", "python", "pip3", "pip", "uv", - "pipx", "cargo", "rustc", "go", "gcc", "cc", "clang", "make", "git", "gh", "docker", "podman", - "kubectl", "rg", "jq", "fd", "curl", "wget", -]; - -/// Read-only tool reporting which developer toolchains are on `PATH`. -#[derive(Debug)] -pub struct DetectToolsTool; - -impl DetectToolsTool { - #[must_use] - /// Create the tool. - pub fn new() -> Self { - Self - } -} - -impl Default for DetectToolsTool { - fn default() -> Self { - Self::new() - } -} - -/// Fallback executable extensions when Windows has no `PATHEXT` set. -const DEFAULT_PATHEXT: &str = ".EXE;.CMD;.BAT"; - -/// Locate `name` on `$PATH`, honoring `PATHEXT` on Windows. Returns the first -/// matching executable path, or `None` if not found. -#[must_use] -pub fn find_on_path(name: &str) -> Option { - let path = std::env::var_os("PATH")?; - let pathext = cfg!(windows) - .then(|| std::env::var("PATHEXT").unwrap_or_else(|_| DEFAULT_PATHEXT.to_string())); - let file_names = candidate_file_names(name, pathext.as_deref()); - for dir in std::env::split_paths(&path) { - for file_name in &file_names { - let candidate = dir.join(file_name); - if is_executable_file(&candidate) { - return Some(candidate); - } - } - } - None -} - -/// Whether `path` names a regular file the current process can execute. -fn is_executable_file(path: &std::path::Path) -> bool { - if !path.is_file() { - return false; - } - // On Unix, checking mode bits alone ignores the current process's - // effective credentials and supplementary groups. - #[cfg(unix)] - { - rustix::fs::accessat( - rustix::fs::CWD, - path.as_os_str().as_encoded_bytes(), - rustix::fs::Access::EXEC_OK, - rustix::fs::AtFlags::EACCESS, - ) - .is_ok() - } - #[cfg(not(unix))] - { - true - } -} - -/// The file names to probe in each `PATH` directory for `name`. -/// -/// `pathext` is the Windows `PATHEXT` list (`;`-separated), or `None` on -/// platforms that run a bare file name. A name that already ends in one of -/// those extensions (compared case-insensitively, as Windows does) is probed -/// unchanged; any other name is probed once per extension. -fn candidate_file_names(name: &str, pathext: Option<&str>) -> Vec { - let extensions: Vec<&str> = pathext - .into_iter() - .flat_map(|list| list.split(';')) - .filter(|ext| !ext.is_empty()) - .collect(); - let lower_name = name.to_ascii_lowercase(); - let has_extension = extensions - .iter() - .any(|ext| lower_name.ends_with(&ext.to_ascii_lowercase())); - if extensions.is_empty() || has_extension { - return vec![name.to_string()]; - } - extensions - .iter() - .map(|ext| format!("{name}{ext}")) - .collect() -} - -#[async_trait] -impl Tool for DetectToolsTool { - fn name(&self) -> &'static str { - "detect_tools" - } - - fn description(&self) -> &'static str { - "Detect which developer tools / language runtimes are installed on the host PATH \ - (e.g. node, python3, cargo, docker, git, rg). Use this before assuming a tool \ - exists or before proposing to install one. Read-only — scans PATH only." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "tools": { - "type": "array", - "items": { "type": "string" }, - "description": "Optional list of tool names to probe. If omitted, a default \ - catalog of common developer tools is probed." - } - } - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::ReadOnly - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - let requested: Vec = args - .get("tools") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|x| x.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default(); - let candidates: Vec = if requested.is_empty() { - DEFAULT_CANDIDATES - .iter() - .map(|s| (*s).to_string()) - .collect() - } else { - requested - }; - - let mut available = Vec::new(); - let mut missing = Vec::new(); - for name in &candidates { - match find_on_path(name) { - Some(p) => available.push(json!({ - "name": name, - "path": p.to_string_lossy(), - })), - None => missing.push(name.clone()), - } - } - - tracing::debug!( - probed = candidates.len(), - available = available.len(), - "[detect_tools] PATH scan complete" - ); - let payload = json!({ - "available": available, - "missing": missing, - "probed": candidates.len(), - }); - Ok(ToolResult::success(serde_json::to_string_pretty(&payload)?)) - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/detect_tools/mod_tests.rs b/crates/tinytools-std/src/detect_tools/mod_tests.rs deleted file mode 100644 index f88a3992..00000000 --- a/crates/tinytools-std/src/detect_tools/mod_tests.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Unit tests for `PATH` probing, `PATHEXT` candidate names, and the -//! `detect_tools` tool's payload. - -use super::*; - -#[cfg(unix)] -#[test] -fn executable_lookup_requires_current_process_access() -> anyhow::Result<()> { - use std::os::unix::fs::PermissionsExt; - - let dir = std::env::temp_dir().join(format!("tinytools-exec-check-{}", std::process::id())); - std::fs::create_dir_all(&dir)?; - let file = dir.join("candidate"); - std::fs::write(&file, "binary")?; - std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600))?; - assert!(!is_executable_file(&file)); - std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o700))?; - assert!(is_executable_file(&file)); - assert!(!is_executable_file(&dir)); - std::fs::remove_dir_all(dir)?; - Ok(()) -} - -#[test] -fn name_and_permission() { - let tool = DetectToolsTool::new(); - assert_eq!(tool.name(), "detect_tools"); - assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); -} - -#[test] -fn default_and_metadata_contracts_are_available() { - let tool = ::default(); - assert!(tool.description().contains("PATH")); - assert_eq!( - tool.parameters_schema()["properties"]["tools"]["type"], - "array" - ); -} - -#[tokio::test] -async fn non_string_tool_names_fall_back_to_the_default_catalog() -> anyhow::Result<()> { - let result = DetectToolsTool::new() - .execute(json!({"tools": [null, 3]})) - .await?; - let payload: serde_json::Value = serde_json::from_str(&result.output())?; - assert_eq!(payload["probed"], super::DEFAULT_CANDIDATES.len()); - Ok(()) -} - -#[tokio::test] -async fn missing_tool_reported_missing() -> anyhow::Result<()> { - let tool = DetectToolsTool::new(); - let result = tool - .execute(json!({ "tools": ["definitely_not_a_real_binary_xyz_123"] })) - .await?; - assert!(!result.is_error); - let payload: serde_json::Value = serde_json::from_str(&result.output())?; - assert_eq!(payload["probed"], 1); - assert_eq!(payload["available"], json!([])); - assert_eq!( - payload["missing"], - json!(["definitely_not_a_real_binary_xyz_123"]) - ); - Ok(()) -} - -#[tokio::test] -async fn available_plus_missing_equals_probed() -> anyhow::Result<()> { - let tool = DetectToolsTool::new(); - let result = tool - .execute(json!({ "tools": ["sh", "definitely_not_a_real_binary_xyz_123"] })) - .await?; - let payload: serde_json::Value = serde_json::from_str(&result.output())?; - let avail = payload["available"].as_array().map_or(0, Vec::len); - let miss = payload["missing"].as_array().map_or(0, Vec::len); - assert_eq!(avail + miss, 2); - Ok(()) -} - -#[test] -fn bare_name_is_probed_unchanged_without_pathext() { - assert_eq!(candidate_file_names("git", None), vec!["git".to_string()]); -} - -#[test] -fn extensionless_name_gets_each_pathext_extension() { - assert_eq!( - candidate_file_names("git", Some(".EXE;.CMD")), - vec!["git.EXE".to_string(), "git.CMD".to_string()] - ); -} - -#[test] -fn name_already_carrying_a_pathext_extension_is_probed_unchanged() { - // Windows must probe `git.exe`, not `git.exe.EXE`. - assert_eq!( - candidate_file_names("git.exe", Some(".EXE;.CMD;.BAT")), - vec!["git.exe".to_string()] - ); - assert_eq!( - candidate_file_names("build.Cmd", Some(".EXE;.CMD")), - vec!["build.Cmd".to_string()] - ); -} - -#[test] -fn name_with_a_non_pathext_extension_still_gets_pathext_appended() { - assert_eq!( - candidate_file_names("python3.11", Some(".EXE")), - vec!["python3.11.EXE".to_string()] - ); -} - -#[test] -fn empty_pathext_entries_are_ignored() { - assert_eq!( - candidate_file_names("git", Some(".EXE;;")), - vec!["git.EXE".to_string()] - ); - assert_eq!( - candidate_file_names("git", Some("")), - vec!["git".to_string()] - ); -} diff --git a/crates/tinytools-std/src/file_state/agent_context.rs b/crates/tinytools-std/src/file_state/agent_context.rs deleted file mode 100644 index 1624adfb..00000000 --- a/crates/tinytools-std/src/file_state/agent_context.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Task-local carrier for the currently-executing agent's identity so -//! file tools can attribute reads/writes without widening the `Tool` trait. -//! -//! Set by the agent harness around tool execution; tools read via [`current_file_state_agent_id`]. - -tokio::task_local! { - static FILE_STATE_AGENT_ID: String; -} - -/// Returns the current agent's identity for file-state tracking, if set. -/// -/// Returns `None` outside an agent turn (CLI, JSON-RPC direct, unit tests). -#[must_use] -pub fn current_file_state_agent_id() -> Option { - FILE_STATE_AGENT_ID.try_with(std::clone::Clone::clone).ok() -} - -/// Run `future` with `agent_id` installed as the file-state identity. -pub async fn with_file_state_agent_id(agent_id: String, future: F) -> R -where - F: std::future::Future, -{ - FILE_STATE_AGENT_ID.scope(agent_id, future).await -} diff --git a/crates/tinytools-std/src/file_state/mod.rs b/crates/tinytools-std/src/file_state/mod.rs deleted file mode 100644 index 35a14b66..00000000 --- a/crates/tinytools-std/src/file_state/mod.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Process-wide file state coordinator for cross-agent staleness detection. -//! -//! Parallel subagents and worker threads share a workspace. Without -//! coordination one worker can read a file, a sibling can edit it, and -//! the first worker can later write based on stale content. This module -//! tracks per-agent read stamps and, for every path, each agent's latest -//! write stamp so that write tools can detect the conflict and return a -//! model-facing error requiring the agent to re-read. A read counts as stale -//! when *any* other agent wrote after it, not only the most recent writer. -//! -//! A read tool captures `Instant::now()` *before* it opens the file and hands -//! that stamp to [`record_read`] afterwards, so a sibling write racing the -//! read's I/O is ordered after the read and still reported stale. -//! -//! The guard is opt-in for the process: the host calls [`init_global`] with -//! `enabled` (typically derived from its own configuration or environment). -//! Until it does, or when it passes `false`, every operation is a no-op. - -mod agent_context; -mod ops; -mod types; - -pub use agent_context::{current_file_state_agent_id, with_file_state_agent_id}; -pub use ops::{ - acquire_path_lock, check_partial_read, check_stale_read, init_global, parent_stale_files, - record_read, record_write, try_global, -}; -pub use types::{FileStateCoordinator, ReadStamp}; - -#[cfg(test)] -mod test; diff --git a/crates/tinytools-std/src/file_state/ops.rs b/crates/tinytools-std/src/file_state/ops.rs deleted file mode 100644 index da098ca0..00000000 --- a/crates/tinytools-std/src/file_state/ops.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Operational API for the file state coordinator. - -use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock}; -use std::time::{Instant, SystemTime}; -use tokio::sync::{Mutex, OwnedMutexGuard}; - -use super::types::{FileStateCoordinator, ReadStamp, writers_after_read}; - -// ── Singleton ──────────────────────────────────────────────────────────── - -static GLOBAL: OnceLock> = OnceLock::new(); - -/// Initialise the process-global coordinator when `enabled`. Safe to call -/// multiple times; only the first enabling call wins. Passing `false` leaves the -/// guard off, so [`try_global`] keeps returning `None` and every tracking -/// operation stays a no-op. -pub fn init_global(enabled: bool) { - if !enabled { - tracing::debug!("[file_state] guard disabled by host"); - return; - } - let _ = GLOBAL.set(Arc::new(FileStateCoordinator::new())); - tracing::debug!("[file_state] coordinator initialised"); -} - -/// Returns the global coordinator, or `None` when disabled / not yet initialised. -pub fn try_global() -> Option> { - GLOBAL.get().cloned() -} - -// ── Read tracking ──────────────────────────────────────────────────────── - -/// Record that `agent_id` read `resolved_path`. -/// -/// `read_started` must be captured with [`Instant::now`] *before* the file -/// is opened, not after its contents were read. A sibling write that lands -/// while the read is in flight is then ordered after the read and reported -/// stale by [`check_stale_read`], instead of being silently absorbed. -/// -/// ``` -/// use std::path::PathBuf; -/// use std::time::{Instant, SystemTime}; -/// use tinytools_std::file_state::record_read; -/// -/// let path = PathBuf::from("/workspace/notes.txt"); -/// let read_started = Instant::now(); -/// // ... open and read the file, then stat it for its mtime ... -/// record_read("agent-1", path, SystemTime::now(), false, read_started); -/// ``` -pub fn record_read( - agent_id: &str, - resolved_path: PathBuf, - mtime: SystemTime, - partial: bool, - read_started: Instant, -) { - let Some(coord) = try_global() else { return }; - coord.record_read(agent_id, resolved_path, mtime, partial, read_started); -} - -impl FileStateCoordinator { - /// Record a read on this coordinator; [`record_read`] delegates here. - pub(crate) fn record_read( - &self, - agent_id: &str, - resolved_path: PathBuf, - mtime: SystemTime, - partial: bool, - read_started: Instant, - ) { - tracing::trace!( - agent = agent_id, - path = %resolved_path.display(), - partial, - "[file_state] record_read" - ); - self.reads.write().insert( - (agent_id.to_string(), resolved_path), - ReadStamp { - mtime, - timestamp: read_started, - partial, - }, - ); - } -} - -// ── Write tracking ─────────────────────────────────────────────────────── - -/// Record that `agent_id` wrote `resolved_path`. -pub fn record_write(agent_id: &str, resolved_path: PathBuf) { - let Some(coord) = try_global() else { return }; - coord.record_write(agent_id, resolved_path); -} - -impl FileStateCoordinator { - /// Record a write on this coordinator; [`record_write`] delegates here. - pub(crate) fn record_write(&self, agent_id: &str, resolved_path: PathBuf) { - tracing::trace!( - agent = agent_id, - path = %resolved_path.display(), - "[file_state] record_write" - ); - let now = Instant::now(); - self.writes - .write() - .entry(resolved_path.clone()) - .or_default() - .insert(agent_id.to_string(), now); - // Also update this agent's own read stamp so its own subsequent - // writes don't trigger self-staleness. - self.reads.write().insert( - (agent_id.to_string(), resolved_path), - ReadStamp { - mtime: SystemTime::now(), - timestamp: now, - partial: false, - }, - ); - } -} - -// ── Staleness checks ───────────────────────────────────────────────────── - -/// Check whether `agent_id`'s view of `resolved_path` is stale because -/// another agent wrote to it after this agent's last read. Returns an -/// error message when stale, `None` when safe. -#[must_use] -pub fn check_stale_read(agent_id: &str, resolved_path: &Path) -> Option { - try_global()?.check_stale_read(agent_id, resolved_path) -} - -impl FileStateCoordinator { - /// [`check_stale_read`] against this coordinator. - pub(crate) fn check_stale_read(&self, agent_id: &str, resolved_path: &Path) -> Option { - let reads = self.reads.read(); - let writes = self.writes.read(); - let read_key = (agent_id.to_string(), resolved_path.to_path_buf()); - let read_stamp = reads.get(&read_key)?; - let path_writers = writes.get(resolved_path)?; - // Name the most recent of the writers that landed after the read; - // on a tie, the alphabetically first, so the message is deterministic. - let (latest, _) = writers_after_read(path_writers, agent_id, read_stamp.timestamp) - .max_by_key(|&(name, written_at)| (written_at, std::cmp::Reverse(name)))?; - let display_path = resolved_path.display(); - Some(format!( - "Stale read: file '{display_path}' was modified by agent '{latest}' after your last \ - read. Re-read the file before editing." - )) - } -} - -/// Check whether `agent_id`'s last read of `resolved_path` was partial. -/// Returns an error message when partial, `None` when safe. -#[must_use] -pub fn check_partial_read(agent_id: &str, resolved_path: &Path) -> Option { - let coord = try_global()?; - let reads = coord.reads.read(); - let read_key = (agent_id.to_string(), resolved_path.to_path_buf()); - let read_stamp = reads.get(&read_key)?; - if read_stamp.partial { - let display_path = resolved_path.display(); - Some(format!( - "Partial read: your last read of '{display_path}' was partial (paginated). \ - Perform a full read before overwriting." - )) - } else { - None - } -} - -// ── Path locking ───────────────────────────────────────────────────────── - -/// Acquire an async lock on `resolved_path` for a read-modify-write -/// section. Returns an `OwnedMutexGuard` that releases when dropped. -/// Returns `None` when the coordinator is disabled. -pub async fn acquire_path_lock(resolved_path: &Path) -> Option> { - let coord = try_global()?; - let mutex = { - let mut locks = coord.path_locks.write(); - locks - .entry(resolved_path.to_path_buf()) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() - }; - Some(mutex.lock_owned().await) -} - -// ── Parent reminder ────────────────────────────────────────────────────── - -/// Return resolved paths that `parent_agent_id` had previously read but -/// were subsequently written by any agent in `child_agent_ids`. -#[must_use] -pub fn parent_stale_files(parent_agent_id: &str, child_agent_ids: &[String]) -> Vec { - try_global().map_or_else(Vec::new, |coord| { - coord.parent_stale_files(parent_agent_id, child_agent_ids) - }) -} - -impl FileStateCoordinator { - /// [`parent_stale_files`] against this coordinator. - pub(crate) fn parent_stale_files( - &self, - parent_agent_id: &str, - child_agent_ids: &[String], - ) -> Vec { - self.stale_reads(parent_agent_id, |writer| { - child_agent_ids.iter().any(|child| child == writer) - }) - } -} diff --git a/crates/tinytools-std/src/file_state/test/agent_context.rs b/crates/tinytools-std/src/file_state/test/agent_context.rs deleted file mode 100644 index de922a0f..00000000 --- a/crates/tinytools-std/src/file_state/test/agent_context.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Tests for the task-local file-state agent identity. - -use crate::file_state::{current_file_state_agent_id, with_file_state_agent_id}; - -#[tokio::test] -async fn returns_none_outside_scope() { - assert_eq!(current_file_state_agent_id(), None); -} - -#[tokio::test] -async fn installs_and_reads_agent_id() { - let observed = - with_file_state_agent_id("agent-1".into(), async { current_file_state_agent_id() }).await; - assert_eq!(observed, Some("agent-1".to_string())); -} - -#[tokio::test] -async fn does_not_leak_across_scopes() { - with_file_state_agent_id("agent-1".into(), async { - assert_eq!(current_file_state_agent_id(), Some("agent-1".to_string())); - }) - .await; - assert_eq!(current_file_state_agent_id(), None); -} - -#[tokio::test] -async fn nested_scope_overrides_outer() { - with_file_state_agent_id("parent".into(), async { - assert_eq!(current_file_state_agent_id(), Some("parent".to_string())); - with_file_state_agent_id("child".into(), async { - assert_eq!(current_file_state_agent_id(), Some("child".to_string())); - }) - .await; - assert_eq!(current_file_state_agent_id(), Some("parent".to_string())); - }) - .await; -} diff --git a/crates/tinytools-std/src/file_state/test/mod.rs b/crates/tinytools-std/src/file_state/test/mod.rs deleted file mode 100644 index 6adf096f..00000000 --- a/crates/tinytools-std/src/file_state/test/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Unit tests for the file state coordinator: staleness, partial reads, -//! write attribution, path locks, and the task-local agent identity. - -mod agent_context; -mod ops; diff --git a/crates/tinytools-std/src/file_state/test/ops.rs b/crates/tinytools-std/src/file_state/test/ops.rs deleted file mode 100644 index 1842bd3d..00000000 --- a/crates/tinytools-std/src/file_state/test/ops.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! Tests for read/write tracking, staleness checks, and path locks. - -use crate::file_state::{FileStateCoordinator, ReadStamp}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime}; -use tokio::sync::Mutex; - -fn fresh_coordinator() -> Arc { - Arc::new(FileStateCoordinator::new()) -} - -#[test] -fn record_and_check_no_staleness() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/a.txt"); - coord.reads.write().insert( - ("agent-a".to_string(), path.clone()), - ReadStamp { - mtime: SystemTime::now(), - timestamp: Instant::now(), - partial: false, - }, - ); - let partial = coord - .reads - .read() - .get(&("agent-a".to_string(), path.clone())) - .map(|rs| rs.partial); - assert_eq!(partial, Some(false)); - assert!(coord.writes.read().get(&path).is_none()); -} - -#[test] -fn detect_sibling_write_staleness() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/b.txt"); - coord.record_read( - "agent-a", - path.clone(), - SystemTime::now(), - false, - Instant::now(), - ); - std::thread::sleep(Duration::from_millis(5)); - coord.record_write("agent-b", path.clone()); - let stale = coord.stale_reads_for_parent("agent-a"); - assert_eq!(stale, vec![path]); -} - -#[test] -fn own_write_does_not_trigger_staleness() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/c.txt"); - coord.record_read( - "agent-a", - path.clone(), - SystemTime::now(), - false, - Instant::now(), - ); - std::thread::sleep(Duration::from_millis(5)); - coord.record_write("agent-a", path.clone()); - let stale = coord.stale_reads_for_parent("agent-a"); - assert_eq!(stale.len(), 0); - assert_eq!(coord.check_stale_read("agent-a", &path), None); -} - -#[test] -fn partial_read_detected() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/d.txt"); - coord.reads.write().insert( - ("agent-a".to_string(), path.clone()), - ReadStamp { - mtime: SystemTime::now(), - timestamp: Instant::now(), - partial: true, - }, - ); - let partial = coord - .reads - .read() - .get(&("agent-a".to_string(), path.clone())) - .map(|rs| rs.partial); - assert_eq!(partial, Some(true)); -} - -#[test] -fn parent_stale_files_detects_child_writes() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/e.txt"); - coord.record_read( - "parent", - path.clone(), - SystemTime::now(), - false, - Instant::now(), - ); - std::thread::sleep(Duration::from_millis(5)); - coord.record_write("child-1", path.clone()); - assert_eq!(coord.stale_reads_for_parent("parent"), vec![path.clone()]); - assert_eq!( - coord.parent_stale_files("parent", &["child-1".to_string()]), - vec![path] - ); - assert_eq!( - coord - .parent_stale_files("parent", &["someone-else".to_string()]) - .len(), - 0 - ); -} - -#[test] -fn paths_written_by_collects_correctly() { - let coord = fresh_coordinator(); - let p1 = PathBuf::from("/tmp/test/f1.txt"); - let p2 = PathBuf::from("/tmp/test/f2.txt"); - coord.record_write("child-1", p1.clone()); - coord.record_write("child-2", p2); - let result = coord.paths_written_by(&["child-1".to_string()]); - assert_eq!(result.len(), 1); - assert_eq!(result.get("child-1"), Some(&vec![p1])); -} - -#[tokio::test] -async fn path_lock_serialises_access() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/lock.txt"); - let mutex = { - let mut locks = coord.path_locks.write(); - locks - .entry(path.clone()) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() - }; - - let guard = mutex.lock().await; - assert!(mutex.try_lock().is_err()); - drop(guard); - assert!(mutex.try_lock().is_ok()); -} - -#[tokio::test] -async fn global_api_tracks_reads_writes_and_locks() -> anyhow::Result<()> { - use crate::file_state::{ - acquire_path_lock, check_partial_read, check_stale_read, init_global, parent_stale_files, - record_read, record_write, try_global, - }; - - init_global(false); - init_global(true); - assert!(try_global().is_some()); - - let path = PathBuf::from("/tmp/test/global-flow.txt"); - record_read( - "reader", - path.clone(), - SystemTime::now(), - true, - Instant::now(), - ); - assert!(check_partial_read("reader", &path).is_some()); - assert!(check_stale_read("reader", &path).is_none()); - - record_read( - "reader", - path.clone(), - SystemTime::now(), - false, - Instant::now(), - ); - assert!(check_partial_read("reader", &path).is_none()); - - std::thread::sleep(Duration::from_millis(5)); - record_write("writer", path.clone()); - let msg = check_stale_read("reader", &path) - .ok_or_else(|| anyhow::anyhow!("expected a stale read after the sibling write"))?; - assert!(msg.contains("writer")); - assert_eq!( - parent_stale_files("reader", &["writer".to_string()]), - vec![path.clone()] - ); - assert!(check_stale_read("writer", &path).is_none()); - - let guard = acquire_path_lock(&path).await; - assert!(guard.is_some()); - Ok(()) -} - -#[test] -fn paths_written_by_keeps_a_path_after_another_agent_overwrites_it() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/history-shared.txt"); - coord.record_write("child-1", path.clone()); - coord.record_write("child-2", path.clone()); - - let result = coord.paths_written_by(&["child-1".to_string(), "child-2".to_string()]); - assert_eq!(result.get("child-1"), Some(&vec![path.clone()])); - assert_eq!(result.get("child-2"), Some(&vec![path])); -} - -#[test] -fn sibling_write_during_an_in_flight_read_is_reported_stale() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/in-flight.txt"); - let read_started = Instant::now(); - std::thread::sleep(Duration::from_millis(2)); - // The sibling write lands after the reader opened the file but before - // the reader got round to recording the read. - coord.record_write("sibling", path.clone()); - std::thread::sleep(Duration::from_millis(2)); - coord.record_read( - "reader", - path.clone(), - SystemTime::now(), - false, - read_started, - ); - - assert_eq!(coord.stale_reads_for_parent("reader"), vec![path]); -} - -// ── A later writer must not mask an earlier one ───────────── - -/// Parent reads `path`, then child-1 and child-2 write it in that order. -fn parent_read_then_two_child_writes(coord: &FileStateCoordinator, path: &Path) { - coord.record_read( - "parent", - path.to_path_buf(), - SystemTime::now(), - false, - Instant::now(), - ); - std::thread::sleep(Duration::from_millis(2)); - coord.record_write("child-1", path.to_path_buf()); - std::thread::sleep(Duration::from_millis(2)); - coord.record_write("child-2", path.to_path_buf()); -} - -#[test] -fn parent_stale_files_reports_an_earlier_child_write_masked_by_a_later_one() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/masked-child.txt"); - parent_read_then_two_child_writes(&coord, &path); - - assert_eq!( - coord.parent_stale_files("parent", &["child-1".to_string()]), - vec![path.clone()] - ); - assert_eq!( - coord.parent_stale_files("parent", &["child-2".to_string()]), - vec![path] - ); -} - -#[test] -fn stale_reads_for_parent_reports_a_path_written_by_two_children() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/two-children.txt"); - parent_read_then_two_child_writes(&coord, &path); - - assert_eq!(coord.stale_reads_for_parent("parent"), vec![path]); -} - -#[test] -fn own_later_write_does_not_mask_a_sibling_write_during_an_in_flight_read() { - // The reader opens the file, a sibling writes it, the reader then writes - // it through another tool, and only afterwards records the read that - // started before the sibling's write. The latest writer is the reader - // itself, but the content it holds may predate the sibling's change. - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/own-write-masks.txt"); - let read_started = Instant::now(); - std::thread::sleep(Duration::from_millis(2)); - coord.record_write("sibling", path.clone()); - std::thread::sleep(Duration::from_millis(2)); - coord.record_write("reader", path.clone()); - coord.record_read( - "reader", - path.clone(), - SystemTime::now(), - false, - read_started, - ); - - assert_eq!(coord.stale_reads_for_parent("reader"), vec![path.clone()]); - let msg = coord.check_stale_read("reader", &path); - assert!( - msg.as_deref().is_some_and(|m| m.contains("'sibling'")), - "got: {msg:?}" - ); -} - -#[test] -fn a_write_before_the_read_is_not_stale() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/write-then-read.txt"); - coord.record_write("child-1", path.clone()); - std::thread::sleep(Duration::from_millis(2)); - coord.record_read( - "parent", - path.clone(), - SystemTime::now(), - false, - Instant::now(), - ); - - assert_eq!(coord.stale_reads_for_parent("parent").len(), 0); - assert_eq!( - coord - .parent_stale_files("parent", &["child-1".to_string()]) - .len(), - 0 - ); - assert_eq!(coord.check_stale_read("parent", &path), None); -} diff --git a/crates/tinytools-std/src/file_state/types.rs b/crates/tinytools-std/src/file_state/types.rs deleted file mode 100644 index 28a4fa90..00000000 --- a/crates/tinytools-std/src/file_state/types.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Core types for the file state coordinator. - -use parking_lot::RwLock; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Instant, SystemTime}; -use tokio::sync::Mutex; - -/// Snapshot of a single file read by an agent. -#[derive(Debug, Clone)] -pub struct ReadStamp { - /// Filesystem mtime at the moment of the read. - pub mtime: SystemTime, - /// Monotonic clock timestamp taken just before the read's I/O began, so - /// any write that lands during the read orders after it. - pub timestamp: Instant, - /// Whether the read was partial (paginated / offset+limit). - pub partial: bool, -} - -/// For one path: each agent that wrote it, mapped to the monotonic instant -/// of that agent's latest write. -pub(crate) type PathWriters = HashMap; - -/// Process-global coordinator that tracks file reads and writes across -/// all agents in the process. Thread-safe via `RwLock`. -#[derive(Debug)] -pub struct FileStateCoordinator { - /// Per-agent, per-resolved-path read stamps. - /// Key: `(agent_id, canonical_path)`. - pub(crate) reads: RwLock>, - - /// Per-resolved-path writers, each with its own latest write instant. - /// A later write by one agent never erases another agent's entry, so a - /// staleness check sees every writer, not just the most recent. - pub(crate) writes: RwLock>, - - /// Per-resolved-path async mutex for serialising read-modify-write - /// sections (used by `edit` and `apply_patch`). - pub(crate) path_locks: RwLock>>>, -} - -impl Default for FileStateCoordinator { - fn default() -> Self { - Self::new() - } -} - -impl FileStateCoordinator { - #[must_use] - /// Create an empty coordinator. - pub fn new() -> Self { - Self { - reads: RwLock::new(HashMap::new()), - writes: RwLock::new(HashMap::new()), - path_locks: RwLock::new(HashMap::new()), - } - } - - /// Return the set of resolved paths that `parent_agent_id` has read - /// but were subsequently written by any other agent. - #[must_use] - pub fn stale_reads_for_parent(&self, parent_agent_id: &str) -> Vec { - self.stale_reads(parent_agent_id, |_| true) - } - - /// Paths `reader` read that some other agent accepted by `counts` wrote - /// after that read. Sorted. - pub(crate) fn stale_reads(&self, reader: &str, counts: impl Fn(&str) -> bool) -> Vec { - let reads = self.reads.read(); - let writes = self.writes.read(); - let mut stale: Vec = reads - .iter() - .filter(|((agent_id, _), _)| agent_id == reader) - .filter(|((_, path), read_stamp)| { - writes.get(path).is_some_and(|writers| { - writers_after_read(writers, reader, read_stamp.timestamp) - .any(|(writer, _)| counts(writer)) - }) - }) - .map(|((_, path), _)| path.clone()) - .collect(); - stale.sort(); - stale - } - - /// Collect every path written by each agent in `agent_ids`, keyed by - /// agent. A path appears under every listed agent that ever wrote it, - /// even when a different agent wrote it afterwards. Paths are sorted; - /// agents with no writes are absent. - #[must_use] - pub fn paths_written_by(&self, agent_ids: &[String]) -> HashMap> { - let writes = self.writes.read(); - let mut result: HashMap> = HashMap::new(); - for (path, writers) in writes.iter() { - for agent_id in agent_ids.iter().filter(|id| writers.contains_key(*id)) { - result - .entry(agent_id.clone()) - .or_default() - .push(path.clone()); - } - } - for paths in result.values_mut() { - paths.sort(); - } - result - } -} - -/// The agents other than `reader` whose latest write to a path came after -/// `read_at`, each with that write's instant. -pub(crate) fn writers_after_read<'a>( - writers: &'a PathWriters, - reader: &'a str, - read_at: Instant, -) -> impl Iterator + 'a { - writers - .iter() - .filter(move |(writer, written_at)| writer.as_str() != reader && **written_at > read_at) - .map(|(writer, written_at)| (writer.as_str(), *written_at)) -} diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod.rs deleted file mode 100644 index b05a3b63..00000000 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs +++ /dev/null @@ -1,472 +0,0 @@ -//! `apply_patch` — atomic multi-edit across one or more files. -//! -//! Coding-harness baseline tool (issue #1205). Takes an array of -//! `{path, old_string, new_string}` edits and applies them atomically: -//! every edit is validated up front (path, exact-match, uniqueness) -//! before any file is written. If any edit fails validation, no files -//! are touched. -//! -//! **Creating a file** is an empty `old_string` against a path that does not -//! exist yet; `new_string` becomes the whole contents. Before that existed the -//! tool could only edit, and an agent asked to produce a document had no route -//! at all: it would create a placeholder with `shell` purely so there was -//! something to patch (the life-scenario benchmark caught two 1-byte files -//! containing `x`). An empty `old_string` against a file that *does* exist is -//! still an error — "replace nothing" is ambiguous, not a create. - -use super::file_sink::atomic_write; -use super::gate::{FsGate, gate_for_context}; -use crate::file_state; -use async_trait::async_trait; -use serde_json::json; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; - -const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; -const MAX_EDITS: usize = 50; - -/// Apply a multi-edit patch to workspace files atomically. -#[derive(Debug)] -pub struct ApplyPatchTool { - gate: Arc, -} - -impl ApplyPatchTool { - /// Create the tool. - #[must_use] - pub fn new(gate: Arc) -> Self { - Self { gate } - } -} - -#[async_trait] -impl Tool for ApplyPatchTool { - fn name(&self) -> &'static str { - "apply_patch" - } - - fn description(&self) -> &'static str { - "Apply a batch of exact-string edits across one or more files atomically. \ - All edits are validated before any are written; validation failure rolls \ - back the whole batch. Each edit is `{path, old_string, new_string, replace_all?}`; \ - a top-level `path` is the default for edits that omit their own. \ - To CREATE a new file, pass an empty `old_string` with the full contents \ - as `new_string`; the path must not already exist." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Default file for edits that omit their own `path`." - }, - "edits": { - "type": "array", - "description": "Ordered list of edits.", - "items": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File to edit; defaults to the top-level `path`." - }, - "old_string": { - "type": "string", - "description": "Exact text to replace. Empty means CREATE: the path must not exist and `new_string` becomes the whole file." - }, - "new_string": { "type": "string" }, - "replace_all": { "type": "boolean", "default": false } - }, - "required": ["old_string", "new_string"] - } - } - }, - "required": ["edits"] - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write - } - - /// `apply_patch` modifies existing files → in ask-before-edit it routes - /// through the human approval gate; in Full it runs; read-only is blocked - /// in `execute`. - fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { - self.gate.write_needs_approval() - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_in_context(args, None).await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - self.execute_in_context(args, context).await - } -} - -impl ApplyPatchTool { - async fn execute_in_context( - &self, - args: serde_json::Value, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let edits = args - .get("edits") - .and_then(|v| v.as_array()) - .ok_or_else(|| anyhow::anyhow!("Missing 'edits' array"))?; - if edits.is_empty() { - return Ok(ToolResult::error("`edits` array is empty")); - } - if edits.len() > MAX_EDITS { - return Ok(ToolResult::error(format!( - "Too many edits: {} (max {MAX_EDITS})", - edits.len() - ))); - } - - if !self.gate.can_act() { - return Ok(ToolResult::error( - "[policy-blocked] Action blocked: autonomy is read-only", - )); - } - 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", - )); - } - - let path_policy = gate_for_context(&self.gate, context, "apply_patch"); - - // "One file, several edits" is a natural call shape, and models write - // it with the path once at the top level (4 of 10 calls in one run; - // the per-edit `path` requirement rejected every one of them and the - // run halted on the fourth). The top-level path is the default; an - // edit's own path still wins. - let default_path = match args.get("path") { - Some(value) => Some( - value - .as_str() - .ok_or_else(|| anyhow::anyhow!("top-level `path` must be a string"))?, - ), - None => None, - }; - - // Parse + group edits by file. - let mut parsed: Vec = Vec::with_capacity(edits.len()); - for (i, raw) in edits.iter().enumerate() { - let path = match raw.get("path") { - Some(value) => value.as_str().ok_or_else(|| { - anyhow::anyhow!("edit[{i}]: `path` must be a string") - })?, - None => default_path.ok_or_else(|| { - anyhow::anyhow!( - "edit[{i}]: missing `path` (give each edit a `path`, or one top-level `path` for all edits)" - ) - })?, - }; - let old_string = raw - .get("old_string") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("edit[{i}]: missing `old_string`"))?; - let new_string = raw - .get("new_string") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("edit[{i}]: missing `new_string`"))?; - let replace_all = raw - .get("replace_all") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - - if !path_policy.is_path_string_allowed(path) { - return Ok(ToolResult::error(format!( - "edit[{i}]: path not allowed: {path}" - ))); - } - // An empty `old_string` is a create, and only against a path that - // does not exist yet. Resolved the same way every other edit is — - // joined onto `action_dir` — so "exists" means the same thing here - // as it does in the apply loop below. - let exists = path_policy.action_dir().join(path).exists() - || (std::path::Path::new(path).is_absolute() - && std::path::Path::new(path).exists()); - let create = old_string.is_empty(); - if create && exists { - return Ok(ToolResult::error(format!( - "edit[{i}]: `old_string` must not be empty for an existing file ({path}). \ - Pass the exact text to replace, or write to a new path to create a file." - ))); - } - - // Symlink check must happen on the *unresolved* path — - // canonicalize resolves symlinks, so a check after that - // point would never see the link. - if let Ok(meta) = tokio::fs::symlink_metadata(path_policy.action_dir().join(path)).await - && meta.file_type().is_symlink() - { - return Ok(ToolResult::error(format!( - "edit[{i}]: refusing to edit through symlink" - ))); - } - - // Security check: validate path string, resolve symlinks, confirm - // workspace containment. A create has no file to canonicalize, - // so it resolves through the parent — the same gate - // `file_write` uses, which walks up to the deepest existing - // ancestor and still checks it for symlink escapes. Resolving - // here, once, means two spellings of one file (`a.txt`, - // `./a.txt`) share one lock, one check and one buffer below. - let resolved = if create { - path_policy.validate_parent_path(path).await - } else { - path_policy.validate_path(path).await - }; - let resolved = match resolved { - Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(format!("edit[{i}]: {msg}"))), - }; - parsed.push(ParsedEdit { - index: i, - path: path.to_string(), - resolved, - old_string: old_string.to_string(), - new_string: new_string.to_string(), - replace_all, - create, - }); - } - - // Acquire per-path locks for all unique paths before any reads. - let unique_paths: Vec<&PathBuf> = { - let mut seen = std::collections::HashSet::new(); - parsed - .iter() - .map(|e| &e.resolved) - .filter(|p| seen.insert(*p)) - .collect() - }; - let mut _path_guards = Vec::new(); - for resolved in &unique_paths { - if let Some(guard) = file_state::acquire_path_lock(resolved).await { - _path_guards.push(guard); - } - } - - // File-state guard: reject edits based on stale reads. A partial read is - // not refused here: an anchored `old_string` match proves the agent saw - // the text it replaces. Only file_write enforces the partial-read guard. - if let Some(agent_id) = file_state::current_file_state_agent_id() { - for resolved in &unique_paths { - if let Some(msg) = file_state::check_stale_read(&agent_id, resolved) { - tracing::debug!( - agent = %agent_id, - path = %resolved.display(), - "[file_state] apply_patch blocked: stale read" - ); - return Ok(ToolResult::error(msg)); - } - } - } - - // Load file contents (once per resolved file). Apply edits in - // memory; if any edit fails, return without writing. - let mut buffers: HashMap = HashMap::new(); - for edit in &parsed { - if !buffers.contains_key(&edit.resolved) { - let resolved = edit.resolved.clone(); - if edit.create { - if let Some(parent) = resolved.parent() - && let Err(e) = tokio::fs::create_dir_all(parent).await - { - return Ok(ToolResult::error(format!( - "edit[{}]: failed to create parent of {}: {e}", - edit.index, edit.path - ))); - } - buffers.insert( - resolved.clone(), - FileBuffer { - path: edit.path.clone(), - resolved, - original: None, - contents: edit.new_string.clone(), - edit_count: 1, - }, - ); - continue; - } - if let Ok(meta) = tokio::fs::metadata(&resolved).await - && meta.len() > MAX_FILE_BYTES - { - return Ok(ToolResult::error(format!( - "edit[{}]: file too large ({} bytes)", - edit.index, - meta.len() - ))); - } - let contents = match tokio::fs::read_to_string(&resolved).await { - Ok(c) => c, - Err(e) => { - return Ok(ToolResult::error(format!( - "edit[{}]: failed to read {}: {e}", - edit.index, edit.path - ))); - } - }; - buffers.insert( - resolved.clone(), - FileBuffer { - path: edit.path.clone(), - resolved, - original: Some(contents.clone()), - contents, - edit_count: 0, - }, - ); - } - - if edit.create { - // A second create for the same path in one batch. The first one - // already populated the buffer; a duplicate would silently - // discard one of the two bodies, so say so. - return Ok(ToolResult::error(format!( - "edit[{}]: {} is created earlier in this batch; only one create per path", - edit.index, edit.path - ))); - } - - let buf = buffers.get_mut(&edit.resolved).unwrap(); - let count = buf.contents.matches(&edit.old_string).count(); - if count == 0 { - return Ok(ToolResult::error(format!( - "edit[{}]: `old_string` not found in {}", - edit.index, edit.path - ))); - } - if count > 1 && !edit.replace_all { - return Ok(ToolResult::error(format!( - "edit[{}]: `old_string` matches {count} times in {}; pass `replace_all`", - edit.index, edit.path - ))); - } - // Refuse before building the result: the edits to one buffer - // must not grow it past the cap the input is held to. - let replaced = if edit.replace_all { count } else { 1 }; - let new_len = (buf.contents.len() - replaced * edit.old_string.len()) - .checked_add(replaced.saturating_mul(edit.new_string.len())); - if new_len.is_none_or(|len| len as u64 > MAX_FILE_BYTES) { - return Ok(ToolResult::error(format!( - "edit[{}]: edits would grow {} past the {MAX_FILE_BYTES}-byte limit", - edit.index, edit.path - ))); - } - buf.contents = if edit.replace_all { - buf.contents.replace(&edit.old_string, &edit.new_string) - } else { - buf.contents.replacen(&edit.old_string, &edit.new_string, 1) - }; - buf.edit_count += count; - } - - // Best-effort atomic write across files. We cannot get true - // multi-file atomicity without filesystem-level transactions, - // but if the i-th write fails we attempt to restore originals - // for the i-1 already-written files from the in-memory snapshot. - let mut summary: Vec = Vec::new(); - let mut written: Vec<&FileBuffer> = Vec::new(); - for buf in buffers.values() { - let path = &buf.path; - if let Err(e) = atomic_write(&buf.resolved, buf.contents.as_bytes()).await { - let restore_errors = restore_originals(&written).await; - let suffix = if restore_errors.is_empty() { - "; previously-written files restored from snapshot".to_string() - } else { - format!("; restore failed for: {}", restore_errors.join(", ")) - }; - return Ok(ToolResult::error(format!( - "Failed to write {path}: {e}{suffix}" - ))); - } - written.push(buf); - summary.push(match buf.original { - None => format!("{path}: created ({} bytes)", buf.contents.len()), - Some(_) => format!("{path}: {} replacement(s)", buf.edit_count), - }); - } - // Record writes in the file-state coordinator. - if let Some(agent_id) = file_state::current_file_state_agent_id() { - for buf in buffers.values() { - file_state::record_write(&agent_id, buf.resolved.clone()); - } - } - - summary.sort(); - Ok(ToolResult::success(format!( - "Applied {} edit(s) across {} file(s)\n{}", - parsed.len(), - buffers.len(), - summary.join("\n") - ))) - } -} - -async fn restore_originals(written: &[&FileBuffer]) -> Vec { - let mut errors = Vec::new(); - for buf in written { - let result = match &buf.original { - Some(original) => atomic_write(&buf.resolved, original.as_bytes()).await, - // The batch created this file, so "restore" means remove it — - // otherwise a failed multi-file patch leaves a half-written new - // file behind and the caller cannot tell it apart from a success. - None => tokio::fs::remove_file(&buf.resolved).await, - }; - if let Err(e) = result { - errors.push(format!("{}: {e}", buf.resolved.display())); - } - } - errors -} - -struct ParsedEdit { - index: usize, - path: String, - /// The gate-resolved path; keys the locks, checks and buffers. - resolved: PathBuf, - old_string: String, - new_string: String, - replace_all: bool, - /// Empty `old_string` against a path that does not exist: `new_string` - /// becomes the whole file. - create: bool, -} - -struct FileBuffer { - /// The first spelling of this file in the batch, for messages. - path: String, - resolved: PathBuf, - /// Snapshot of the file's contents as we first read them. - /// Used to restore on a partial-write failure. `None` for a file this batch - /// is creating — there is nothing to restore *to*, so rollback deletes it. - original: Option, - contents: String, - edit_count: usize, -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs deleted file mode 100644 index 8e4521a7..00000000 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs +++ /dev/null @@ -1,701 +0,0 @@ -//! Behavior tests for the `apply_patch` tool, driven through a fake [`FsGate`]. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::TestGate; - -fn test_security(workspace: std::path::PathBuf) -> Arc { - TestGate::supervised(workspace) -} - -#[test] -fn apply_patch_name() { - let tool = ApplyPatchTool::new(test_security(std::env::temp_dir())); - assert_eq!(tool.name(), "apply_patch"); -} - -#[tokio::test] -async fn apply_patch_applies_multiple_edits() { - let dir = std::env::temp_dir().join("openhuman_test_patch_multi"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "alpha\nbravo") - .await - .unwrap(); - tokio::fs::write(dir.join("b.txt"), "one two") - .await - .unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "edits": [ - { "path": "a.txt", "old_string": "alpha", "new_string": "ALPHA" }, - { "path": "b.txt", "old_string": "two", "new_string": "TWO" } - ] - })) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - let a = tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(); - let b = tokio::fs::read_to_string(dir.join("b.txt")).await.unwrap(); - assert_eq!(a, "ALPHA\nbravo"); - assert_eq!(b, "one TWO"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn apply_patch_atomic_on_validation_failure() { - let dir = std::env::temp_dir().join("openhuman_test_patch_atomic"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "alpha").await.unwrap(); - tokio::fs::write(dir.join("b.txt"), "bravo").await.unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - // Second edit will fail (no match) — first must NOT be applied. - let result = tool - .execute(json!({ - "edits": [ - { "path": "a.txt", "old_string": "alpha", "new_string": "ALPHA" }, - { "path": "b.txt", "old_string": "missing", "new_string": "x" } - ] - })) - .await - .unwrap(); - assert!(result.is_error); - let a = tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(); - assert_eq!(a, "alpha", "atomic: first edit must not be persisted"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn apply_patch_chained_edits_same_file() { - let dir = std::env::temp_dir().join("openhuman_test_patch_chain"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "one two three") - .await - .unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "edits": [ - { "path": "a.txt", "old_string": "one", "new_string": "ONE" }, - { "path": "a.txt", "old_string": "two", "new_string": "TWO" } - ] - })) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - let updated = tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(); - assert_eq!(updated, "ONE TWO three"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn apply_patch_rejects_empty_edits() { - let dir = std::env::temp_dir().join("openhuman_test_patch_empty"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"edits": []})).await.unwrap(); - assert!(result.is_error); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn apply_patch_rejects_traversal() { - let tool = ApplyPatchTool::new(test_security(std::env::temp_dir())); - let result = tool - .execute(json!({ - "edits": [ - { "path": "../etc/passwd", "old_string": "x", "new_string": "y" } - ] - })) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("not allowed")); -} - -// -- create mode -------------------------------------------------------------- -// -// Empty `old_string` + a path that does not exist = create. Before this the -// tool could only edit, so an agent asked to produce a document had no route: -// the life-scenario `meal-plan` run left two 1-byte files containing `x`, -// placeholders it made with `shell` purely so `apply_patch` had something to -// patch. - -#[tokio::test] -async fn apply_patch_creates_a_new_file_from_an_empty_old_string() { - let dir = std::env::temp_dir().join("openhuman_test_patch_create"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "edits": [ - { "path": "out/plan.md", "old_string": "", "new_string": "# Plan\nday one\n" } - ] - })) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - let written = tokio::fs::read_to_string(dir.join("out/plan.md")) - .await - .expect("the file (and its parent) must have been created"); - assert_eq!(written, "# Plan\nday one\n"); - assert!(result.output().contains("created"), "{}", result.output()); -} - -#[tokio::test] -async fn apply_patch_takes_a_top_level_path_as_the_default_for_every_edit() { - // The shape a model writes for "one file, several edits": the path once, - // at the top level. Each such call used to be rejected for a missing - // `edits[0].path`, and four in a row halted a run with 26 minutes left. - let dir = std::env::temp_dir().join("openhuman_test_patch_top_level_path"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.c"), "int x = 1;\nint y = 2;\n") - .await - .unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "path": "a.c", - "edits": [ - { "old_string": "int x = 1;", "new_string": "int x = 10;" }, - { "old_string": "int y = 2;", "new_string": "int y = 20;" } - ] - })) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert_eq!( - tokio::fs::read_to_string(dir.join("a.c")).await.unwrap(), - "int x = 10;\nint y = 20;\n" - ); -} - -#[tokio::test] -async fn apply_patch_rejects_malformed_edit_path_instead_of_using_default() { - let dir = std::env::temp_dir().join("openhuman_test_patch_malformed_path"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("default.txt"), "original") - .await - .unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "path": "default.txt", - "edits": [{ "path": null, "old_string": "original", "new_string": "changed" }] - })) - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("`path` must be a string") - ); - assert_eq!( - tokio::fs::read_to_string(dir.join("default.txt")) - .await - .unwrap(), - "original" - ); - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn apply_patch_rejects_a_malformed_top_level_path() { - let dir = std::env::temp_dir().join("openhuman_test_patch_malformed_top_level_path"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "original") - .await - .unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "path": 123, - "edits": [{ "path": "a.txt", "old_string": "original", "new_string": "changed" }] - })) - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("top-level `path` must be a string") - ); - assert_eq!( - tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), - "original" - ); - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn apply_patch_lets_an_edits_own_path_win_over_the_top_level_one() { - let dir = std::env::temp_dir().join("openhuman_test_patch_top_level_path_override"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "alpha").await.unwrap(); - tokio::fs::write(dir.join("b.txt"), "bravo").await.unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "path": "a.txt", - "edits": [ - { "old_string": "alpha", "new_string": "ALPHA" }, - { "path": "b.txt", "old_string": "bravo", "new_string": "BRAVO" } - ] - })) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert_eq!( - tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), - "ALPHA" - ); - assert_eq!( - tokio::fs::read_to_string(dir.join("b.txt")).await.unwrap(), - "BRAVO" - ); -} - -#[tokio::test] -async fn apply_patch_names_both_ways_to_give_a_path_when_neither_is_given() { - let dir = std::env::temp_dir().join("openhuman_test_patch_no_path_anywhere"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let err = tool - .execute(json!({ - "edits": [ { "old_string": "a", "new_string": "b" } ] - })) - .await - .expect_err("no path anywhere is an argument error"); - let text = err.to_string(); - assert!(text.contains("edit[0]: missing `path`"), "{text}"); - assert!(text.contains("one top-level `path`"), "{text}"); -} - -#[tokio::test] -async fn apply_patch_refuses_an_empty_old_string_on_an_existing_file() { - let dir = std::env::temp_dir().join("openhuman_test_patch_create_existing"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "alpha").await.unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "edits": [{ "path": "a.txt", "old_string": "", "new_string": "overwritten" }] - })) - .await - .unwrap(); - assert!(result.is_error, "{}", result.output()); - // Unchanged — "replace nothing" must never become "replace everything". - let a = tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(); - assert_eq!(a, "alpha"); -} - -#[tokio::test] -async fn apply_patch_mixes_a_create_and_an_edit_in_one_batch() { - let dir = std::env::temp_dir().join("openhuman_test_patch_create_mixed"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "alpha").await.unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "edits": [ - { "path": "a.txt", "old_string": "alpha", "new_string": "ALPHA" }, - { "path": "b.txt", "old_string": "", "new_string": "bravo" } - ] - })) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert_eq!( - tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), - "ALPHA" - ); - assert_eq!( - tokio::fs::read_to_string(dir.join("b.txt")).await.unwrap(), - "bravo" - ); -} - -#[tokio::test] -async fn apply_patch_refuses_two_creates_for_the_same_path() { - let dir = std::env::temp_dir().join("openhuman_test_patch_create_dup"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "edits": [ - { "path": "c.txt", "old_string": "", "new_string": "first" }, - { "path": "c.txt", "old_string": "", "new_string": "second" } - ] - })) - .await - .unwrap(); - assert!(result.is_error, "{}", result.output()); - assert!( - !dir.join("c.txt").exists(), - "a rejected batch must write nothing" - ); -} - -#[tokio::test] -async fn apply_patch_create_still_obeys_the_path_policy() { - let dir = std::env::temp_dir().join("openhuman_test_patch_create_escape"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "edits": [{ "path": "../escaped.md", "old_string": "", "new_string": "nope" }] - })) - .await - .unwrap(); - assert!(result.is_error, "{}", result.output()); -} - -// ── Coverage of limits, budgets, guards, symlinks and write failures ──── - -use crate::filesystem::test_support::{AutonomyLevel, RacyGate, WorkspaceContext}; - -fn one_edit(path: &str, old: &str, new: &str) -> serde_json::Value { - json!({"edits": [{"path": path, "old_string": old, "new_string": new}]}) -} - -#[tokio::test] -async fn apply_patch_rejects_too_many_edits() { - let dir = tempfile::tempdir().unwrap(); - let edits: Vec<_> = (0..=MAX_EDITS) - .map(|_| json!({"path": "a.txt", "old_string": "a", "new_string": "b"})) - .collect(); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let result = tool.execute(json!({"edits": edits})).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Too many edits")); -} - -#[tokio::test] -async fn apply_patch_enforces_autonomy_and_budgets() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("a.txt"), "x").unwrap(); - let args = one_edit("a.txt", "x", "y"); - - let read_only = ApplyPatchTool::new(TestGate::with( - dir.path().to_path_buf(), - AutonomyLevel::ReadOnly, - 100, - )); - let result = read_only.execute(args.clone()).await.unwrap(); - assert!(result.output().contains("autonomy is read-only")); - - let limited = ApplyPatchTool::new(TestGate::with( - dir.path().to_path_buf(), - AutonomyLevel::Supervised, - 0, - )); - let result = limited.execute(args.clone()).await.unwrap(); - assert!( - result - .output() - .contains("too many actions in the last hour") - ); - - let racy = ApplyPatchTool::new(Arc::new(RacyGate(test_security(dir.path().to_path_buf())))); - let result = racy.execute(args).await.unwrap(); - assert!(result.output().contains("action budget exhausted")); -} - -#[tokio::test] -async fn apply_patch_resolves_an_absolute_create_target() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("abs_new.txt"); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let result = tool - .execute(one_edit(target.to_str().unwrap(), "", "created")) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert_eq!(std::fs::read_to_string(target).unwrap(), "created"); -} - -#[cfg(unix)] -#[tokio::test] -async fn apply_patch_refuses_to_edit_through_a_symlink() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("real.txt"), "x").unwrap(); - std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link.txt")).unwrap(); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let result = tool.execute(one_edit("link.txt", "x", "y")).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("refusing to edit through symlink")); -} - -#[tokio::test] -async fn apply_patch_reports_unresolvable_and_unreadable_targets() { - let dir = tempfile::tempdir().unwrap(); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let result = tool - .execute(one_edit("absent.txt", "x", "y")) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().starts_with("edit[0]: ")); - assert!(result.output().contains("Failed to resolve")); - - std::fs::write(dir.path().join("bin.dat"), [0xff_u8, 0xfe]).unwrap(); - let result = tool.execute(one_edit("bin.dat", "x", "y")).await.unwrap(); - assert!(result.output().contains("failed to read bin.dat")); -} - -#[tokio::test] -async fn apply_patch_reports_a_create_whose_parent_cannot_be_made() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("file.txt"), "x").unwrap(); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let result = tool - .execute(one_edit("file.txt/child.txt", "", "body")) - .await - .unwrap(); - assert!(result.is_error); - assert!( - result - .output() - .contains("failed to create parent of file.txt/child.txt") - ); -} - -#[tokio::test] -async fn apply_patch_refuses_an_oversized_file() { - let dir = tempfile::tempdir().unwrap(); - let file = std::fs::File::create(dir.path().join("big.txt")).unwrap(); - file.set_len(MAX_FILE_BYTES + 1).unwrap(); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let result = tool.execute(one_edit("big.txt", "x", "y")).await.unwrap(); - assert!(result.output().contains("file too large")); -} - -#[tokio::test] -async fn apply_patch_needs_replace_all_for_repeated_matches() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("r.txt"), "a a a").unwrap(); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let result = tool.execute(one_edit("r.txt", "a", "b")).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("matches 3 times")); - assert_eq!( - std::fs::read_to_string(dir.path().join("r.txt")).unwrap(), - "a a a" - ); - - let result = tool - .execute(json!({"edits": [ - {"path": "r.txt", "old_string": "a", "new_string": "b", "replace_all": true} - ]})) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert_eq!( - std::fs::read_to_string(dir.path().join("r.txt")).unwrap(), - "b b b" - ); -} - -#[tokio::test] -async fn apply_patch_uses_the_context_workspace_when_one_is_threaded() { - let home = tempfile::tempdir().unwrap(); - let isolated = tempfile::tempdir().unwrap(); - std::fs::write(isolated.path().join("w.txt"), "x").unwrap(); - let tool = ApplyPatchTool::new(test_security(home.path().to_path_buf())); - let context = WorkspaceContext::at(isolated.path()); - let result = tool - .execute_with_context( - one_edit("w.txt", "x", "y"), - ToolCallOptions::default(), - Some(&context), - ) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert_eq!( - std::fs::read_to_string(isolated.path().join("w.txt")).unwrap(), - "y" - ); -} - -#[tokio::test] -async fn apply_patch_honours_the_file_state_guard_and_records_writes() { - crate::file_state::init_global(true); - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().canonicalize().unwrap().join("g.txt"); - std::fs::write(&target, "x").unwrap(); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let agent = format!("ap-agent-{}", dir.path().display()); - let other = format!("ap-other-{}", dir.path().display()); - let run = |agent: String| { - crate::file_state::with_file_state_agent_id( - agent, - tool.execute(one_edit("g.txt", "x", "y")), - ) - }; - - crate::file_state::record_read( - &agent, - target.clone(), - std::time::SystemTime::now(), - true, - std::time::Instant::now(), - ); - // A partial read does not block a patch: the anchored match proves the - // agent saw the text it replaces. - let result = run(agent.clone()).await.unwrap(); - assert!(!result.is_error, "{}", result.output()); - std::fs::write(&target, "x").unwrap(); - - crate::file_state::record_read( - &agent, - target.clone(), - std::time::SystemTime::now(), - false, - std::time::Instant::now(), - ); - crate::file_state::record_write(&other, target.clone()); - assert!( - run(agent.clone()) - .await - .unwrap() - .output() - .contains("Stale read") - ); - - crate::file_state::record_read( - &agent, - target.clone(), - std::time::SystemTime::now(), - false, - std::time::Instant::now(), - ); - let result = run(agent.clone()).await.unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert!(crate::file_state::check_stale_read(&agent, &target).is_none()); -} - -/// A create whose file name exceeds the OS limit passes every check and then -/// fails at write time. Whichever order the batch writes in, no file from the -/// failed batch may be left changed, and the error must say it was restored. -#[tokio::test] -async fn apply_patch_restores_earlier_writes_when_a_later_write_fails() { - let long_name = "n".repeat(300); - let mut saw_restore = false; - for _ in 0..40 { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("keep.txt"), "orig").unwrap(); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let result = tool - .execute(json!({"edits": [ - {"path": "keep.txt", "old_string": "orig", "new_string": "changed"}, - {"path": "fresh.txt", "old_string": "", "new_string": "new"}, - {"path": long_name, "old_string": "", "new_string": "boom"}, - ]})) - .await - .unwrap(); - assert!(result.is_error, "{}", result.output()); - assert!(result.output().contains("Failed to write")); - assert_eq!( - std::fs::read_to_string(dir.path().join("keep.txt")).unwrap(), - "orig" - ); - assert!(!dir.path().join("fresh.txt").exists()); - saw_restore |= result.output().contains("restored from snapshot"); - } - assert!( - saw_restore, - "no iteration wrote a file before the failing one" - ); -} - -// Two spellings of one file in a batch must apply both edits (or refuse), -// never hang or lose one. -#[tokio::test] -async fn two_spellings_of_one_path_apply_both_edits() { - let dir = tempfile::TempDir::new().unwrap(); - tokio::fs::write(dir.path().join("a.txt"), "one two three") - .await - .unwrap(); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let result = tokio::time::timeout( - std::time::Duration::from_secs(5), - tool.execute(json!({ - "edits": [ - { "path": "a.txt", "old_string": "one", "new_string": "ONE" }, - { "path": "./a.txt", "old_string": "two", "new_string": "TWO" } - ] - })), - ) - .await - .expect("apply_patch hung on two spellings of one path") - .unwrap(); - let updated = tokio::fs::read_to_string(dir.path().join("a.txt")) - .await - .unwrap(); - assert!( - result.is_error || updated == "ONE TWO three", - "reported {:?} but file is {updated:?}", - result.output() - ); -} - -// The edits to one file, together, must not grow it past the cap the input is -// held to: each edit stays under it, the second one crosses it. -#[tokio::test] -async fn apply_patch_refuses_edits_that_outgrow_the_cap() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("a.txt"), "a".repeat(1024 * 1024)).unwrap(); - let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); - let result = tool - .execute(json!({ - "edits": [ - { "path": "a.txt", "old_string": "a", "new_string": "bbb", "replace_all": true }, - { "path": "a.txt", "old_string": "b", "new_string": "cc", "replace_all": true } - ] - })) - .await - .unwrap(); - let len = std::fs::metadata(dir.path().join("a.txt")).unwrap().len(); - assert!( - result.is_error && len == 1024 * 1024, - "wrote {len} bytes (cap {MAX_FILE_BYTES}): {}", - &result.output()[..result.output().len().min(200)] - ); -} diff --git a/crates/tinytools-std/src/filesystem/contract_tests.rs b/crates/tinytools-std/src/filesystem/contract_tests.rs deleted file mode 100644 index f86b22db..00000000 --- a/crates/tinytools-std/src/filesystem/contract_tests.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Wire-contract fixtures: the name, description, permission level, exposure -//! and JSON Schema each filesystem 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::sync::Arc; - -use serde_json::{Value, json}; -use tinytools::Tool; - -use super::*; -use crate::filesystem::test_support::{AutonomyLevel, TestGate}; - -fn tools() -> Vec<(&'static str, Box)> { - let gate = TestGate::supervised(std::env::temp_dir()); - let workspace = std::env::temp_dir(); - vec![ - ( - "apply_patch", - Box::new(ApplyPatchTool::new(gate.clone())) as Box, - ), - ("csv_export", Box::new(CsvExportTool::new(gate.clone()))), - ("edit_file", Box::new(EditFileTool::new(gate.clone()))), - ("file_read", Box::new(FileReadTool::new(gate.clone()))), - ("file_write", Box::new(FileWriteTool::new(gate.clone()))), - ( - "git_operations", - Box::new(GitOperationsTool::new(gate.clone(), workspace.clone())), - ), - ("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()))), - ( - "run_linter", - Box::new(RunLinterTool::new(workspace.clone())), - ), - ("run_tests", Box::new(RunTestsTool::new(workspace.clone()))), - ( - "update_memory_md", - Box::new(UpdateMemoryMdTool::new(workspace.clone())), - ), - ( - "read_workspace_state", - Box::new(WorkspaceStateTool::new(workspace)), - ), - ] -} - -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 { - "apply_patch" => include_str!("fixtures/apply_patch.json"), - "csv_export" => include_str!("fixtures/csv_export.json"), - "edit_file" => include_str!("fixtures/edit_file.json"), - "file_read" => include_str!("fixtures/file_read.json"), - "file_write" => include_str!("fixtures/file_write.json"), - "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") -} - -#[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 tool_names_are_unique() { - let mut names: Vec = tools().iter().map(|(_, t)| t.name().to_string()).collect(); - names.sort(); - let before = names.len(); - names.dedup(); - assert_eq!(before, names.len()); -} - -#[test] -fn a_gate_can_be_shared_across_tools() { - let gate: Arc = TestGate::supervised(std::env::temp_dir()); - let _read = FileReadTool::new(Arc::clone(&gate)); - let _write = FileWriteTool::new(gate); -} - -fn gate_at(autonomy: AutonomyLevel) -> Arc { - TestGate::with(std::env::temp_dir(), autonomy, 1_000_000) -} - -#[test] -fn write_tools_route_through_approval_only_when_the_gate_asks() { - let existing = tempfile::tempdir().unwrap(); - std::fs::write(existing.path().join("f.txt"), "x").unwrap(); - let existing_arg = json!({"path": existing.path().join("f.txt").to_string_lossy()}); - let new_arg = json!({"path": existing.path().join("missing.txt").to_string_lossy()}); - - for (autonomy, prompts) in [ - (AutonomyLevel::Supervised, true), - (AutonomyLevel::Full, false), - (AutonomyLevel::ReadOnly, false), - ] { - let gate = gate_at(autonomy); - assert_eq!( - EditFileTool::new(gate.clone()).external_effect_with_args(&json!({})), - prompts - ); - assert_eq!( - ApplyPatchTool::new(gate.clone()).external_effect_with_args(&json!({})), - prompts - ); - let write = FileWriteTool::new(gate.clone()); - // Overwriting prompts, creating does not, an unknown path fails safe. - assert_eq!(write.external_effect_with_args(&existing_arg), prompts); - assert!(!write.external_effect_with_args(&new_arg)); - assert_eq!(write.external_effect_with_args(&json!({})), prompts); - let git = GitOperationsTool::new(gate, std::env::temp_dir()); - assert_eq!( - git.external_effect_with_args(&json!({"operation": "commit"})), - prompts - ); - assert!(!git.external_effect_with_args(&json!({"operation": "status"}))); - } -} diff --git a/crates/tinytools-std/src/filesystem/csv_export/extra_tests.rs b/crates/tinytools-std/src/filesystem/csv_export/extra_tests.rs deleted file mode 100644 index 664f61cb..00000000 --- a/crates/tinytools-std/src/filesystem/csv_export/extra_tests.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Refusal, size-label and failure-path tests for `csv_export`. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::{AutonomyLevel, TestGate, WrapGate}; -use tempfile::TempDir; - -fn tool_in(dir: &TempDir) -> CsvExportTool { - CsvExportTool::new(TestGate::supervised(dir.path().to_path_buf())) -} - -fn data(v: &serde_json::Value) -> String { - serde_json::to_string(v).unwrap() -} - -#[tokio::test] -async fn read_only_autonomy_is_refused() { - let dir = TempDir::new().unwrap(); - let gate = TestGate::with(dir.path().to_path_buf(), AutonomyLevel::ReadOnly, 100); - let r = CsvExportTool::new(gate) - .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "x.csv"})) - .await - .unwrap(); - assert!(r.is_error); - assert!(r.output().contains("autonomy is read-only")); -} - -#[tokio::test] -async fn exhausted_hourly_limit_is_refused() { - let dir = TempDir::new().unwrap(); - let gate = TestGate::with(dir.path().to_path_buf(), AutonomyLevel::Supervised, 0); - let r = CsvExportTool::new(gate) - .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "x.csv"})) - .await - .unwrap(); - assert!(r.output().contains("too many actions in the last hour")); -} - -#[tokio::test] -async fn exhausted_action_budget_is_refused_before_writing() { - let dir = TempDir::new().unwrap(); - let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) - .no_budget() - .arc(); - let r = CsvExportTool::new(gate) - .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "x.csv"})) - .await - .unwrap(); - assert!(r.output().contains("action budget exhausted")); - assert!(!dir.path().join("exports/x.csv").exists()); -} - -#[tokio::test] -async fn invalid_json_is_reported() { - let dir = TempDir::new().unwrap(); - let r = tool_in(&dir) - .execute(json!({"data": "{not json", "filename": "x.csv"})) - .await - .unwrap(); - assert!(r.is_error); - assert!(r.output().contains("Failed to parse data as JSON")); -} - -#[tokio::test] -async fn empty_array_is_reported() { - let dir = TempDir::new().unwrap(); - let r = tool_in(&dir) - .execute(json!({"data": "[]", "filename": "x.csv"})) - .await - .unwrap(); - assert!(r.output().contains("nothing to export")); -} - -#[tokio::test] -async fn a_path_the_gate_refuses_is_reported() { - let dir = TempDir::new().unwrap(); - let r = tool_in(&dir) - .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "../escape.csv"})) - .await - .unwrap(); - assert!(r.is_error); - assert!(r.output().contains("Path not allowed")); -} - -#[cfg(unix)] -#[tokio::test] -async fn a_symlinked_target_is_refused() { - let dir = TempDir::new().unwrap(); - std::fs::create_dir(dir.path().join("exports")).unwrap(); - std::fs::write(dir.path().join("real.txt"), "keep").unwrap(); - std::os::unix::fs::symlink( - dir.path().join("real.txt"), - dir.path().join("exports/link.csv"), - ) - .unwrap(); - let r = tool_in(&dir) - .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "link.csv"})) - .await - .unwrap(); - assert!(r.is_error); - assert!(r.output().contains("Refusing to write through symlink")); - assert_eq!( - std::fs::read_to_string(dir.path().join("real.txt")).unwrap(), - "keep" - ); -} - -#[tokio::test] -async fn a_directory_in_the_way_reports_the_write_failure() { - let dir = TempDir::new().unwrap(); - std::fs::create_dir_all(dir.path().join("exports/out.csv")).unwrap(); - let r = tool_in(&dir) - .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "out.csv"})) - .await - .unwrap(); - assert!(r.is_error); - assert!(r.output().contains("Failed to write CSV file")); -} - -#[tokio::test] -async fn size_labels_scale_to_kb_and_mb() { - let dir = TempDir::new().unwrap(); - let kb_rows: Vec<_> = (0..200) - .map(|i| json!({"n": format!("row-{i:04}-padding")})) - .collect(); - let r = tool_in(&dir) - .execute(json!({"data": data(&json!(kb_rows)), "filename": "kb.csv"})) - .await - .unwrap(); - assert!(r.output().contains(" KB)"), "{}", r.output()); - - let big = "x".repeat(1024 * 1024 + 10); - let r = tool_in(&dir) - .execute(json!({"data": data(&json!([{"blob": big}])), "filename": "mb.csv"})) - .await - .unwrap(); - assert!(r.output().contains(" MB)"), "{}", r.output()); -} - -#[test] -fn cells_and_columns_cover_bools_and_non_object_rows() { - assert_eq!(value_to_cell(&json!(true)), "true"); - assert_eq!(value_to_cell(&json!(null)), ""); - assert_eq!(value_to_cell(&json!([1, 2])), "[1,2]"); - assert_eq!(resolve_columns(&[json!(1), json!(2)], None).len(), 0); -} - -#[tokio::test] -async fn execute_with_context_delegates() { - let dir = TempDir::new().unwrap(); - let r = tool_in(&dir) - .execute_with_context( - json!({"data": data(&json!([{"ok": true}])), "filename": "c.csv"}), - ToolCallOptions::default(), - None, - ) - .await - .unwrap(); - assert!(!r.is_error, "{}", r.output()); -} diff --git a/crates/tinytools-std/src/filesystem/csv_export/mod.rs b/crates/tinytools-std/src/filesystem/csv_export/mod.rs deleted file mode 100644 index 7c55155a..00000000 --- a/crates/tinytools-std/src/filesystem/csv_export/mod.rs +++ /dev/null @@ -1,270 +0,0 @@ -use super::gate::{FsGate, gate_for_context}; -use async_trait::async_trait; -use serde_json::json; -use std::sync::Arc; -use tinytools::ToolRunContext; -use tinytools::{Tool, ToolCallOptions, ToolResult}; - -/// Export structured data (JSON array of objects) as a CSV file to the workspace. -#[derive(Debug)] -pub struct CsvExportTool { - gate: Arc, -} - -impl CsvExportTool { - /// Create the tool. - #[must_use] - pub fn new(gate: Arc) -> Self { - Self { gate } - } -} - -/// Escape a value for inclusion in a CSV cell. Wraps the value in -/// double-quotes when it contains commas, quotes, or newlines. Embedded -/// double-quotes are escaped by doubling them per RFC 4180. -fn csv_escape(value: &str) -> String { - if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') { - let escaped = value.replace('"', "\"\""); - format!("\"{escaped}\"") - } else { - value.to_string() - } -} - -/// Convert a `serde_json::Value` into a plain string suitable for a CSV -/// cell. Objects and arrays are serialised as compact JSON; booleans and -/// numbers use their natural representation; nulls become the empty -/// string. -fn value_to_cell(v: &serde_json::Value) -> String { - match v { - serde_json::Value::Null => String::new(), - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - // Nested objects/arrays → compact JSON string - other => other.to_string(), - } -} - -/// Collect column headers from a JSON array. If `columns` is provided, -/// use those in order. Otherwise, collect all keys from the first object -/// in the array (sorted alphabetically — `serde_json` uses `BTreeMap` by -/// default). Callers who need a specific column order should pass the -/// `columns` parameter. -fn resolve_columns(items: &[serde_json::Value], columns: Option<&[String]>) -> Vec { - if let Some(cols) = columns { - return cols.to_vec(); - } - // Collect keys from the first object. - if let Some(first) = items.first() - && let Some(obj) = first.as_object() - { - return obj.keys().cloned().collect(); - } - Vec::new() -} - -/// Render a JSON array of objects into a CSV string. -fn render_csv(items: &[serde_json::Value], columns: &[String]) -> String { - let mut buf = String::new(); - - // Header row - let header: Vec = columns.iter().map(|c| csv_escape(c)).collect(); - buf.push_str(&header.join(",")); - buf.push('\n'); - - // Data rows - for item in items { - let row: Vec = columns - .iter() - .map(|col| { - let cell_value = item.get(col).map(value_to_cell).unwrap_or_default(); - csv_escape(&cell_value) - }) - .collect(); - buf.push_str(&row.join(",")); - buf.push('\n'); - } - - buf -} - -#[async_trait] -impl Tool for CsvExportTool { - fn name(&self) -> &'static str { - "csv_export" - } - - fn description(&self) -> &'static str { - "Export structured data (JSON array of objects) as a CSV file to the workspace. \ - Returns the file path. Use when the user wants raw tabular data from a tool \ - result that's too large to include inline." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "data": { - "type": "string", - "description": "JSON string containing an array of objects to export. Each object becomes a row; keys become column headers." - }, - "filename": { - "type": "string", - "description": "Output filename (without path). Will be written to workspace/exports/. Example: 'github-issues-2026-04-16.csv'" - }, - "columns": { - "type": "array", - "items": { "type": "string" }, - "description": "Optional ordered list of column names to include. If omitted, all keys from the first object are used as headers." - } - }, - "required": ["data", "filename"] - }) - } - - fn permission_level(&self) -> tinytools::PermissionLevel { - tinytools::PermissionLevel::Write - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_in_context(args, None).await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - self.execute_in_context(args, context).await - } -} - -impl CsvExportTool { - async fn execute_in_context( - &self, - args: serde_json::Value, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let data_str = args - .get("data") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'data' parameter"))?; - - let filename = args - .get("filename") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'filename' parameter"))?; - - let columns: Option> = args.get("columns").and_then(|v| { - v.as_array().map(|arr| { - arr.iter() - .filter_map(|item| item.as_str().map(String::from)) - .collect() - }) - }); - - // Security: check write permission - if !self.gate.can_act() { - return Ok(ToolResult::error( - "[policy-blocked] Action blocked: autonomy is read-only", - )); - } - - if self.gate.is_rate_limited() { - return Ok(ToolResult::error( - "Rate limit exceeded: too many actions in the last hour", - )); - } - - // Parse the JSON data - let parsed: serde_json::Value = match serde_json::from_str(data_str) { - Ok(v) => v, - Err(e) => { - return Ok(ToolResult::error(format!( - "Failed to parse data as JSON: {e}" - ))); - } - }; - - let items = match parsed.as_array() { - Some(arr) => arr, - None => { - return Ok(ToolResult::error( - "Data must be a JSON array of objects, but got a non-array value", - )); - } - }; - - if items.is_empty() { - return Ok(ToolResult::error("Data array is empty — nothing to export")); - } - - // Resolve columns and render CSV - let cols = resolve_columns(items, columns.as_deref()); - let csv_content = render_csv(items, &cols); - let csv_bytes = csv_content.len(); - - // Validate the relative path - let relative_path = format!("exports/{filename}"); - - let path_policy = gate_for_context(&self.gate, context, "csv_export"); - - // Security check first: validate path string, resolve symlinks, confirm workspace - // containment. validate_parent_path walks up to the deepest existing ancestor so - // it does not require the exports/ directory to exist yet. - let resolved_target = match path_policy.validate_parent_path(&relative_path).await { - Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(msg)), - }; - - // Create exports/ directory only at the validated, resolved location. - if let Some(resolved_parent) = resolved_target.parent() { - tokio::fs::create_dir_all(resolved_parent).await?; - } - - // If the target already exists and is a symlink, refuse to follow it - if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await - && meta.file_type().is_symlink() - { - return Ok(ToolResult::error(format!( - "Refusing to write through symlink: {}", - resolved_target.display() - ))); - } - - if !self.gate.record_action() { - return Ok(ToolResult::error( - "Rate limit exceeded: action budget exhausted", - )); - } - - // Write the CSV file - match super::file_sink::atomic_write(&resolved_target, csv_content.as_bytes()).await { - Ok(()) => { - let size_display = if csv_bytes >= 1024 * 1024 { - format!("{:.1} MB", csv_bytes as f64 / (1024.0 * 1024.0)) - } else if csv_bytes >= 1024 { - format!("{:.1} KB", csv_bytes as f64 / 1024.0) - } else { - format!("{csv_bytes} bytes") - }; - - Ok(ToolResult::success(format!( - "Exported {} rows to {relative_path} ({size_display})", - items.len() - ))) - } - Err(e) => Ok(ToolResult::error(format!("Failed to write CSV file: {e}"))), - } - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; - -#[cfg(test)] -#[path = "extra_tests.rs"] -mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/csv_export/mod_tests.rs b/crates/tinytools-std/src/filesystem/csv_export/mod_tests.rs deleted file mode 100644 index d2d8cc0e..00000000 --- a/crates/tinytools-std/src/filesystem/csv_export/mod_tests.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! Behavior tests for the `csv_export` tool, driven through a fake [`FsGate`]. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::TestGate; - -fn test_security(workspace: std::path::PathBuf) -> Arc { - TestGate::supervised(workspace) -} - -#[test] -fn csv_export_name() { - let tool = CsvExportTool::new(test_security(std::env::temp_dir())); - assert_eq!(tool.name(), "csv_export"); -} - -#[test] -fn csv_export_schema_has_required_fields() { - let tool = CsvExportTool::new(test_security(std::env::temp_dir())); - let schema = tool.parameters_schema(); - assert!(schema["properties"]["data"].is_object()); - assert!(schema["properties"]["filename"].is_object()); - assert!(schema["properties"]["columns"].is_object()); - let required = schema["required"].as_array().unwrap(); - assert!(required.contains(&json!("data"))); - assert!(required.contains(&json!("filename"))); -} - -#[tokio::test] -async fn csv_export_formats_simple_array() { - let dir = std::env::temp_dir().join("openhuman_test_csv_export_simple"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = CsvExportTool::new(test_security(dir.clone())); - let data = serde_json::to_string(&json!([ - {"name": "Alice", "age": 30, "city": "NYC"}, - {"name": "Bob", "age": 25, "city": "LA"}, - {"name": "Carol", "age": 35, "city": "Chicago"} - ])) - .unwrap(); - - let result = tool - .execute(json!({ - "data": data, - "filename": "people.csv" - })) - .await - .unwrap(); - - assert!(!result.is_error, "unexpected error: {}", result.output()); - assert!(result.output().contains("3 rows")); - - let content = tokio::fs::read_to_string(dir.join("exports/people.csv")) - .await - .unwrap(); - let lines: Vec<&str> = content.trim().lines().collect(); - assert_eq!(lines.len(), 4, "header + 3 data rows"); - - // Header should contain the keys from the first object - let header = lines[0]; - assert!(header.contains("name")); - assert!(header.contains("age")); - assert!(header.contains("city")); - - // Data rows should contain values - assert!(lines[1].contains("Alice")); - assert!(lines[2].contains("Bob")); - assert!(lines[3].contains("Carol")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn csv_export_handles_missing_keys() { - let dir = std::env::temp_dir().join("openhuman_test_csv_export_missing_keys"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = CsvExportTool::new(test_security(dir.clone())); - let data = serde_json::to_string(&json!([ - {"name": "Alice", "age": 30, "city": "NYC"}, - {"name": "Bob"}, - {"name": "Carol", "city": "Chicago"} - ])) - .unwrap(); - - let result = tool - .execute(json!({ - "data": data, - "filename": "sparse.csv", - "columns": ["name", "age", "city"] - })) - .await - .unwrap(); - - assert!(!result.is_error, "unexpected error: {}", result.output()); - - let content = tokio::fs::read_to_string(dir.join("exports/sparse.csv")) - .await - .unwrap(); - let lines: Vec<&str> = content.trim().lines().collect(); - assert_eq!(lines.len(), 4); - - // Bob's row should have empty cells for age and city - let bob_row = lines[2]; - let bob_cells: Vec<&str> = bob_row.split(',').collect(); - assert_eq!(bob_cells.len(), 3, "Bob row should have 3 cells"); - assert_eq!(bob_cells[0], "Bob"); - assert_eq!(bob_cells[1], "", "missing age should be empty"); - assert_eq!(bob_cells[2], "", "missing city should be empty"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn csv_export_respects_column_order() { - let dir = std::env::temp_dir().join("openhuman_test_csv_export_column_order"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = CsvExportTool::new(test_security(dir.clone())); - let data = serde_json::to_string(&json!([ - {"name": "Alice", "age": 30, "city": "NYC"}, - {"name": "Bob", "age": 25, "city": "LA"} - ])) - .unwrap(); - - let result = tool - .execute(json!({ - "data": data, - "filename": "ordered.csv", - "columns": ["city", "name", "age"] - })) - .await - .unwrap(); - - assert!(!result.is_error, "unexpected error: {}", result.output()); - - let content = tokio::fs::read_to_string(dir.join("exports/ordered.csv")) - .await - .unwrap(); - let lines: Vec<&str> = content.trim().lines().collect(); - assert_eq!( - lines[0], "city,name,age", - "header must follow requested column order" - ); - assert_eq!(lines[1], "NYC,Alice,30"); - assert_eq!(lines[2], "LA,Bob,25"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn csv_export_rejects_non_array_input() { - let dir = std::env::temp_dir().join("openhuman_test_csv_export_non_array"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = CsvExportTool::new(test_security(dir.clone())); - let data = serde_json::to_string(&json!({"not": "an array"})).unwrap(); - - let result = tool - .execute(json!({ - "data": data, - "filename": "bad.csv" - })) - .await - .unwrap(); - - assert!(result.is_error); - assert!( - result.output().contains("non-array"), - "error should mention non-array, got: {}", - result.output() - ); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn csv_export_handles_nested_values() { - let dir = std::env::temp_dir().join("openhuman_test_csv_export_nested"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = CsvExportTool::new(test_security(dir.clone())); - let data = serde_json::to_string(&json!([ - { - "name": "Alice", - "tags": ["admin", "dev"], - "meta": {"role": "lead", "level": 5} - }, - { - "name": "Bob", - "tags": [], - "meta": null - } - ])) - .unwrap(); - - let result = tool - .execute(json!({ - "data": data, - "filename": "nested.csv", - "columns": ["name", "tags", "meta"] - })) - .await - .unwrap(); - - assert!(!result.is_error, "unexpected error: {}", result.output()); - - let content = tokio::fs::read_to_string(dir.join("exports/nested.csv")) - .await - .unwrap(); - let lines: Vec<&str> = content.trim().lines().collect(); - assert_eq!(lines.len(), 3, "header + 2 data rows"); - - // Alice's tags should be serialized as a JSON string (in quotes because it contains commas) - let alice_row = lines[1]; - assert!(alice_row.contains("Alice")); - // The JSON array should be serialized as a string and quoted - assert!( - alice_row.contains(r#"[""admin"",""dev""]"#), - "nested arrays should be JSON-serialized in CSV: {alice_row}" - ); - - // Bob's meta is null → empty cell - let bob_row = lines[2]; - assert!(bob_row.contains("Bob")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} diff --git a/crates/tinytools-std/src/filesystem/edit_file/mod.rs b/crates/tinytools-std/src/filesystem/edit_file/mod.rs deleted file mode 100644 index 06b047db..00000000 --- a/crates/tinytools-std/src/filesystem/edit_file/mod.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! `edit` — string-replace edit on a single file. -//! -//! Coding-harness baseline tool (issue #1205). Models the -//! Anthropic/Claude-Code `Edit` semantics: exact-match `old_string` → -//! `new_string` substitution. By default, `old_string` MUST match -//! exactly once in the file (so the model can't accidentally edit -//! every match). Set `replace_all` to override. - -use super::gate::{FsGate, gate_for_context}; -use crate::file_state; -use async_trait::async_trait; -use serde_json::json; -use std::sync::Arc; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; - -const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; - -/// Replace an exact string in a workspace file. -#[derive(Debug)] -pub struct EditFileTool { - gate: Arc, - sink: Arc, -} - -impl EditFileTool { - /// Create the tool. - #[must_use] - pub fn new(gate: Arc) -> Self { - Self { - gate, - sink: super::file_sink::os_sink(), - } - } - - /// Sends this tool's writes somewhere other than the OS. - #[cfg(test)] - pub(super) fn with_sink(mut self, sink: Arc) -> Self { - self.sink = sink; - self - } -} - -#[async_trait] -impl Tool for EditFileTool { - fn exposure(&self) -> tinytools::ToolExposure { - tinytools::ToolExposure::Deferred - } - - fn name(&self) -> &'static str { - "edit" - } - - fn description(&self) -> &'static str { - "Edit a file by exact string replacement. By default `old_string` must \ - match exactly once. Set `replace_all` to true to replace every match." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Workspace-relative file path." }, - "old_string": { "type": "string", "description": "Text to find. Must match exactly." }, - "new_string": { "type": "string", "description": "Replacement text." }, - "replace_all": { - "type": "boolean", - "description": "If true, replace every occurrence (default false).", - "default": false - } - }, - "required": ["path", "old_string", "new_string"] - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write - } - - /// `edit` always modifies an **existing** file → in ask-before-edit it - /// routes through the human approval gate; in Full it runs; read-only is - /// blocked in `execute`. - fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { - self.gate.write_needs_approval() - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_in_context(args, None).await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - self.execute_in_context(args, context).await - } -} - -impl EditFileTool { - async fn execute_in_context( - &self, - args: serde_json::Value, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let path = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; - let old_string = args - .get("old_string") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'old_string' parameter"))?; - let new_string = args - .get("new_string") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'new_string' parameter"))?; - let replace_all = args - .get("replace_all") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - - if old_string.is_empty() { - return Ok(ToolResult::error("`old_string` must not be empty")); - } - if old_string == new_string { - return Ok(ToolResult::error( - "`old_string` and `new_string` are identical — nothing to do", - )); - } - - if !self.gate.can_act() { - return Ok(ToolResult::error( - "[policy-blocked] Action blocked: autonomy is read-only", - )); - } - 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", - )); - } - - let path_policy = gate_for_context(&self.gate, context, "edit"); - let full = path_policy.action_dir().join(path); - - // Symlink check must happen on the *unresolved* path — - // `canonicalize` resolves symlinks, so checking after that point - // would always see the link's final target. - if let Ok(meta) = tokio::fs::symlink_metadata(&full).await - && meta.file_type().is_symlink() - { - return Ok(ToolResult::error(format!( - "Refusing to edit through symlink: {}", - full.display() - ))); - } - - // Security check: validate path string, resolve symlinks, confirm workspace containment. - let resolved = match path_policy.validate_path(path).await { - Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(msg)), - }; - - if let Ok(meta) = tokio::fs::metadata(&resolved).await { - // A FIFO or device would block the read (or never end). - if !meta.is_file() { - return Ok(ToolResult::error(format!("{path} is not a regular file"))); - } - if meta.len() > MAX_FILE_BYTES { - return Ok(ToolResult::error(format!( - "File too large: {} bytes (limit: {MAX_FILE_BYTES} bytes)", - meta.len() - ))); - } - } - - // Acquire per-path lock for the read-modify-write section. - let _path_guard = file_state::acquire_path_lock(&resolved).await; - - // File-state guard: reject edits based on stale reads. A partial read is - // not refused here: an anchored `old_string` match proves the agent saw - // the text it replaces. Only file_write enforces the partial-read guard. - if let Some(agent_id) = file_state::current_file_state_agent_id() - && let Some(msg) = file_state::check_stale_read(&agent_id, &resolved) - { - tracing::debug!( - agent = %agent_id, - path = %resolved.display(), - "[file_state] edit blocked: stale read" - ); - return Ok(ToolResult::error(msg)); - } - - let contents = match tokio::fs::read_to_string(&resolved).await { - Ok(c) => c, - Err(e) => return Ok(ToolResult::error(format!("Failed to read file: {e}"))), - }; - - let count = contents.matches(old_string).count(); - if count == 0 { - return Ok(ToolResult::error(format!( - "`old_string` not found in {path}" - ))); - } - if count > 1 && !replace_all { - return Ok(ToolResult::error(format!( - "`old_string` matches {count} times in {path}; pass `replace_all: true` or \ - expand `old_string` so it is unique" - ))); - } - - // Refuse before building the result: replace_all can multiply a file - // past the cap the input is held to. - let replaced = if replace_all { count } else { 1 }; - let new_len = (contents.len() - replaced * old_string.len()) - .checked_add(replaced.saturating_mul(new_string.len())); - if new_len.is_none_or(|len| len as u64 > MAX_FILE_BYTES) { - return Ok(ToolResult::error(format!( - "Edit would grow {path} past the {MAX_FILE_BYTES}-byte limit" - ))); - } - - let updated = if replace_all { - contents.replace(old_string, new_string) - } else { - contents.replacen(old_string, new_string, 1) - }; - - match self.sink.write(&resolved, updated.as_bytes()).await { - Ok(()) => { - if let Some(agent_id) = file_state::current_file_state_agent_id() { - file_state::record_write(&agent_id, resolved); - } - Ok(ToolResult::success(format!( - "Edited {path}: {count} replacement(s)" - ))) - } - Err(e) => Ok(ToolResult::error(format!("Failed to write file: {e}"))), - } - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs b/crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs deleted file mode 100644 index 400c7cef..00000000 --- a/crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Behavior tests for the `edit_file` tool, driven through a fake [`FsGate`]. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::{AutonomyLevel, TestGate}; - -fn test_security(workspace: std::path::PathBuf) -> Arc { - TestGate::supervised(workspace) -} - -fn test_security_readonly(workspace: std::path::PathBuf) -> Arc { - TestGate::with(workspace, AutonomyLevel::ReadOnly, 1_000_000) -} - -#[test] -fn edit_name() { - let tool = EditFileTool::new(test_security(std::env::temp_dir())); - assert_eq!(tool.name(), "edit"); -} - -#[tokio::test] -async fn edit_replaces_unique_match() { - let dir = std::env::temp_dir().join("openhuman_test_edit_unique"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("f.txt"), "alpha bravo") - .await - .unwrap(); - - let tool = EditFileTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "f.txt", "old_string": "bravo", "new_string": "charlie"})) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - let updated = tokio::fs::read_to_string(dir.join("f.txt")).await.unwrap(); - assert_eq!(updated, "alpha charlie"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn edit_rejects_ambiguous_match() { - let dir = std::env::temp_dir().join("openhuman_test_edit_ambig"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("f.txt"), "x x x").await.unwrap(); - - let tool = EditFileTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "f.txt", "old_string": "x", "new_string": "y"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("matches 3 times")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn edit_replace_all() { - let dir = std::env::temp_dir().join("openhuman_test_edit_all"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("f.txt"), "x x x").await.unwrap(); - - let tool = EditFileTool::new(test_security(dir.clone())); - let result = tool - .execute( - json!({"path": "f.txt", "old_string": "x", "new_string": "y", "replace_all": true}), - ) - .await - .unwrap(); - assert!(!result.is_error); - let updated = tokio::fs::read_to_string(dir.join("f.txt")).await.unwrap(); - assert_eq!(updated, "y y y"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn edit_no_match() { - let dir = std::env::temp_dir().join("openhuman_test_edit_nomatch"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("f.txt"), "alpha").await.unwrap(); - - let tool = EditFileTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "f.txt", "old_string": "zulu", "new_string": "x"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("not found")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn edit_blocks_readonly_mode() { - let dir = std::env::temp_dir().join("openhuman_test_edit_ro"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("f.txt"), "abc").await.unwrap(); - - let tool = EditFileTool::new(test_security_readonly(dir.clone())); - let result = tool - .execute(json!({"path": "f.txt", "old_string": "abc", "new_string": "xyz"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("read-only")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn edit_rejects_empty_old_string() { - let dir = std::env::temp_dir().join("openhuman_test_edit_empty_old"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("f.txt"), "abc").await.unwrap(); - - let tool = EditFileTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "f.txt", "old_string": "", "new_string": "x"})) - .await - .unwrap(); - assert!(result.is_error); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn edit_rejects_identical_strings() { - let dir = std::env::temp_dir().join("openhuman_test_edit_same"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("f.txt"), "abc").await.unwrap(); - - let tool = EditFileTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "f.txt", "old_string": "abc", "new_string": "abc"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("identical")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn edit_reports_an_os_write_failure_rather_than_a_silent_success() { - let dir = std::env::temp_dir().join("openhuman_test_edit_os_write_fail"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - let file = dir.join("f.txt"); - tokio::fs::write(&file, "abc").await.unwrap(); - - let tool = EditFileTool::new(test_security(dir.clone())).with_sink(Arc::new( - crate::filesystem::file_sink::RefusingSink(std::io::ErrorKind::PermissionDenied), - )); - let result = tool - .execute(json!({"path": "f.txt", "old_string": "abc", "new_string": "xyz"})) - .await - .unwrap(); - - assert!( - result.is_error, - "a refused write must surface as an error, not a fabricated success" - ); - assert!(result.output().contains("Failed to write file")); - assert_eq!( - tokio::fs::read_to_string(&file).await.unwrap(), - "abc", - "the file must be left as it was when the write did not happen" - ); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -// ── Coverage of the budget, symlink, size, read and guard branches ────── - -use crate::filesystem::test_support::{RacyGate, WorkspaceContext}; - -fn edit_args(path: &str) -> serde_json::Value { - json!({"path": path, "old_string": "aaa", "new_string": "bbb"}) -} - -#[tokio::test] -async fn edit_reports_rate_limits() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("f.txt"), "aaa").unwrap(); - - let limited = EditFileTool::new(TestGate::with( - dir.path().to_path_buf(), - AutonomyLevel::Supervised, - 0, - )); - let result = limited.execute(edit_args("f.txt")).await.unwrap(); - assert!( - result - .output() - .contains("too many actions in the last hour") - ); - - let racy = EditFileTool::new(Arc::new(RacyGate(test_security(dir.path().to_path_buf())))); - let result = racy.execute(edit_args("f.txt")).await.unwrap(); - assert!(result.output().contains("action budget exhausted")); -} - -#[cfg(unix)] -#[tokio::test] -async fn edit_refuses_to_edit_through_a_symlink() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("real.txt"), "aaa").unwrap(); - std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link.txt")).unwrap(); - let tool = EditFileTool::new(test_security(dir.path().to_path_buf())); - let result = tool.execute(edit_args("link.txt")).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Refusing to edit through symlink")); -} - -#[tokio::test] -async fn edit_reports_a_missing_file_and_unreadable_contents() { - let dir = tempfile::tempdir().unwrap(); - let tool = EditFileTool::new(test_security(dir.path().to_path_buf())); - let result = tool.execute(edit_args("absent.txt")).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Failed to resolve")); - - std::fs::write(dir.path().join("bin.dat"), [0xff_u8, 0xfe, 0xfd]).unwrap(); - let result = tool.execute(edit_args("bin.dat")).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Failed to read file")); -} - -#[tokio::test] -async fn edit_refuses_an_oversized_file() { - let dir = tempfile::tempdir().unwrap(); - let file = std::fs::File::create(dir.path().join("big.txt")).unwrap(); - file.set_len(MAX_FILE_BYTES + 1).unwrap(); - let tool = EditFileTool::new(test_security(dir.path().to_path_buf())); - let result = tool.execute(edit_args("big.txt")).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("File too large")); -} - -#[tokio::test] -async fn edit_uses_the_context_workspace_when_one_is_threaded() { - let home = tempfile::tempdir().unwrap(); - let isolated = tempfile::tempdir().unwrap(); - std::fs::write(isolated.path().join("w.txt"), "aaa").unwrap(); - let tool = EditFileTool::new(test_security(home.path().to_path_buf())); - let context = WorkspaceContext::at(isolated.path()); - let result = tool - .execute_with_context( - edit_args("w.txt"), - ToolCallOptions::default(), - Some(&context), - ) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert_eq!( - std::fs::read_to_string(isolated.path().join("w.txt")).unwrap(), - "bbb" - ); -} - -#[tokio::test] -async fn edit_honours_the_file_state_guard_and_records_its_write() { - crate::file_state::init_global(true); - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().canonicalize().unwrap().join("g.txt"); - std::fs::write(&target, "aaa").unwrap(); - let tool = EditFileTool::new(test_security(dir.path().to_path_buf())); - let agent = format!("ed-agent-{}", dir.path().display()); - let other = format!("ed-other-{}", dir.path().display()); - let run = |agent: String| { - crate::file_state::with_file_state_agent_id(agent, tool.execute(edit_args("g.txt"))) - }; - - crate::file_state::record_read( - &agent, - target.clone(), - std::time::SystemTime::now(), - true, - std::time::Instant::now(), - ); - // A partial read does not block an edit: the anchored match proves the - // agent saw the text it replaces. - let result = run(agent.clone()).await.unwrap(); - assert!(!result.is_error, "{}", result.output()); - std::fs::write(&target, "aaa").unwrap(); - - crate::file_state::record_read( - &agent, - target.clone(), - std::time::SystemTime::now(), - false, - std::time::Instant::now(), - ); - crate::file_state::record_write(&other, target.clone()); - let result = run(agent.clone()).await.unwrap(); - assert!(result.output().contains("Stale read")); - - crate::file_state::record_read( - &agent, - target.clone(), - std::time::SystemTime::now(), - false, - std::time::Instant::now(), - ); - let result = run(agent.clone()).await.unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert!(crate::file_state::check_stale_read(&agent, &target).is_none()); -} - -// replace_all must not grow a file past the cap the input is held to. -#[tokio::test] -async fn edit_refuses_a_replacement_that_outgrows_the_cap() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("a.txt"), "a".repeat(1024 * 1024)).unwrap(); - let tool = EditFileTool::new(test_security(dir.path().to_path_buf())); - let result = tool - .execute(json!({ - "path": "a.txt", - "old_string": "a", - "new_string": "b".repeat(64), - "replace_all": true - })) - .await - .unwrap(); - let len = std::fs::metadata(dir.path().join("a.txt")).unwrap().len(); - assert!( - result.is_error && len == 1024 * 1024, - "wrote {len} bytes (cap {MAX_FILE_BYTES}): {}", - &result.output()[..result.output().len().min(200)] - ); -} - -#[cfg(unix)] -#[tokio::test] -async fn edit_refuses_a_fifo() { - let dir = tempfile::tempdir().unwrap(); - let fifo = dir.path().join("pipe"); - assert!( - std::process::Command::new("mkfifo") - .arg(&fifo) - .status() - .unwrap() - .success() - ); - let tool = EditFileTool::new(test_security(dir.path().to_path_buf())); - let result = tokio::time::timeout( - std::time::Duration::from_secs(5), - tool.execute(json!({"path": "pipe", "old_string": "a", "new_string": "b"})), - ) - .await; - // Give a stuck reader EOF so a failing run can still shut down. - std::thread::spawn(move || drop(std::fs::OpenOptions::new().write(true).open(fifo))); - let result = result.expect("edit blocked on a FIFO").unwrap(); - assert!(result.is_error); - assert!(result.output().contains("not a regular file")); -} diff --git a/crates/tinytools-std/src/filesystem/file_read/extra_tests.rs b/crates/tinytools-std/src/filesystem/file_read/extra_tests.rs deleted file mode 100644 index e384a567..00000000 --- a/crates/tinytools-std/src/filesystem/file_read/extra_tests.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Budget, metadata-failure, file-state and paging-marker tests for `file_read`. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::{TestGate, WorkspaceContext, WrapGate}; -use tempfile::TempDir; - -#[test] -fn reads_are_concurrency_safe() { - let dir = TempDir::new().unwrap(); - let tool = FileReadTool::new(TestGate::supervised(dir.path().to_path_buf())); - assert!(tool.is_concurrency_safe(&json!({}))); -} - -#[tokio::test] -async fn exhausted_action_budget_is_refused() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("a.txt"), "x").unwrap(); - let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) - .no_budget() - .arc(); - let r = FileReadTool::new(gate) - .execute(json!({"path": "a.txt"})) - .await - .unwrap(); - assert!(r.output().contains("action budget exhausted")); -} - -#[tokio::test] -async fn missing_metadata_is_reported() { - let dir = TempDir::new().unwrap(); - let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) - .fixed_path(dir.path().join("vanished.txt")) - .arc(); - let r = FileReadTool::new(gate) - .execute(json!({"path": "vanished.txt"})) - .await - .unwrap(); - assert!(r.is_error); - assert!(r.output().contains("Failed to read file metadata")); -} - -#[tokio::test] -async fn a_read_under_an_agent_id_records_file_state() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("a.txt"), "tracked").unwrap(); - let tool = FileReadTool::new(TestGate::supervised(dir.path().to_path_buf())); - let r = crate::file_state::with_file_state_agent_id( - "agent-file-read-extra".to_string(), - tool.execute(json!({"path": "a.txt"})), - ) - .await - .unwrap(); - assert_eq!(r.output(), "tracked"); -} - -#[tokio::test] -async fn execute_with_context_delegates() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("a.txt"), "ctx").unwrap(); - let tool = FileReadTool::new(TestGate::supervised(dir.path().to_path_buf())); - let ctx = WorkspaceContext::at(dir.path()); - let r = tool - .execute_with_context( - json!({"path": "a.txt"}), - ToolCallOptions::default(), - Some(&ctx), - ) - .await - .unwrap(); - assert_eq!(r.output(), "ctx"); -} - -#[tokio::test] -async fn an_oversized_path_drops_out_of_the_continuation_marker() { - let dir = TempDir::new().unwrap(); - let file = dir.path().join("big.txt"); - std::fs::write(&file, "a".repeat(40 * 1024)).unwrap(); - let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) - .fixed_path(file) - .arc(); - let long_path = "p".repeat(20 * 1024); - let r = FileReadTool::new(gate) - .execute(json!({"path": long_path})) - .await - .unwrap(); - assert!(!r.is_error); - let out = r.output(); - assert!( - out.contains("continue with file_read at \"offset\":"), - "marker missing" - ); - assert!(!out.contains("ppppp")); - assert!(out.len() <= 16 * 1024); -} - -// A paged read must arm the partial-read guard before a full overwrite. -#[tokio::test] -async fn a_paged_read_is_recorded_as_partial() { - crate::file_state::init_global(true); - let dir = TempDir::new().unwrap(); - let target = dir.path().canonicalize().unwrap().join("big.txt"); - std::fs::write(&target, "x".repeat(40 * 1024)).unwrap(); - let tool = FileReadTool::new(TestGate::supervised(dir.path().to_path_buf())); - let agent = format!("partial-{}", dir.path().display()); - let r = crate::file_state::with_file_state_agent_id( - agent.clone(), - tool.execute(json!({"path": "big.txt"})), - ) - .await - .unwrap(); - assert!( - r.output().contains("[file_read page:"), - "not paged: test premise" - ); - assert!( - crate::file_state::check_partial_read(&agent, &target).is_some(), - "a paged read was recorded as a full read" - ); -} - -#[cfg(unix)] -#[tokio::test] -async fn a_fifo_is_refused_not_read() { - let dir = TempDir::new().unwrap(); - let fifo = dir.path().join("pipe"); - assert!( - std::process::Command::new("mkfifo") - .arg(&fifo) - .status() - .unwrap() - .success() - ); - let tool = FileReadTool::new(TestGate::supervised(dir.path().to_path_buf())); - let result = tokio::time::timeout( - std::time::Duration::from_secs(5), - tool.execute(json!({"path": "pipe"})), - ) - .await; - // Give a stuck reader EOF so a failing run can still shut down. - std::thread::spawn(move || drop(std::fs::OpenOptions::new().write(true).open(fifo))); - let result = result.expect("file_read blocked on a FIFO").unwrap(); - assert!(result.is_error); - assert!(result.output().contains("not a regular file")); -} diff --git a/crates/tinytools-std/src/filesystem/file_read/mod.rs b/crates/tinytools-std/src/filesystem/file_read/mod.rs deleted file mode 100644 index bf8efdc2..00000000 --- a/crates/tinytools-std/src/filesystem/file_read/mod.rs +++ /dev/null @@ -1,247 +0,0 @@ -use super::gate::{FsGate, gate_for_context}; -use crate::file_state; -use async_trait::async_trait; -use serde_json::json; -use std::sync::Arc; -use tinytools::ToolRunContext; -use tinytools::{Tool, ToolCallOptions, ToolResult}; - -const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024; -/// Keep a page below the harness's 16 KiB per-result ceiling so the continuation -/// marker survives the result middleware. Returning the whole remainder and -/// relying on that middleware to truncate it loses the next offset and makes a -/// model repeat the same read forever. -const MAX_PAGE_BYTES: usize = 12 * 1024; -const MAX_TOOL_OUTPUT_BYTES: usize = 16 * 1024; - -/// Read file contents with path sandboxing -#[derive(Debug)] -pub struct FileReadTool { - gate: Arc, -} - -impl FileReadTool { - /// Largest file this tool will open. Anything that must stay readable - /// through it (a persisted tool-result artifact) has to fit. - pub const MAX_FILE_SIZE_BYTES: u64 = MAX_FILE_SIZE_BYTES; - - /// Create the tool. - #[must_use] - pub fn new(gate: Arc) -> Self { - Self { gate } - } -} - -#[async_trait] -impl Tool for FileReadTool { - fn name(&self) -> &'static str { - "file_read" - } - - fn description(&self) -> &'static str { - "Read the contents of a file in your working directory (the action sandbox). \ - Relative paths resolve against that directory; paths outside it are blocked. \ - To read a file written by `shell`, confirm its location with `pwd` and use the \ - same relative path. Long files are returned one page at a time; continue with \ - the exact `offset` reported at the end of the page." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Relative path to the file within the workspace" - }, - "offset": { - "type": "integer", - "minimum": 0, - "description": "Byte offset to start reading from. Use the offset a paged read reports to continue it." - } - }, - "required": ["path"] - }) - } - - /// Pure read — safe to fan out across parallel `file_read` calls. - fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { - true - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_in_context(args, None).await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - self.execute_in_context(args, context).await - } -} - -impl FileReadTool { - async fn execute_in_context( - &self, - args: serde_json::Value, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let path = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; - - if self.gate.is_rate_limited() { - return Ok(ToolResult::error( - "Rate limit exceeded: too many actions in the last hour", - )); - } - - // Record action BEFORE validation so that every non-trivially-rejected - // request consumes rate limit budget. This prevents attackers from probing - // path existence (via canonicalize errors) without rate limit cost. - if !self.gate.record_action() { - return Ok(ToolResult::error( - "Rate limit exceeded: action budget exhausted", - )); - } - - // Security check: validate path string, resolve symlinks, confirm workspace containment. - let path_policy = gate_for_context(&self.gate, context, "file_read"); - let resolved_path = match path_policy.validate_path(path).await { - Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(msg)), - }; - - // Captured before any read I/O: `file_state` compares it against - // sibling agents' writes to decide whether this read may already be stale. - let read_started = std::time::Instant::now(); - - // Check file size AFTER canonicalization to prevent TOCTOU symlink bypass - match tokio::fs::metadata(&resolved_path).await { - Ok(meta) => { - // A FIFO or device would block the read (or never end). - if !meta.is_file() { - return Ok(ToolResult::error(format!("{path} is not a regular file"))); - } - if meta.len() > MAX_FILE_SIZE_BYTES { - return Ok(ToolResult::error(format!( - "File too large: {} bytes (limit: {MAX_FILE_SIZE_BYTES} bytes)", - meta.len() - ))); - } - } - Err(e) => { - return Ok(ToolResult::error(format!( - "Failed to read file metadata: {e}" - ))); - } - } - - match tokio::fs::read_to_string(&resolved_path).await { - Ok(contents) => { - // An absent or null offset reads from the start; anything else - // that is not a non-negative integer is rejected, so a - // malformed continuation never silently re-reads the file from - // byte 0. - let offset = match args.get("offset") { - None | Some(serde_json::Value::Null) => 0, - Some(value) => match value.as_u64().and_then(|o| usize::try_from(o).ok()) { - Some(offset) => offset, - None => { - return Ok(ToolResult::error(format!( - "offset must be a non-negative integer byte offset, got {value}" - ))); - } - }, - }; - if offset > contents.len() { - return Ok(ToolResult::error(format!( - "offset {offset} is past the end of the file ({} bytes)", - contents.len() - ))); - } - // Rejected rather than snapped to a boundary: a caller paging - // from `offset` computes the next offset from where it asked to - // start, so a silent move backwards would skip or repeat bytes. - if !contents.is_char_boundary(offset) { - return Ok(ToolResult::error(format!( - "offset {offset} falls inside a multi-byte character; continue from the offset a paged read reports" - ))); - } - if let Some(agent_id) = file_state::current_file_state_agent_id() { - let mtime = tokio::fs::metadata(&resolved_path) - .await - .ok() - .and_then(|m| m.modified().ok()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); - // Partial unless this read returns the whole file: the - // negation of `page_contents`' whole-file branch. - let partial = offset > 0 || contents.len() - offset > MAX_PAGE_BYTES; - file_state::record_read(&agent_id, resolved_path, mtime, partial, read_started); - } - Ok(ToolResult::success(page_contents(&contents, path, offset))) - } - Err(e) => Ok(ToolResult::error(format!("Failed to read file: {e}"))), - } - } -} - -fn page_contents(contents: &str, path: &str, offset: usize) -> String { - let initial_end = offset.saturating_add(MAX_PAGE_BYTES).min(contents.len()); - if initial_end == contents.len() { - return contents[offset..].to_string(); - } - - let path_json = - serde_json::to_string(path).unwrap_or_else(|_| "\"\"".to_string()); - let marker_with_path = |end: usize| { - format!( - "\n\n[file_read page: bytes {offset}..{end} of {}; continue with file_read \ - {{\"path\":{path_json},\"offset\":{end}}}]", - contents.len() - ) - }; - let marker_without_path = |end: usize| { - format!( - "\n\n[file_read page: bytes {offset}..{end} of {}; continue with file_read at \ - \"offset\":{end}]", - contents.len() - ) - }; - - // Size against the longest offset this page can report. If the escaped path - // consumes too much of the middleware budget, omit it: the caller already - // has the path and the continuation offset is the irreplaceable part. - let longest_with_path = marker_with_path(initial_end); - let include_path = longest_with_path.len() + 4 <= MAX_TOOL_OUTPUT_BYTES; - let marker_len = if include_path { - longest_with_path.len() - } else { - marker_without_path(initial_end).len() - }; - let content_budget = MAX_PAGE_BYTES.min(MAX_TOOL_OUTPUT_BYTES.saturating_sub(marker_len)); - let mut end = offset.saturating_add(content_budget).min(contents.len()); - while end > offset && !contents.is_char_boundary(end) { - end -= 1; - } - let marker = if include_path { - marker_with_path(end) - } else { - marker_without_path(end) - }; - let mut page = contents[offset..end].to_string(); - page.push_str(&marker); - page -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; - -#[cfg(test)] -#[path = "extra_tests.rs"] -mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/file_read/mod_tests.rs b/crates/tinytools-std/src/filesystem/file_read/mod_tests.rs deleted file mode 100644 index 6eef9e52..00000000 --- a/crates/tinytools-std/src/filesystem/file_read/mod_tests.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! Behavior tests for the `file_read` tool, driven through a fake [`FsGate`]. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::{AutonomyLevel, TestGate}; - -fn test_security(workspace: std::path::PathBuf) -> Arc { - TestGate::supervised(workspace) -} - -fn test_security_with( - workspace: std::path::PathBuf, - autonomy: AutonomyLevel, - max_actions_per_hour: usize, -) -> Arc { - TestGate::with(workspace, autonomy, max_actions_per_hour) -} - -#[test] -fn file_read_name() { - let tool = FileReadTool::new(test_security(std::env::temp_dir())); - assert_eq!(tool.name(), "file_read"); -} - -#[test] -fn file_read_schema_has_path() { - let tool = FileReadTool::new(test_security(std::env::temp_dir())); - let schema = tool.parameters_schema(); - assert!(schema["properties"]["path"].is_object()); - assert!( - schema["required"] - .as_array() - .unwrap() - .contains(&json!("path")) - ); -} - -#[tokio::test] -async fn file_read_existing_file() { - let dir = std::env::temp_dir().join("openhuman_test_file_read"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("test.txt"), "hello world") - .await - .unwrap(); - - let tool = FileReadTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"path": "test.txt"})).await.unwrap(); - assert!(!result.is_error); - assert_eq!(result.output(), "hello world"); - assert!(!result.is_error); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_read_nonexistent_file() { - let dir = std::env::temp_dir().join("openhuman_test_file_read_missing"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileReadTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"path": "nope.txt"})).await.unwrap(); - assert!(result.is_error); - assert!(&result.output().contains("Failed to resolve")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_read_blocks_path_traversal() { - let dir = std::env::temp_dir().join("openhuman_test_file_read_traversal"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileReadTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "../../../etc/passwd"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(&result.output().contains("not allowed")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_read_blocks_absolute_path() { - let tool = FileReadTool::new(test_security(std::env::temp_dir())); - let result = tool.execute(json!({"path": "/etc/passwd"})).await.unwrap(); - assert!(result.is_error); - assert!(&result.output().contains("not allowed")); -} - -#[tokio::test] -async fn file_read_blocks_when_rate_limited() { - let dir = std::env::temp_dir().join("openhuman_test_file_read_rate_limited"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("test.txt"), "hello world") - .await - .unwrap(); - - let tool = FileReadTool::new(test_security_with( - dir.clone(), - AutonomyLevel::Supervised, - 0, - )); - let result = tool.execute(json!({"path": "test.txt"})).await.unwrap(); - - assert!(result.is_error); - assert!(result.output().contains("Rate limit exceeded")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_read_allows_readonly_mode() { - let dir = std::env::temp_dir().join("openhuman_test_file_read_readonly"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("test.txt"), "readonly ok") - .await - .unwrap(); - - let tool = FileReadTool::new(test_security_with(dir.clone(), AutonomyLevel::ReadOnly, 20)); - let result = tool.execute(json!({"path": "test.txt"})).await.unwrap(); - - assert!(!result.is_error); - assert_eq!(result.output(), "readonly ok"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_read_missing_path_param() { - let tool = FileReadTool::new(test_security(std::env::temp_dir())); - let result = tool.execute(json!({})).await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn file_read_empty_file() { - let dir = std::env::temp_dir().join("openhuman_test_file_read_empty"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("empty.txt"), "").await.unwrap(); - - let tool = FileReadTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"path": "empty.txt"})).await.unwrap(); - assert!(!result.is_error); - assert_eq!(result.output(), ""); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_read_nested_path() { - let dir = std::env::temp_dir().join("openhuman_test_file_read_nested"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(dir.join("sub/dir")) - .await - .unwrap(); - tokio::fs::write(dir.join("sub/dir/deep.txt"), "deep content") - .await - .unwrap(); - - let tool = FileReadTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "sub/dir/deep.txt"})) - .await - .unwrap(); - assert!(!result.is_error); - assert_eq!(result.output(), "deep content"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[cfg(unix)] -#[tokio::test] -async fn file_read_blocks_symlink_escape() { - use std::os::unix::fs::symlink; - - let root = std::env::temp_dir().join("openhuman_test_file_read_symlink_escape"); - let workspace = root.join("workspace"); - let outside = root.join("outside"); - - let _ = tokio::fs::remove_dir_all(&root).await; - tokio::fs::create_dir_all(&workspace).await.unwrap(); - tokio::fs::create_dir_all(&outside).await.unwrap(); - - tokio::fs::write(outside.join("secret.txt"), "outside workspace") - .await - .unwrap(); - - symlink(outside.join("secret.txt"), workspace.join("escape.txt")).unwrap(); - - let tool = FileReadTool::new(test_security(workspace.clone())); - let result = tool.execute(json!({"path": "escape.txt"})).await.unwrap(); - - assert!(result.is_error); - // After the symlink-safe canonical check landed in - // SecurityPolicy::is_path_allowed (#1927), the policy layer blocks - // the escape before file_read's own resolved-path check runs — the - // error becomes "Path not allowed by security policy". Either - // message signals defense-in-depth worked. - let out = result.output(); - assert!( - out.contains("escapes workspace") || out.contains("not allowed"), - "expected escape/not-allowed error, got: {out}" - ); - - let _ = tokio::fs::remove_dir_all(&root).await; -} - -#[tokio::test] -async fn file_read_nonexistent_consumes_rate_limit_budget() { - let dir = std::env::temp_dir().join("openhuman_test_file_read_probe"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - // Allow only 2 actions total - let tool = FileReadTool::new(test_security_with( - dir.clone(), - AutonomyLevel::Supervised, - 2, - )); - - // Both reads fail (file doesn't exist) but should consume budget - let r1 = tool.execute(json!({"path": "nope1.txt"})).await.unwrap(); - assert!(r1.is_error); - assert!(r1.output().contains("Failed to resolve")); - - let r2 = tool.execute(json!({"path": "nope2.txt"})).await.unwrap(); - assert!(r2.is_error); - assert!(r2.output().contains("Failed to resolve")); - - // Third attempt should be rate limited even though file doesn't exist - let r3 = tool.execute(json!({"path": "nope3.txt"})).await.unwrap(); - assert!(r3.is_error); - let r3_output = r3.output(); - assert!( - r3_output.contains("Rate limit"), - "Expected rate limit error, got: {r3_output:?}" - ); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_read_rejects_oversized_file() { - let dir = std::env::temp_dir().join("openhuman_test_file_read_large"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - // Create a file just over 10 MB - let big = vec![b'x'; 10 * 1024 * 1024 + 1]; - tokio::fs::write(dir.join("huge.bin"), &big).await.unwrap(); - - let tool = FileReadTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"path": "huge.bin"})).await.unwrap(); - assert!(result.is_error); - assert!(&result.output().contains("File too large")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_read_reports_invalid_utf8_as_an_error_not_a_panic() { - let dir = std::env::temp_dir().join("openhuman_test_file_read_non_utf8"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - // Passes the size cap, fails `read_to_string`: 0x80 is a lone UTF-8 - // continuation byte with no lead byte, invalid at any position. - tokio::fs::write(dir.join("binary.dat"), [0xFFu8, 0xFE, 0x00, 0x80]) - .await - .unwrap(); - - let tool = FileReadTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"path": "binary.dat"})).await.unwrap(); - assert!( - result.is_error, - "invalid UTF-8 must surface as a tool error, not succeed with replaced/garbled text" - ); - assert!(&result.output().contains("Failed to read file")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_read_offset_continues_from_the_given_byte() { - let dir = tempfile::tempdir().unwrap(); - tokio::fs::write(dir.path().join("paged.txt"), "hello world") - .await - .unwrap(); - let tool = FileReadTool::new(test_security(dir.path().to_path_buf())); - - let page = tool - .execute(json!({"path": "paged.txt", "offset": 6})) - .await - .unwrap(); - assert!(!page.is_error, "{}", page.output()); - assert_eq!( - page.output(), - "world", - "a read with an offset must start at that byte" - ); - - let past_end = tool - .execute(json!({"path": "paged.txt", "offset": 99})) - .await - .unwrap(); - assert!( - past_end.is_error, - "an offset past the end must be reported, not returned as an empty read" - ); -} - -#[tokio::test] -async fn file_read_pages_long_files_and_reports_a_working_continuation() { - let dir = tempfile::tempdir().unwrap(); - let contents = format!("{}éTAIL", "x".repeat(MAX_PAGE_BYTES - 1)); - tokio::fs::write(dir.path().join("long.txt"), &contents) - .await - .unwrap(); - let tool = FileReadTool::new(test_security(dir.path().to_path_buf())); - - let first = tool.execute(json!({"path": "long.txt"})).await.unwrap(); - assert!(!first.is_error, "{}", first.output()); - assert!( - first.output().contains("continue with file_read"), - "a truncated page must tell the caller exactly how to continue: {}", - first.output() - ); - assert!( - first.output().len() < 16 * 1024, - "the page and marker must survive the harness's per-result budget" - ); - let marker = first - .output() - .split("\"offset\":") - .nth(1) - .and_then(|tail| tail.split('}').next()) - .and_then(|value| value.parse::().ok()) - .expect("continuation offset in marker"); - assert!(contents.is_char_boundary(marker)); - - let second = tool - .execute(json!({"path": "long.txt", "offset": marker})) - .await - .unwrap(); - assert!(!second.is_error, "{}", second.output()); - assert_eq!( - format!("{}{}", &first.output()[..marker], second.output()), - contents - ); -} - -#[test] -fn file_read_reserves_the_continuation_marker_for_a_long_escaped_path() { - let contents = "x".repeat(MAX_PAGE_BYTES * 2); - let path = format!("{}quoted\\\"name.txt", "nested\\\\".repeat(900)); - let page = page_contents(&contents, &path, 0); - - assert!(page.len() <= MAX_TOOL_OUTPUT_BYTES, "{}", page.len()); - assert!(page.contains("continue with file_read")); - assert!(page.contains("\"offset\":")); - assert!( - !page.contains(&path), - "an oversized escaped path should be omitted so the offset survives" - ); -} - -#[tokio::test] -async fn file_read_rejects_an_offset_inside_a_multibyte_character() { - let dir = tempfile::tempdir().unwrap(); - // "é" is two bytes, so byte 1 is inside it. - tokio::fs::write(dir.path().join("utf8.txt"), "é world") - .await - .unwrap(); - let tool = FileReadTool::new(test_security(dir.path().to_path_buf())); - - let inside = tool - .execute(json!({"path": "utf8.txt", "offset": 1})) - .await - .unwrap(); - assert!( - inside.is_error, - "an offset inside a character must be rejected, not moved, or paging skips bytes: {}", - inside.output() - ); - - let boundary = tool - .execute(json!({"path": "utf8.txt", "offset": 2})) - .await - .unwrap(); - assert_eq!(boundary.output(), " world"); -} - -#[tokio::test] -async fn file_read_rejects_an_offset_that_is_not_a_non_negative_integer() { - let dir = tempfile::tempdir().unwrap(); - tokio::fs::write(dir.path().join("offsets.txt"), "hello world") - .await - .unwrap(); - let tool = FileReadTool::new(test_security(dir.path().to_path_buf())); - - for bad in [json!(-5), json!("6"), json!(1.5)] { - let result = tool - .execute(json!({"path": "offsets.txt", "offset": bad.clone()})) - .await - .unwrap(); - assert!( - result.is_error, - "offset {bad} must be rejected, not treated as 0: got {:?}", - result.output() - ); - } - - let null_offset = tool - .execute(json!({"path": "offsets.txt", "offset": null})) - .await - .unwrap(); - assert_eq!( - null_offset.output(), - "hello world", - "a null offset reads from the start" - ); -} diff --git a/crates/tinytools-std/src/filesystem/file_sink.rs b/crates/tinytools-std/src/filesystem/file_sink.rs deleted file mode 100644 index 1ed9fd4c..00000000 --- a/crates/tinytools-std/src/filesystem/file_sink.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! The filesystem write the tools go through. -//! -//! Every tool that reports "the write failed" has a branch that only runs when -//! the OS refuses, and that branch is the one worth asserting: it decides -//! whether a failed edit is reported honestly or fabricated as a success. -//! -//! Provoking a real refusal from a test is where this gets awkward. A -//! mode-bit denial is a discretionary check, and a root test runner carries -//! `CAP_DAC_OVERRIDE` and writes through it — so a fixture built on -//! permissions succeeds under CI, and the case either fails on a premise that -//! never held or is skipped and asserts nothing at all. Both read as coverage. -//! -//! So the write is a seam instead. Production installs the OS; a test installs -//! one that refuses, and the reporting branch becomes ordinary to assert on -//! any platform and as any user. - -use std::path::Path; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; - -use async_trait::async_trait; -use tokio::io::AsyncWriteExt; - -/// Where a tool's file writes land. -#[async_trait] -pub(super) trait FileSink: std::fmt::Debug + Send + Sync { - async fn write(&self, path: &Path, contents: &[u8]) -> std::io::Result<()>; -} - -/// The real filesystem. -#[derive(Debug)] -pub(super) struct OsFileSink; - -#[async_trait] -impl FileSink for OsFileSink { - async fn write(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> { - atomic_write(path, contents).await - } -} - -/// Monotonic counter for temp-file names within a process. -static TEMP_FILE_SEQ: AtomicU64 = AtomicU64::new(0); - -/// Atomically replace `path`'s contents with `contents`. -/// -/// Writes a fresh (`create_new`) sibling temp file in the same directory, so -/// the rename stays on one filesystem, gives it the target's permissions, and -/// `rename`s it over the target. A crash mid-write leaves the old file or the -/// complete new one, never a truncation, and a symlink at `path` is replaced -/// rather than followed. The rename installs a new inode: other hard links -/// keep the old contents, and the file is owned by the writing user. -pub(super) async fn atomic_write(path: &Path, contents: &[u8]) -> std::io::Result<()> { - let dir = path.parent().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{} has no parent directory", path.display()), - ) - })?; - let name = path.file_name().unwrap_or_default().to_string_lossy(); - let seq = TEMP_FILE_SEQ.fetch_add(1, Ordering::Relaxed); - let tmp = dir.join(format!(".{name}.{}.{seq}.tmp", std::process::id())); - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&tmp) - .await - .map_err(|e| std::io::Error::new(e.kind(), format!("stage temp file: {e}")))?; - let mut result = stage(&mut file, path, contents).await; - drop(file); - if result.is_ok() { - result = tokio::fs::rename(&tmp, path).await; - } - if result.is_err() { - let _ = tokio::fs::remove_file(&tmp).await; - } - result -} - -/// Fill the temp file, copy the target's permissions onto it (or `+x` is -/// lost on the rename), and flush it to disk before the rename. -async fn stage(file: &mut tokio::fs::File, target: &Path, contents: &[u8]) -> std::io::Result<()> { - file.write_all(contents).await?; - if let Ok(meta) = tokio::fs::metadata(target).await { - file.set_permissions(meta.permissions()).await?; - } - file.sync_all().await -} - -/// The sink a tool uses unless a caller installs another one. -pub(super) fn os_sink() -> Arc { - Arc::new(OsFileSink) -} - -/// A sink that refuses every write with `kind`, for asserting the branch a -/// tool takes when the filesystem says no. -#[cfg(test)] -#[derive(Debug)] -pub(super) struct RefusingSink(pub std::io::ErrorKind); - -#[cfg(test)] -#[async_trait] -impl FileSink for RefusingSink { - async fn write(&self, path: &Path, _contents: &[u8]) -> std::io::Result<()> { - Err(std::io::Error::new( - self.0, - format!("refused by the test sink: {}", path.display()), - )) - } -} - -#[cfg(test)] -#[path = "file_sink_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/filesystem/file_sink_tests.rs b/crates/tinytools-std/src/filesystem/file_sink_tests.rs deleted file mode 100644 index f4678b30..00000000 --- a/crates/tinytools-std/src/filesystem/file_sink_tests.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! The OS sink replaces a file atomically: through a temp file and a rename. - -#![allow(clippy::unwrap_used)] - -use super::*; - -// A symlink swapped in after a tool's symlink check must not redirect the -// write: the rename replaces the link itself, and its target is untouched. -#[cfg(unix)] -#[tokio::test] -async fn a_write_replaces_a_symlink_instead_of_following_it() { - let dir = tempfile::tempdir().unwrap(); - let outside = tempfile::tempdir().unwrap(); - let secret = outside.path().join("secret.txt"); - std::fs::write(&secret, "secret").unwrap(); - let link = dir.path().join("a.txt"); - std::os::unix::fs::symlink(&secret, &link).unwrap(); - - OsFileSink.write(&link, b"new").await.unwrap(); - - assert_eq!(std::fs::read_to_string(&secret).unwrap(), "secret"); - assert!(!std::fs::symlink_metadata(&link).unwrap().is_symlink()); - assert_eq!(std::fs::read_to_string(&link).unwrap(), "new"); -} - -// The rename installs a new inode, so the target's mode bits are copied to -// it first: an executable script stays executable after an edit. -#[cfg(unix)] -#[tokio::test] -async fn a_write_keeps_the_targets_permissions() { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); - let script = dir.path().join("run.sh"); - std::fs::write(&script, "old").unwrap(); - std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o750)).unwrap(); - - OsFileSink.write(&script, b"new").await.unwrap(); - - let mode = std::fs::metadata(&script).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o750); - assert_eq!(std::fs::read_to_string(&script).unwrap(), "new"); - let leftovers = std::fs::read_dir(dir.path()).unwrap().count(); - assert_eq!(leftovers, 1, "a temp file was left behind"); -} - -#[tokio::test] -async fn failed_writes_report_why_and_leave_no_temp_file() { - let no_parent = atomic_write(Path::new("/"), b"x").await.unwrap_err(); - assert_eq!(no_parent.kind(), std::io::ErrorKind::InvalidInput); - - let dir = tempfile::tempdir().unwrap(); - let missing_dir = atomic_write(&dir.path().join("nope").join("a.txt"), b"x") - .await - .unwrap_err(); - assert!(missing_dir.to_string().contains("stage temp file")); - - // Renaming a file over a directory fails after the temp file was staged. - let target = dir.path().join("a.txt"); - std::fs::create_dir(&target).unwrap(); - assert!(atomic_write(&target, b"x").await.is_err()); - assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); -} diff --git a/crates/tinytools-std/src/filesystem/file_write/mod.rs b/crates/tinytools-std/src/filesystem/file_write/mod.rs deleted file mode 100644 index dfd49163..00000000 --- a/crates/tinytools-std/src/filesystem/file_write/mod.rs +++ /dev/null @@ -1,254 +0,0 @@ -use super::gate::{FsGate, gate_for_context}; -use crate::file_state; -use async_trait::async_trait; -use serde_json::json; -use std::sync::Arc; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; - -const MAX_CONTENT_BYTES: usize = 5 * 1024 * 1024; - -/// Write file contents with path sandboxing -#[derive(Debug)] -pub struct FileWriteTool { - gate: Arc, - approval_workspace_root: Option, - sink: Arc, -} - -impl FileWriteTool { - /// Create the tool. - #[must_use] - pub fn new(gate: Arc) -> Self { - Self { - gate, - approval_workspace_root: None, - sink: super::file_sink::os_sink(), - } - } - - /// Sends this tool's writes somewhere other than the OS. - #[cfg(test)] - pub(super) fn with_sink(mut self, sink: Arc) -> Self { - self.sink = sink; - self - } - - /// Use the same effective workspace root for approval routing that tool - /// execution receives through its `ToolExecutionContext`. - pub fn with_approval_workspace_root( - gate: Arc, - approval_workspace_root: std::path::PathBuf, - ) -> Self { - Self { - gate, - approval_workspace_root: Some(approval_workspace_root), - sink: super::file_sink::os_sink(), - } - } -} - -#[async_trait] -impl Tool for FileWriteTool { - fn name(&self) -> &'static str { - "file_write" - } - - fn description(&self) -> &'static str { - "Write contents to a file in your working directory (the action sandbox). \ - Relative paths resolve against that directory; writes outside it are blocked. \ - Reference the file later by the same relative path so `file_read` resolves to it." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Relative path to the file within the workspace" - }, - "content": { - "type": "string", - "description": "Content to write to the file" - } - }, - "required": ["path", "content"] - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write - } - - /// "Ask before edit": modifying an **existing** file routes through the - /// human approval gate in ask-before-edit mode; creating a **new** file is - /// free. In Full neither prompts; in read-only `execute` blocks via - /// `can_act()`. The existence probe is best-effort (relative to the - /// workspace); when the path can't be resolved we fail safe and prompt. - /// - /// The probe runs at gate-routing time, microseconds before `execute()` in - /// the same sequential turn. A create→edit flip in that window would require - /// an external process to win the race — outside this gate's threat model, - /// which governs the agent, not concurrent writers — and `execute()` still - /// enforces workspace containment + symlink refusal regardless, so a write - /// that slips through as "create" cannot escape the sandbox. `execute()` - /// does re-probe for one case: a context workspace can resolve the path - /// somewhere other than this probe's root, and an existing file there that - /// this probe saw as new is refused rather than overwritten unapproved. - fn external_effect_with_args(&self, args: &serde_json::Value) -> bool { - if !self.gate.write_needs_approval() { - return false; // Full (allow) or read-only (blocked in execute) - } - let Some(path) = args.get("path").and_then(|v| v.as_str()) else { - return true; // unknown path → prompt (fail safe) - }; - let target = if std::path::Path::new(path).is_absolute() { - std::path::PathBuf::from(path) - } else { - self.approval_workspace_root - .as_deref() - .unwrap_or_else(|| self.gate.action_dir()) - .join(path) - }; - // Sync `stat` — intentionally blocking, since the `Tool` trait makes - // this method sync. Fast for local paths; would only need - // `block_in_place` if a remote/slow filesystem is ever supported here. - target.exists() // exists = edit → prompt; new = create → free - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_in_context(args, None).await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - self.execute_in_context(args, context).await - } -} - -impl FileWriteTool { - async fn execute_in_context( - &self, - args: serde_json::Value, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let path = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; - - let content = args - .get("content") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?; - - if !self.gate.can_act() { - return Ok(ToolResult::error( - "[policy-blocked] Action blocked: autonomy is read-only", - )); - } - - if content.len() > MAX_CONTENT_BYTES { - return Ok(ToolResult::error(format!( - "Content too large: {} bytes (limit: {MAX_CONTENT_BYTES} bytes)", - content.len() - ))); - } - - if self.gate.is_rate_limited() { - return Ok(ToolResult::error( - "Rate limit exceeded: too many actions in the last hour", - )); - } - - // Security check first: validate path string, resolve symlinks, confirm workspace - // containment. validate_parent_path walks up to the deepest existing ancestor so - // it does not require the parent directory to exist yet. - let path_policy = gate_for_context(&self.gate, context, "file_write"); - let resolved_target = match path_policy.validate_parent_path(path).await { - Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(msg)), - }; - - // Re-probe: the approval probe resolved `path` against the approval - // root, but a context workspace can move the write elsewhere. A path it - // saw as new (no approval asked) that exists here would be an - // unapproved overwrite. - if self.gate.write_needs_approval() - && !self.external_effect_with_args(&args) - && tokio::fs::symlink_metadata(&resolved_target).await.is_ok() - { - return Ok(ToolResult::error(format!( - "{path} already exists in this workspace; overwriting it needs approval. \ - Use the edit tool to change it" - ))); - } - - // Create parent directory only at the validated, resolved location. - if let Some(resolved_parent) = resolved_target.parent() { - tokio::fs::create_dir_all(resolved_parent).await?; - } - - // If the target already exists and is a symlink, refuse to follow it - if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await - && meta.file_type().is_symlink() - { - return Ok(ToolResult::error(format!( - "Refusing to write through symlink: {}", - resolved_target.display() - ))); - } - - if !self.gate.record_action() { - return Ok(ToolResult::error( - "Rate limit exceeded: action budget exhausted", - )); - } - - // Per-path lock, as edit_file and apply_patch take: the stale check and - // the write must not interleave with another tool's edit of the file. - let _path_guard = file_state::acquire_path_lock(&resolved_target).await; - - // File-state guard: reject writes based on stale or partial reads. - if let Some(agent_id) = file_state::current_file_state_agent_id() { - if let Some(msg) = file_state::check_stale_read(&agent_id, &resolved_target) { - tracing::debug!( - agent = %agent_id, - path = %resolved_target.display(), - "[file_state] file_write blocked: stale read" - ); - return Ok(ToolResult::error(msg)); - } - if let Some(msg) = file_state::check_partial_read(&agent_id, &resolved_target) { - tracing::debug!( - agent = %agent_id, - path = %resolved_target.display(), - "[file_state] file_write blocked: partial read" - ); - return Ok(ToolResult::error(msg)); - } - } - - match self.sink.write(&resolved_target, content.as_bytes()).await { - Ok(()) => { - if let Some(agent_id) = file_state::current_file_state_agent_id() { - file_state::record_write(&agent_id, resolved_target); - } - Ok(ToolResult::success(format!( - "Written {} bytes to {path}", - content.len() - ))) - } - Err(e) => Ok(ToolResult::error(format!("Failed to write file: {e}"))), - } - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/file_write/mod_tests.rs b/crates/tinytools-std/src/filesystem/file_write/mod_tests.rs deleted file mode 100644 index e8946bb7..00000000 --- a/crates/tinytools-std/src/filesystem/file_write/mod_tests.rs +++ /dev/null @@ -1,527 +0,0 @@ -//! Behavior tests for the `file_write` tool, driven through a fake [`FsGate`]. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::{AutonomyLevel, TestGate}; - -fn test_security(workspace: std::path::PathBuf) -> Arc { - TestGate::supervised(workspace) -} - -fn test_security_with( - workspace: std::path::PathBuf, - autonomy: AutonomyLevel, - max_actions_per_hour: usize, -) -> Arc { - TestGate::with(workspace, autonomy, max_actions_per_hour) -} - -#[test] -fn file_write_name() { - let tool = FileWriteTool::new(test_security(std::env::temp_dir())); - assert_eq!(tool.name(), "file_write"); -} - -#[test] -fn file_write_schema_has_path_and_content() { - let tool = FileWriteTool::new(test_security(std::env::temp_dir())); - let schema = tool.parameters_schema(); - assert!(schema["properties"]["path"].is_object()); - assert!(schema["properties"]["content"].is_object()); - let required = schema["required"].as_array().unwrap(); - assert!(required.contains(&json!("path"))); - assert!(required.contains(&json!("content"))); -} - -#[test] -fn approval_probe_uses_effective_workspace_root() { - let root = tempfile::tempdir().expect("root"); - let profile_workspace = root.path().join("profiles/alice"); - std::fs::create_dir_all(&profile_workspace).expect("profile workspace"); - std::fs::write(profile_workspace.join("notes.md"), "existing").expect("seed file"); - - let tool = FileWriteTool::with_approval_workspace_root( - test_security(root.path().to_path_buf()), - profile_workspace, - ); - - assert!(tool.external_effect_with_args(&json!({ - "path": "notes.md", - "content": "updated" - }))); -} - -#[tokio::test] -async fn file_write_creates_file() { - let dir = std::env::temp_dir().join("openhuman_test_file_write"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileWriteTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "out.txt", "content": "written!"})) - .await - .unwrap(); - assert!(!result.is_error); - assert!(result.output().contains("8 bytes")); - - let content = tokio::fs::read_to_string(dir.join("out.txt")) - .await - .unwrap(); - assert_eq!(content, "written!"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_write_creates_parent_dirs() { - let dir = std::env::temp_dir().join("openhuman_test_file_write_nested"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileWriteTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "a/b/c/deep.txt", "content": "deep"})) - .await - .unwrap(); - assert!(!result.is_error); - - let content = tokio::fs::read_to_string(dir.join("a/b/c/deep.txt")) - .await - .unwrap(); - assert_eq!(content, "deep"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_write_overwrites_existing() { - let dir = std::env::temp_dir().join("openhuman_test_file_write_overwrite"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("exist.txt"), "old") - .await - .unwrap(); - - let tool = FileWriteTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "exist.txt", "content": "new"})) - .await - .unwrap(); - assert!(!result.is_error); - - let content = tokio::fs::read_to_string(dir.join("exist.txt")) - .await - .unwrap(); - assert_eq!(content, "new"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_write_blocks_path_traversal() { - let dir = std::env::temp_dir().join("openhuman_test_file_write_traversal"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileWriteTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "../../etc/evil", "content": "bad"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(&result.output().contains("not allowed")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_write_blocks_absolute_path() { - let tool = FileWriteTool::new(test_security(std::env::temp_dir())); - let result = tool - .execute(json!({"path": "/etc/evil", "content": "bad"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(&result.output().contains("not allowed")); -} - -#[tokio::test] -async fn file_write_missing_path_param() { - let tool = FileWriteTool::new(test_security(std::env::temp_dir())); - let result = tool.execute(json!({"content": "data"})).await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn file_write_missing_content_param() { - let tool = FileWriteTool::new(test_security(std::env::temp_dir())); - let result = tool.execute(json!({"path": "file.txt"})).await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn file_write_empty_content() { - let dir = std::env::temp_dir().join("openhuman_test_file_write_empty"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileWriteTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "empty.txt", "content": ""})) - .await - .unwrap(); - assert!(!result.is_error); - assert!(result.output().contains("0 bytes")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[cfg(unix)] -#[tokio::test] -async fn file_write_blocks_symlink_escape() { - use std::os::unix::fs::symlink; - - let root = std::env::temp_dir().join("openhuman_test_file_write_symlink_escape"); - let workspace = root.join("workspace"); - let outside = root.join("outside"); - - let _ = tokio::fs::remove_dir_all(&root).await; - tokio::fs::create_dir_all(&workspace).await.unwrap(); - tokio::fs::create_dir_all(&outside).await.unwrap(); - - symlink(&outside, workspace.join("escape_dir")).unwrap(); - - let tool = FileWriteTool::new(test_security(workspace.clone())); - let result = tool - .execute(json!({"path": "escape_dir/hijack.txt", "content": "bad"})) - .await - .unwrap(); - - assert!(result.is_error); - // SecurityPolicy now blocks symlink escapes at the is_path_allowed - // layer (#1927) — error becomes "Path not allowed by security - // policy" rather than the deeper "escapes workspace" message. - let out = result.output(); - assert!( - out.contains("escapes workspace") || out.contains("not allowed"), - "expected escape/not-allowed error, got: {out}" - ); - assert!(!outside.join("hijack.txt").exists()); - - let _ = tokio::fs::remove_dir_all(&root).await; -} - -#[tokio::test] -async fn file_write_blocks_readonly_mode() { - let dir = std::env::temp_dir().join("openhuman_test_file_write_readonly"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileWriteTool::new(test_security_with(dir.clone(), AutonomyLevel::ReadOnly, 20)); - let result = tool - .execute(json!({"path": "out.txt", "content": "should-block"})) - .await - .unwrap(); - - assert!(result.is_error); - assert!(result.output().contains("read-only")); - // The readonly block must carry the hard-reject marker so the agent - // harness recognizes it and halts on a verbatim repeat instead of - // grinding. Ties this tool's literal to the marker const — the - // const→detector half lives in `RepeatedToolFailureMiddleware` and is - // covered by its tests (`crates/openhuman-core/src/agent/tinyagents/middleware.rs`). - assert!( - result - .output() - .contains(crate::filesystem::test_support::POLICY_BLOCKED_MARKER), - "file_write readonly block must carry the hard-reject marker: {}", - result.output() - ); - assert!(!dir.join("out.txt").exists()); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_write_blocks_when_rate_limited() { - let dir = std::env::temp_dir().join("openhuman_test_file_write_rate_limited"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileWriteTool::new(test_security_with( - dir.clone(), - AutonomyLevel::Supervised, - 0, - )); - let result = tool - .execute(json!({"path": "out.txt", "content": "should-block"})) - .await - .unwrap(); - - assert!(result.is_error); - assert!(result.output().contains("Rate limit exceeded")); - assert!(!dir.join("out.txt").exists()); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -// ── §5.1 TOCTOU / symlink file write protection tests ──── - -#[cfg(unix)] -#[tokio::test] -async fn file_write_blocks_symlink_target_file() { - use std::os::unix::fs::symlink; - - let root = std::env::temp_dir().join("openhuman_test_file_write_symlink_target"); - let workspace = root.join("workspace"); - let outside = root.join("outside"); - - let _ = tokio::fs::remove_dir_all(&root).await; - tokio::fs::create_dir_all(&workspace).await.unwrap(); - tokio::fs::create_dir_all(&outside).await.unwrap(); - - // Create a file outside and symlink to it inside workspace - tokio::fs::write(outside.join("target.txt"), "original") - .await - .unwrap(); - symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap(); - - let tool = FileWriteTool::new(test_security(workspace.clone())); - let result = tool - .execute(json!({"path": "linked.txt", "content": "overwritten"})) - .await - .unwrap(); - - assert!(result.is_error, "writing through symlink must be blocked"); - // The symlink-safe is_path_allowed check (#1927) blocks at the - // policy layer before the tool's own symlink-target detection - // runs; accept either error message. - let out = result.output(); - assert!( - out.contains("symlink") || out.contains("not allowed"), - "error should mention symlink or policy block, got: {out}" - ); - - // Verify original file was not modified - let content = tokio::fs::read_to_string(outside.join("target.txt")) - .await - .unwrap(); - assert_eq!(content, "original", "original file must not be modified"); - - let _ = tokio::fs::remove_dir_all(&root).await; -} - -#[tokio::test] -async fn file_write_blocks_null_byte_in_path() { - let dir = std::env::temp_dir().join("openhuman_test_file_write_null"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileWriteTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"path": "file\u{0000}.txt", "content": "bad"})) - .await - .unwrap(); - assert!(result.is_error, "paths with null bytes must be blocked"); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -/// `file_write` enforces the same 5MB content-size cap as `edit_file` and -/// `grep`'s file caps (`file_read` allows up to 10MB for reads). -#[tokio::test] -async fn file_write_reports_a_refused_write_rather_than_a_silent_success() { - let dir = std::env::temp_dir().join("openhuman_test_file_write_refused"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileWriteTool::new(test_security(dir.clone())).with_sink(Arc::new( - crate::filesystem::file_sink::RefusingSink(std::io::ErrorKind::PermissionDenied), - )); - let result = tool - .execute(json!({"path": "f.txt", "content": "abc"})) - .await - .unwrap(); - - assert!( - result.is_error, - "a refused write must surface as an error, not a fabricated success" - ); - assert!( - !dir.join("f.txt").exists(), - "and nothing may be left behind claiming the write happened" - ); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn file_write_refuses_an_oversized_content_write() { - let dir = std::env::temp_dir().join("openhuman_test_file_write_oversized"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = FileWriteTool::new(test_security(dir.clone())); - let content = "x".repeat(64 * 1024 * 1024); - let result = tool - .execute(json!({"path": "huge.txt", "content": content})) - .await - .unwrap(); - assert!( - result.is_error, - "a 64MB write must be refused by a stated ceiling, not written: {}", - result.output() - ); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -// ── Coverage of the guard, symlink and budget branches ────────────────── - -use crate::filesystem::test_support::RacyGate; - -#[tokio::test] -async fn file_write_refuses_a_racing_budget() { - let dir = tempfile::tempdir().unwrap(); - let tool = FileWriteTool::new(Arc::new(RacyGate(test_security(dir.path().to_path_buf())))); - let result = tool - .execute(json!({"path": "a.txt", "content": "x"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("action budget exhausted")); -} - -#[cfg(unix)] -#[tokio::test] -async fn file_write_refuses_to_write_through_an_in_workspace_symlink() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("real.txt"), "real").unwrap(); - std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link.txt")).unwrap(); - let tool = FileWriteTool::new(test_security(dir.path().to_path_buf())); - let result = tool - .execute(json!({"path": "link.txt", "content": "x"})) - .await - .unwrap(); - assert!(result.is_error); - assert!( - result - .output() - .contains("Refusing to write through symlink") - ); - assert_eq!( - std::fs::read_to_string(dir.path().join("real.txt")).unwrap(), - "real" - ); -} - -#[tokio::test] -async fn file_write_honours_the_file_state_guard_and_records_its_write() { - crate::file_state::init_global(true); - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().canonicalize().unwrap().join("g.txt"); - std::fs::write(&target, "old").unwrap(); - let tool = FileWriteTool::new(test_security(dir.path().to_path_buf())); - let agent = format!("fw-agent-{}", dir.path().display()); - let other = format!("fw-other-{}", dir.path().display()); - - // Partial read blocks the overwrite. - crate::file_state::record_read( - &agent, - target.clone(), - std::time::SystemTime::now(), - true, - std::time::Instant::now(), - ); - let result = crate::file_state::with_file_state_agent_id( - agent.clone(), - tool.execute(json!({"path": "g.txt", "content": "new"})), - ) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Partial read")); - - // A sibling's later write makes the read stale. - crate::file_state::record_read( - &agent, - target.clone(), - std::time::SystemTime::now(), - false, - std::time::Instant::now(), - ); - crate::file_state::record_write(&other, target.clone()); - let result = crate::file_state::with_file_state_agent_id( - agent.clone(), - tool.execute(json!({"path": "g.txt", "content": "new"})), - ) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Stale read")); - - // With a fresh full read the write goes through and is recorded. - crate::file_state::record_read( - &agent, - target.clone(), - std::time::SystemTime::now(), - false, - std::time::Instant::now(), - ); - let result = crate::file_state::with_file_state_agent_id( - agent.clone(), - tool.execute(json!({"path": "g.txt", "content": "new"})), - ) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert_eq!(std::fs::read_to_string(&target).unwrap(), "new"); - assert!(crate::file_state::check_stale_read(&agent, &target).is_none()); -} - -#[tokio::test] -async fn file_write_uses_the_context_workspace_when_one_is_threaded() { - use crate::filesystem::test_support::WorkspaceContext; - let home = tempfile::tempdir().unwrap(); - let isolated = tempfile::tempdir().unwrap(); - let tool = FileWriteTool::new(test_security(home.path().to_path_buf())); - let context = WorkspaceContext::at(isolated.path()); - let result = tool - .execute_with_context( - json!({"path": "in_ws.txt", "content": "hi"}), - ToolCallOptions::default(), - Some(&context), - ) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert!(isolated.path().join("in_ws.txt").exists()); - assert!(!home.path().join("in_ws.txt").exists()); -} - -// The approval probe ran against the tool's approval root, where the path is -// new, so no approval was asked. Under a context workspace the same path is an -// existing file: overwriting it would be an unapproved edit. -#[tokio::test] -async fn an_overwrite_the_approval_probe_saw_as_a_create_is_refused() { - use crate::filesystem::test_support::WorkspaceContext; - let home = tempfile::tempdir().unwrap(); - let isolated = tempfile::tempdir().unwrap(); - std::fs::write(isolated.path().join("a.txt"), "keep").unwrap(); - let tool = FileWriteTool::new(test_security(home.path().to_path_buf())); - let args = json!({"path": "a.txt", "content": "clobbered"}); - assert!(!tool.external_effect_with_args(&args), "probed as a create"); - - let context = WorkspaceContext::at(isolated.path()); - let result = tool - .execute_with_context(args, ToolCallOptions::default(), Some(&context)) - .await - .unwrap(); - assert!(result.is_error, "{}", result.output()); - assert_eq!( - std::fs::read_to_string(isolated.path().join("a.txt")).unwrap(), - "keep" - ); -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json b/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json deleted file mode 100644 index 7b98f41e..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "description": "Apply a batch of exact-string edits across one or more files atomically. All edits are validated before any are written; validation failure rolls back the whole batch. Each edit is `{path, old_string, new_string, replace_all?}`; a top-level `path` is the default for edits that omit their own. To CREATE a new file, pass an empty `old_string` with the full contents as `new_string`; the path must not already exist.", - "exposure": "Direct", - "name": "apply_patch", - "permission_level": "Write", - "schema": { - "properties": { - "path": { - "description": "Default file for edits that omit their own `path`.", - "type": "string" - }, - "edits": { - "description": "Ordered list of edits.", - "items": { - "properties": { - "new_string": { - "type": "string" - }, - "old_string": { - "description": "Exact text to replace. Empty means CREATE: the path must not exist and `new_string` becomes the whole file.", - "type": "string" - }, - "path": { - "description": "File to edit; defaults to the top-level `path`.", - "type": "string" - }, - "replace_all": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "old_string", - "new_string" - ], - "type": "object" - }, - "type": "array" - } - }, - "required": [ - "edits" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/csv_export.json b/crates/tinytools-std/src/filesystem/fixtures/csv_export.json deleted file mode 100644 index dd4e4d5a..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/csv_export.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "description": "Export structured data (JSON array of objects) as a CSV file to the workspace. Returns the file path. Use when the user wants raw tabular data from a tool result that's too large to include inline.", - "exposure": "Direct", - "name": "csv_export", - "permission_level": "Write", - "schema": { - "properties": { - "columns": { - "description": "Optional ordered list of column names to include. If omitted, all keys from the first object are used as headers.", - "items": { - "type": "string" - }, - "type": "array" - }, - "data": { - "description": "JSON string containing an array of objects to export. Each object becomes a row; keys become column headers.", - "type": "string" - }, - "filename": { - "description": "Output filename (without path). Will be written to workspace/exports/. Example: 'github-issues-2026-04-16.csv'", - "type": "string" - } - }, - "required": [ - "data", - "filename" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/edit_file.json b/crates/tinytools-std/src/filesystem/fixtures/edit_file.json deleted file mode 100644 index b6d95817..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/edit_file.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "description": "Edit a file by exact string replacement. By default `old_string` must match exactly once. Set `replace_all` to true to replace every match.", - "exposure": "Deferred", - "name": "edit", - "permission_level": "Write", - "schema": { - "properties": { - "new_string": { - "description": "Replacement text.", - "type": "string" - }, - "old_string": { - "description": "Text to find. Must match exactly.", - "type": "string" - }, - "path": { - "description": "Workspace-relative file path.", - "type": "string" - }, - "replace_all": { - "default": false, - "description": "If true, replace every occurrence (default false).", - "type": "boolean" - } - }, - "required": [ - "path", - "old_string", - "new_string" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/file_read.json b/crates/tinytools-std/src/filesystem/fixtures/file_read.json deleted file mode 100644 index fa9744fd..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/file_read.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "description": "Read the contents of a file in your working directory (the action sandbox). Relative paths resolve against that directory; paths outside it are blocked. To read a file written by `shell`, confirm its location with `pwd` and use the same relative path. Long files are returned one page at a time; continue with the exact `offset` reported at the end of the page.", - "exposure": "Direct", - "name": "file_read", - "permission_level": "ReadOnly", - "schema": { - "properties": { - "offset": { - "description": "Byte offset to start reading from. Use the offset a paged read reports to continue it.", - "minimum": 0, - "type": "integer" - }, - "path": { - "description": "Relative path to the file within the workspace", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/file_write.json b/crates/tinytools-std/src/filesystem/fixtures/file_write.json deleted file mode 100644 index 8621d0bd..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/file_write.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "description": "Write contents to a file in your working directory (the action sandbox). Relative paths resolve against that directory; writes outside it are blocked. Reference the file later by the same relative path so `file_read` resolves to it.", - "exposure": "Direct", - "name": "file_write", - "permission_level": "Write", - "schema": { - "properties": { - "content": { - "description": "Content to write to the file", - "type": "string" - }, - "path": { - "description": "Relative path to the file within the workspace", - "type": "string" - } - }, - "required": [ - "path", - "content" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/git_operations.json b/crates/tinytools-std/src/filesystem/fixtures/git_operations.json deleted file mode 100644 index 302536c1..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/git_operations.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "description": "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash). Provides parsed JSON output and integrates with security policy for autonomy controls.", - "exposure": "Direct", - "name": "git_operations", - "permission_level": "ReadOnly", - "schema": { - "properties": { - "action": { - "description": "Stash action (for 'stash' operation)", - "enum": [ - "push", - "pop", - "list", - "drop" - ], - "type": "string" - }, - "branch": { - "description": "Branch name (for 'checkout' operation)", - "type": "string" - }, - "cached": { - "description": "Show staged changes (for 'diff' operation)", - "type": "boolean" - }, - "files": { - "description": "File or path to diff (for 'diff' operation, default: '.')", - "type": "string" - }, - "index": { - "description": "Stash index (for 'stash' with 'drop' action)", - "type": "integer" - }, - "limit": { - "description": "Number of log entries (for 'log' operation, default: 10)", - "type": "integer" - }, - "message": { - "description": "Commit message (for 'commit' operation)", - "type": "string" - }, - "operation": { - "description": "Git operation to perform", - "enum": [ - "status", - "diff", - "log", - "branch", - "commit", - "add", - "checkout", - "stash" - ], - "type": "string" - }, - "paths": { - "description": "File paths to stage (for 'add' operation)", - "type": "string" - } - }, - "required": [ - "operation" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/glob_search.json b/crates/tinytools-std/src/filesystem/fixtures/glob_search.json deleted file mode 100644 index 926937d5..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/glob_search.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "description": "Find files matching a glob pattern (e.g. `src/**/*.rs`), searching the action sandbox unless `path` says otherwise. Returns paths newest-first, ready to pass straight to `file_read` / `grep`.", - "exposure": "Direct", - "name": "glob", - "permission_level": "ReadOnly", - "schema": { - "properties": { - "max_results": { - "description": "Cap on returned paths (default 500).", - "minimum": 1, - "type": "integer" - }, - "path": { - "description": "Directory to search within. Defaults to the working directory (action sandbox). Must be a location you're allowed to read; relative paths resolve against the working directory.", - "type": "string" - }, - "pattern": { - "description": "Glob pattern, e.g. `**/*.rs` or `src/**/*.{ts,tsx}` (single brace expansion not supported — list patterns separately). Matched against paths relative to the search directory.", - "type": "string" - } - }, - "required": [ - "pattern" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/grep.json b/crates/tinytools-std/src/filesystem/fixtures/grep.json deleted file mode 100644 index 039c98fd..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/grep.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "description": "Search file contents in the workspace with a regular expression. Returns up to `max_matches` matches (default 200) as `path:line:text` lines.", - "exposure": "Direct", - "name": "grep", - "permission_level": "ReadOnly", - "schema": { - "properties": { - "case_insensitive": { - "default": false, - "description": "If true, compile the regex with the `i` flag.", - "type": "boolean" - }, - "max_matches": { - "description": "Cap on matches returned (default 200).", - "minimum": 1, - "type": "integer" - }, - "path": { - "default": ".", - "description": "Optional sub-path to restrict the search to (relative to workspace).", - "type": "string" - }, - "pattern": { - "description": "Regular expression (Rust `regex` crate syntax).", - "type": "string" - } - }, - "required": [ - "pattern" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/image_info.json b/crates/tinytools-std/src/filesystem/fixtures/image_info.json deleted file mode 100644 index 3125c3d1..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/image_info.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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/list_files.json b/crates/tinytools-std/src/filesystem/fixtures/list_files.json deleted file mode 100644 index 98415d48..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/list_files.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "description": "List entries in a workspace directory (non-recursive). Each line is `\t` where kind is `dir`, `file`, or `link`.", - "exposure": "Direct", - "name": "list", - "permission_level": "ReadOnly", - "schema": { - "properties": { - "path": { - "default": ".", - "description": "Directory path relative to the workspace (default `.`).", - "type": "string" - } - }, - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/read_diff.json b/crates/tinytools-std/src/filesystem/fixtures/read_diff.json deleted file mode 100644 index f32196a0..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/read_diff.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "description": "Get the git diff of current changes. Can diff staged, unstaged, or against a specific base branch/commit. Returns file paths and hunks.", - "exposure": "Deferred", - "name": "read_diff", - "permission_level": "ReadOnly", - "schema": { - "properties": { - "base": { - "description": "Base ref to diff against (e.g. 'main', 'HEAD~3'). Default: unstaged changes.", - "type": "string" - }, - "path_filter": { - "description": "Limit diff to a specific path or glob.", - "type": "string" - }, - "staged": { - "description": "Show staged changes only (--cached). Default: false.", - "type": "boolean" - } - }, - "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 deleted file mode 100644 index 962bb1bb..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/read_workspace_state.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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/fixtures/run_linter.json b/crates/tinytools-std/src/filesystem/fixtures/run_linter.json deleted file mode 100644 index 4c16bbdf..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/run_linter.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "description": "Run linting tools on the codebase. Supports 'clippy' for Rust and 'eslint' for TypeScript/JavaScript. Returns warnings and errors.", - "exposure": "Deferred", - "name": "run_linter", - "permission_level": "Execute", - "schema": { - "properties": { - "linter": { - "default": "auto", - "description": "Which linter to run. 'auto' detects from project files.", - "enum": [ - "clippy", - "eslint", - "auto" - ], - "type": "string" - }, - "path": { - "description": "Limit linting to a specific path.", - "type": "string" - } - }, - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/run_tests.json b/crates/tinytools-std/src/filesystem/fixtures/run_tests.json deleted file mode 100644 index f0f5dc9f..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/run_tests.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "description": "Run the project test suite. Supports 'cargo_test' for Rust and 'vitest' for TypeScript/JavaScript. Returns pass/fail results with output.", - "exposure": "Deferred", - "name": "run_tests", - "permission_level": "Execute", - "schema": { - "properties": { - "filter": { - "description": "Filter to run specific tests (e.g. test name or module).", - "type": "string" - }, - "runner": { - "default": "auto", - "description": "Which test runner to use. 'auto' detects from project files.", - "enum": [ - "cargo_test", - "vitest", - "auto" - ], - "type": "string" - }, - "timeout_secs": { - "default": 120, - "description": "Timeout in seconds (default: 120).", - "type": "integer" - } - }, - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/update_memory_md.json b/crates/tinytools-std/src/filesystem/fixtures/update_memory_md.json deleted file mode 100644 index 16237185..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/update_memory_md.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "description": "Append or update sections in MEMORY.md or SKILL.md workspace files. Use 'append' to add new notes at the end, or 'replace_section' to overwrite the body under a named '## Section' heading.", - "exposure": "Direct", - "name": "update_memory_md", - "permission_level": "Write", - "schema": { - "properties": { - "action": { - "description": "'append' adds content at the end; 'replace_section' replaces the body of the named section.", - "enum": [ - "append", - "replace_section" - ], - "type": "string" - }, - "content": { - "description": "The markdown text to write.", - "type": "string" - }, - "file": { - "description": "Which workspace markdown file to modify.", - "enum": [ - "MEMORY.md", - "SKILL.md" - ], - "type": "string" - }, - "section_title": { - "description": "Required for 'replace_section': the heading text (without '## ').", - "type": "string" - } - }, - "required": [ - "file", - "action", - "content" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/gate.rs b/crates/tinytools-std/src/filesystem/gate.rs deleted file mode 100644 index ca9965e9..00000000 --- a/crates/tinytools-std/src/filesystem/gate.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! The host's say over every filesystem tool call. -//! -//! The tools in this module read, write, search and patch files, but they do -//! not decide what they are *allowed* to touch. That decision (an autonomy -//! level, a workspace boundary, an approval prompt, an hourly action budget, -//! credential stores that are never reachable) belongs to the host whose threat -//! model and configuration it depends on. A tool asks an [`FsGate`] instead. -//! -//! The trait is exactly the set of questions the tools put to a policy, and -//! nothing wider: it carries no decision *types* of its own, only booleans and -//! resolved paths, so a host maps its own policy onto it without translating a -//! vocabulary. - -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use async_trait::async_trait; -use tinytools::ToolRunContext; - -/// The host policy a filesystem 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. -#[async_trait] -pub trait FsGate: std::fmt::Debug + Send + Sync { - /// Whether the host permits the agent to change anything at all right now. - /// - /// `false` blocks every mutating tool before it touches the disk. - fn can_act(&self) -> bool; - - /// Whether the host is in a strictly read-only mode. - /// - /// Asked by `git_operations`, on top of [`can_act`](Self::can_act), before - /// a write operation runs. - fn is_read_only(&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 a write 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 write_needs_approval(&self) -> bool; - - /// The directory relative paths resolve against. - fn action_dir(&self) -> &Path; - - /// Cheap, string-level check of whether `path` may be used at all. - /// - /// Used to filter search hits without touching the disk. - fn is_path_string_allowed(&self, path: &str) -> bool; - - /// Batch form of [`is_path_string_allowed`](Self::is_path_string_allowed). - /// - /// Answers are in request order and the result has the same length as - /// `paths`. The default asks the single-path question once per entry, so - /// every existing gate behaves exactly as before. A host that can answer a - /// whole batch more cheaply (one round trip to a policy module instead of - /// one per path) overrides this. - /// - /// A caller must treat an answer whose length differs from `paths` as - /// every path refused. - fn paths_string_allowed(&self, paths: &[String]) -> Vec { - paths - .iter() - .map(|path| self.is_path_string_allowed(path)) - .collect() - } - - /// Resolve an existing path and confirm the host allows it. - /// - /// # Errors - /// - /// Returns the message shown to the model when the path is refused or - /// cannot be resolved. - async fn validate_path(&self, path: &str) -> Result; - - /// Resolve a path whose *parent* must exist but whose target may not, and - /// confirm the host allows it. Used for writes that create files. - /// - /// # Errors - /// - /// Returns the message shown to the model when the path is refused or - /// cannot be resolved. - async fn validate_parent_path(&self, path: &str) -> Result; - - /// A gate for the same policy, rooted at `root`. - /// - /// The run's isolated workspace becomes both the directory relative paths - /// resolve against and a readable-and-writable root. The grant is - /// per-call: it must not mutate `self`, so concurrent turns cannot race - /// each other, and it must not widen whatever the host treats as always - /// forbidden. `root` originates from trusted in-process code (the session - /// builder or a sub-agent runner), never from model-supplied text. - fn scoped_to_workspace(&self, root: &Path) -> Arc; -} - -/// Resolve the gate a tool should validate paths against for this call. -/// -/// When the harness threaded a workspace descriptor into the call (an -/// edit-capable worker running in an isolated checkout), the returned gate is -/// scoped to that workspace root; otherwise it is `gate` itself. Counting and -/// approval questions ([`FsGate::can_act`], [`FsGate::record_action`], ...) -/// are still asked of the unscoped gate. -pub(crate) fn gate_for_context( - gate: &Arc, - context: Option<&dyn ToolRunContext>, - tool: &str, -) -> Arc { - match context.and_then(ToolRunContext::workspace) { - Some(workspace) => { - tracing::debug!( - tool, - workspace_root = %workspace.root.display(), - policy_id = %workspace.policy_id, - "[tools:filesystem] granting TinyAgents workspace descriptor as action dir + trusted root" - ); - gate.scoped_to_workspace(&workspace.root) - } - None => Arc::clone(gate), - } -} diff --git a/crates/tinytools-std/src/filesystem/gate_tests.rs b/crates/tinytools-std/src/filesystem/gate_tests.rs deleted file mode 100644 index 727a3b04..00000000 --- a/crates/tinytools-std/src/filesystem/gate_tests.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Tests for how a call's workspace descriptor scopes the gate. - -#![allow(clippy::unwrap_used)] - -use std::sync::Arc; - -use super::gate::{FsGate, gate_for_context}; -use super::test_support::{TestGate, WorkspaceContext}; - -#[tokio::test] -async fn without_a_descriptor_the_same_gate_is_used() { - let dir = tempfile::tempdir().unwrap(); - let gate: Arc = TestGate::supervised(dir.path().to_path_buf()); - let resolved = gate_for_context(&gate, None, "file_read"); - assert!(Arc::ptr_eq(&gate, &resolved)); -} - -#[tokio::test] -async fn a_descriptor_roots_the_gate_at_the_isolated_workspace() { - let home = tempfile::tempdir().unwrap(); - let isolated = tempfile::tempdir().unwrap(); - std::fs::write(isolated.path().join("seen.txt"), "x").unwrap(); - let gate: Arc = TestGate::supervised(home.path().to_path_buf()); - - let context = WorkspaceContext::at(isolated.path()); - let scoped = gate_for_context(&gate, Some(&context), "file_read"); - - assert_eq!(scoped.action_dir(), isolated.path()); - let resolved = scoped.validate_path("seen.txt").await.unwrap(); - assert_eq!( - resolved, - isolated.path().join("seen.txt").canonicalize().unwrap() - ); - // The original gate keeps its own root and refuses the isolated file. - assert!(gate.validate_path("seen.txt").await.is_err()); -} - -#[tokio::test] -async fn the_fake_gates_delegate_every_question_to_the_gate_they_wrap() { - use super::test_support::{AutonomyLevel, RacyGate, WrapGate}; - - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("f.txt"), "x").unwrap(); - let inner = TestGate::with(dir.path().to_path_buf(), AutonomyLevel::Supervised, 5); - - let racy = RacyGate(inner.clone()); - let wrap = WrapGate::new(inner.clone()).arc(); - let gates: [&dyn FsGate; 2] = [&racy, wrap.as_ref()]; - for gate in gates { - assert!(gate.can_act()); - assert!(!gate.is_read_only()); - assert!(gate.write_needs_approval()); - assert_eq!(gate.action_dir(), dir.path()); - assert!(gate.is_path_string_allowed("f.txt")); - assert!(gate.validate_path("f.txt").await.is_ok()); - assert!(gate.validate_parent_path("new.txt").await.is_ok()); - let scoped = gate.scoped_to_workspace(dir.path()); - assert_eq!(scoped.action_dir(), dir.path()); - } -} - -#[tokio::test] -async fn the_fake_gate_refuses_what_a_real_policy_would() { - let dir = tempfile::tempdir().unwrap(); - let gate = TestGate::supervised(dir.path().to_path_buf()); - - // No parent / no file name to resolve. - assert!(gate.validate_parent_path("/").await.is_err()); - // Nothing on disk to resolve, even at the filesystem root. - let far = TestGate::supervised(std::path::PathBuf::from("/definitely/not/a/real/root")); - assert!(!far.is_path_string_allowed("x.txt")); - assert!(far.validate_parent_path("x.txt").await.is_err()); - // A NUL byte is never a path. - assert!(!gate.is_path_string_allowed("a\0b")); -} - -#[test] -fn the_default_batch_answer_maps_the_single_path_check_in_order() { - let dir = tempfile::tempdir().unwrap(); - let gate = TestGate::supervised(dir.path().to_path_buf()); - let paths = vec![ - "ok.txt".to_string(), - "../escape.txt".to_string(), - "a\0b".to_string(), - "sub/ok.rs".to_string(), - ]; - let batch = gate.paths_string_allowed(&paths); - let single: Vec = paths - .iter() - .map(|p| gate.is_path_string_allowed(p)) - .collect(); - assert_eq!(batch, single); - assert_eq!(batch, vec![true, false, false, true]); - assert_eq!(gate.paths_string_allowed(&[]), Vec::::new()); -} diff --git a/crates/tinytools-std/src/filesystem/git_operations/config.rs b/crates/tinytools-std/src/filesystem/git_operations/config.rs deleted file mode 100644 index f7e066be..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/config.rs +++ /dev/null @@ -1,378 +0,0 @@ -//! Ambient-git-config hardening for [`super::git_operations`]. -//! -//! Split out of `git_operations.rs` for the Rust layout gate. Cohesive on its -//! own: the allow-list, the neutralised keys, and the two helpers that build a -//! hardened `git` invocation are one policy, and reviewing that policy is -//! easier when it is not interleaved with the command implementations. - -use std::path::Path; - -/// Repository config keys this tool will run `git` under. -/// -/// This is an allowlist, and the direction is the whole point. Several git -/// config keys name a command that git then executes — `core.fsmonitor` is -/// run by `git status` (used by the `status` operation here), `core.sshCommand` -/// by anything that reaches a remote, `diff.external` by `diff`, `core.pager` -/// and `core.editor` by the commands that use them, and the `filter.*.process` -/// / `*.clean` / `*.smudge` and `diff.*.textconv` families by content -/// operations. Enumerating *those* and clearing them is the obvious fix and it -/// is the wrong shape: the list is only correct until git adds a key, and a -/// denylist that has gone stale reads as protection while providing none. -/// -/// An allowlist ages the other way. A key nobody here has heard of is refused, -/// so a new git release makes this tool fail closed and loud rather than -/// silently regain the hole. -/// -/// The entries are `section.key`, lowercased, with any subsection elided — -/// `remote.origin.url` is checked as `remote.url`. -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 - // this list started from. It redirects the working-tree root Git - // operates against — including a linked-worktree-shaped redirect this - // tool does not otherwise offer — and this tool runs *write* operations - // (`checkout`, `add`, `commit`, `stash`) whose target directory is - // supposed to be `action_dir`/the resolved workspace, never something a - // repository's own config gets to name. Worktree isolation is already - // handled through `WorkspaceDescriptor` (`effective_action_dir_for_context`), - // a trusted, in-process mechanism — nothing here needs the config key. - "core.repositoryformatversion", - "core.filemode", - "core.bare", - "core.logallrefupdates", - "core.ignorecase", - "core.precomposeunicode", - "core.symlinks", - "remote.url", - "remote.fetch", - "remote.pushurl", - "remote.mirror", - "branch.remote", - "branch.merge", - "branch.rebase", - "submodule.active", - "submodule.url", - "user.name", - "user.email", - "pull.rebase", - "push.default", - "init.defaultbranch", - // Inert settings ordinary repositories carry that a first-draft allowlist - // would refuse, making the tool useless on a large class of real - // workspaces. Each is a value git *interprets*; none names a program git - // runs. - "core.autocrlf", - "core.eol", - "core.untrackedcache", - "core.longpaths", - "core.fscache", - "core.hidedotfiles", - "core.sparsecheckout", - "core.sparsecheckoutcone", - "commit.gpgsign", - "tag.gpgsign", - "remote.tagopt", - "remote.prune", - "remote.partialclonefilter", - "remote.promisor", - "branch.vscodemerge", - "gc.auto", - "fetch.prune", - // SHA-256 repositories and worktree-scoped config. Only these two - // `extensions.*` keys — the namespace as a whole is where git puts - // repository-format switches, and a blanket allow would admit whatever it - // adds next. - "extensions.objectformat", - "extensions.worktreeconfig", - // `filter..required` is a boolean. The driver's actual programs — - // `clean`, `smudge`, `process` — are NOT here and must not be; see the LFS - // note on `NEUTRALISED_CONFIG`. - "filter.required", - "lfs.repositoryformatversion", -]; - -/// Command-valued keys cleared on the command line as a second layer. -/// -/// Command-line `-c` outranks every config file, so this genuinely -/// neutralises these keys even when a repository sets them — and it does so -/// at the moment `git` actually runs, not at the moment -/// [`first_disallowed_repo_config_key`] happened to inspect the config a -/// moment earlier. That distinction matters: the inspection and the real -/// command are two separate `git` invocations, so a key set in the gap -/// between them (a second concurrent writer, or a worktree-scoped write) is -/// invisible to the inspection but still reaches here — where it is -/// neutralised regardless of timing. It is a denylist and therefore cannot be -/// the *only* guarantee — [`ALLOWED_REPO_CONFIG`] is the one that fails -/// closed on a key nobody anticipated — but this layer is what actually holds -/// under a race, not the inspection step. -/// -/// `credential.helper` is command-valued — a value beginning `!` is run as a -/// shell command — so it belongs nowhere near [`ALLOWED_REPO_CONFIG`] despite -/// reading like a mere preference; it is refused there instead, since none of -/// this tool's operations reach a remote and so have no legitimate use for it -/// to preserve. -/// -/// A `git lfs install` clone is refused, deliberately: `filter.lfs.clean`, -/// `.smudge` and `.process` each name a program, so an LFS working copy -/// cannot be read by this tool. That is the fail-closed answer and it is the -/// intended one. Only `filter..required`, a boolean, is allowed. -/// -/// `core.hooksPath` and `commit.gpgSign` are handled separately, in -/// [`hardened_git`] — see there for why. -pub(crate) const NEUTRALISED_CONFIG: &[&str] = &[ - "core.fsmonitor=", - "core.sshCommand=", - "core.pager=cat", - "core.editor=false", - // NOT `diff.external=`. An empty value does not disable an external diff — - // git tries to *execute* the empty string and the whole command dies with - // `error: cannot run : No such file or directory` / `fatal: external diff - // died`, so every `diff` operation failed rather than being hardened. - // Suppression belongs on the command instead: `git diff --no-ext-diff`, - // which ignores `diff.external` however the repository set it. Verified - // both ways against a repo with `diff.external=/bin/false`: plain `diff` - // dies, `--no-ext-diff` prints the patch. - "sequence.editor=false", - "uploadpack.packObjectsHook=", -]; - -/// The entries of [`NEUTRALISED_CONFIG`] that are also safe to force on an -/// *arbitrary* `git` invocation, plus `commit.gpgSign`. -/// -/// The `shell` tool runs git too, and since the `files` tool pack withheld -/// `git_operations` it runs most of it. Nothing there controls argv, so the -/// `-c` layer [`hardened_git`] builds is unavailable; the same overrides ride -/// `GIT_CONFIG_PARAMETERS` instead, which git documents as the mechanism `-c` -/// itself uses to reach child processes, at the same precedence. -/// -/// **This is the denylist half of the policy, not the fail-closed half.** -/// [`ALLOWED_REPO_CONFIG`] cannot come with it: inspecting the repository -/// config before every shell command would cost a `git` round trip on commands -/// that have nothing to do with git, and would refuse to run in an ordinary -/// LFS checkout. So this narrows the surface; it does not close it the way -/// `git_operations` does. -/// -/// Three entries of [`NEUTRALISED_CONFIG`] are deliberately **not** here: -/// -/// - `core.sshCommand=`. It is the same trap the `diff.external` note above -/// records: an empty value is not "use the default", it is a command git -/// tries to execute. `git_operations` gets away with listing it because none -/// of its operations reach a remote; a shell `git fetch` / `git push` does, -/// and would die on every one. Excluded rather than risked. -/// - `diff.external`. Unsuppressable by value for the same reason, and the fix -/// `git_operations` uses — `--no-ext-diff` — is an argv change this path -/// cannot make. A repository that sets it keeps it, and that is a known gap. -/// - `core.hooksPath`. There is no portable inert value for it, and overriding -/// it would change ordinary shell behavior by suppressing repository hooks. -/// Shell-invoked git therefore keeps the repository's configured hooks. -pub(crate) const SHELL_NEUTRALISED_CONFIG: &[&str] = &[ - "core.fsmonitor=", - "core.pager=cat", - "core.editor=false", - "sequence.editor=false", - "uploadpack.packObjectsHook=", - // Not in `NEUTRALISED_CONFIG` — `hardened_git` adds it separately, for the - // reason written there: a repository must not get to spend the host's - // signing key on a commit nobody asked to sign. - "commit.gpgSign=false", -]; - -/// Environment overrides applied to arbitrary commands launched by the shell -/// tool so any nested `git` process receives the safe subset above. -/// -/// Preserve ambient `GIT_CONFIG_PARAMETERS` exactly, including non-UTF-8 -/// bytes, because git uses it to propagate a parent's `-c` settings. The -/// safety overrides are appended so they win when the same key is repeated. -#[must_use] -pub fn shell_git_env() -> std::collections::HashMap { - let mut parameters = std::env::var_os("GIT_CONFIG_PARAMETERS").unwrap_or_default(); - for entry in SHELL_NEUTRALISED_CONFIG { - let Some((key, value)) = entry.split_once('=') else { - continue; - }; - if !parameters.is_empty() { - parameters.push(" "); - } - parameters.push(format!("'{key}'='{value}'")); - } - - std::collections::HashMap::from([( - std::ffi::OsString::from("GIT_CONFIG_PARAMETERS"), - parameters, - )]) -} - -/// A path git will read as an empty config file. -/// -/// `GIT_CONFIG_GLOBAL` must name something readable-and-empty rather than be -/// unset — unsetting it lets git fall back to `~/.gitconfig`, which is the -/// thing being suppressed. `/dev/null` is not a path on Windows; `NUL` is. -pub(crate) const NULL_CONFIG_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" }; - -/// Close the system/global config files and the command-valued `GIT_*` env -/// vars on a `git` invocation, without touching anything about how it reads -/// the repository's own config. -/// -/// `GIT_CONFIG_NOSYSTEM` and `GIT_CONFIG_GLOBAL` close the system and global -/// config files. Note what they do *not* close: the repository's own local -/// and worktree-scoped config, which is what an agent-writable workspace -/// actually lets an attacker author. That is handled by -/// [`first_disallowed_repo_config_key`] — a separate step, precisely because -/// [`hardened_git`]'s `-c` layer below must not be present while that step is -/// reading what the repository itself set (see its own doc comment). -pub(crate) fn suppress_ambient_git_config( - cmd: &mut tokio::process::Command, -) -> &mut tokio::process::Command { - cmd.env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", NULL_CONFIG_PATH) - // A parent `git -c` invocation propagates its command-line settings - // through these variables. They outrank every config file, so leaving - // them intact would let an ambient `core.hooksPath` or any other - // command-valued setting bypass both the file suppression and the - // repository allowlist below. - .env_remove("GIT_CONFIG_PARAMETERS") - .env_remove("GIT_CONFIG_COUNT") - // `git` consults these before it reads any config file. - .env_remove("GIT_EXTERNAL_DIFF") - .env_remove("GIT_PAGER") - .env_remove("GIT_EDITOR") - .env_remove("GIT_SSH") - .env_remove("GIT_SSH_COMMAND") - .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES") - .env("GIT_TERMINAL_PROMPT", "0") -} - -/// Build the `git` invocation actually used to run a requested operation. -/// -/// Layers [`suppress_ambient_git_config`] under the [`NEUTRALISED_CONFIG`] -/// `-c` overrides, which outrank every config file the repository itself -/// could set — including one set in the gap between -/// [`first_disallowed_repo_config_key`]'s inspection and this invocation. -/// -/// Two more `-c` overrides are added here rather than in the static -/// [`NEUTRALISED_CONFIG`] list, because their safe value is not a fixed -/// literal: -/// -/// - `core.hooksPath=` pointed at [`NULL_CONFIG_PATH`]. `commit` and -/// `checkout` are the two operations this tool exposes that run hooks -/// (`pre-commit`/`commit-msg`/`post-commit`, `post-checkout`), and a -/// repository-writable `core.hooksPath` naming a directory with an -/// executable `pre-commit` in it is exactly the shape of the worktree-scoped -/// bypass this hardening exists to close — verified directly: pointing -/// `core.hooksPath` at a directory containing a `pre-commit` that touches a -/// marker file, then running with `-c core.hooksPath=`, leaves -/// the marker untouched. A previous version of this comment claimed there -/// was no portable value that meant "nowhere"; there is — the same one -/// [`suppress_ambient_git_config`] already uses for `GIT_CONFIG_GLOBAL`, -/// since a location with nothing at it is exactly what git needs it to be. -/// - `commit.gpgSign=false`. `commit.gpgsign` is on [`ALLOWED_REPO_CONFIG`] as -/// an ordinary boolean, but a repository could still set it to force every -/// commit through this tool to be GPG-signed — with whatever real signing -/// key the *host* happens to have configured, silently attributing a -/// cryptographic signature to a commit the operator did not ask this tool to -/// 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(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); - crate::timeout::hide_console_window(cmd.as_std_mut()); - for kv in NEUTRALISED_CONFIG { - cmd.arg("-c").arg(kv); - } - cmd.arg("-c") - .arg(format!("core.hooksPath={NULL_CONFIG_PATH}")) - .arg("-c") - .arg("commit.gpgSign=false"); - cmd -} - -/// Normalise a `git config --list` key to the `section.key` form -/// [`ALLOWED_REPO_CONFIG`] uses, dropping any subsection. -/// -/// `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(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 => { - format!("{}.{}", &key[..first], &key[last + 1..]) - } - _ => key, - } -} - -/// Returns the first repository config key at `dir` that is not on -/// [`ALLOWED_REPO_CONFIG`], or `None` if every key it sets is recognised. -/// -/// Deliberately **not** `--local`: when `extensions.worktreeConfig` is set — -/// itself on [`ALLOWED_REPO_CONFIG`] as an ordinary, non-command-valued -/// setting — git additionally reads `config.worktree`, a second file `--local` -/// does not cover. A `core.hooksPath` set with `git config --worktree ...` -/// lands there, is invisible to `--local`, and is exactly the kind of key this -/// check exists to catch: `commit` runs the hook it names. Bare `--list` -/// returns the same merged view `git` itself consults — local *and* -/// worktree-scoped — with system and global excluded by -/// [`suppress_ambient_git_config`] instead of by a location flag. Verified -/// directly: `git config --worktree core.hooksPath ...` followed by -/// `--local --null` omits it; the same followed by a bare `--list --null` -/// (system/global suppressed) reports it. -/// -/// This step must run through [`suppress_ambient_git_config`] alone, **not** -/// [`hardened_git`]: the latter's `-c` layer would inject the very keys this -/// check inspects for (`core.fsmonitor=`, `core.pager=cat`, …), none of which -/// are themselves on [`ALLOWED_REPO_CONFIG`], and every invocation would -/// refuse itself. Reading the config this way also does not consult -/// `core.fsmonitor` or spawn a pager when its output is captured, so this -/// inspection step does not have the property it is checking for. -/// -/// A non-zero exit here is treated as a refusal, not as "nothing to -/// distrust": by the time this runs, the caller (`execute_in_context`) has -/// already confirmed `dir` — or one of its parents — contains a `.git`, so -/// `git config --list` failing means the config could not be read, not that -/// there is none. Proceeding to run the real command against config this step -/// never actually inspected would defeat the point of inspecting it first. -/// The refusal text for a repository whose config carries `key`. -/// -/// Lives here so the two paths that produce it — the guard inside -/// `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(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. \ - Several git config keys name a command git then executes, and this \ - directory is agent-writable, so unrecognised configuration is treated \ - as untrusted rather than honoured.", - dir.display() - ) -} - -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); - crate::timeout::hide_console_window(cmd.as_std_mut()); - let output = cmd.args(["config", "--list", "--null"]).output().await?; - - if !output.status.success() { - anyhow::bail!( - "refusing to run git in {}: could not inspect its repository config \ - ({}), so whether it is safe to run under could not be determined", - dir.display(), - String::from_utf8_lossy(&output.stderr).trim() - ); - } - - let listing = String::from_utf8_lossy(&output.stdout); - for entry in listing.split('\0').filter(|e| !e.is_empty()) { - // `--null` separates entries with NUL and key from value with LF. - let key = entry.split('\n').next().unwrap_or(entry); - if !ALLOWED_REPO_CONFIG.contains(&normalise_config_key(key).as_str()) { - return Ok(Some(key.to_string())); - } - } - Ok(None) -} diff --git a/crates/tinytools-std/src/filesystem/git_operations/config_tests.rs b/crates/tinytools-std/src/filesystem/git_operations/config_tests.rs deleted file mode 100644 index 12a329ee..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/config_tests.rs +++ /dev/null @@ -1,671 +0,0 @@ -//! Repository-config hardening (#5494) — the tests for -//! [`super::config`]. -//! -//! Split out of `test.rs` for the Rust layout gate, along the -//! same seam as the module split: these exercise the allow-list and the -//! hardened `git` invocation rather than the command surface. -//! -//! Attached to `git_operations` (not to the config module) so `use super::*` -//! still resolves `GitOperationsTool` the way it did before the split, and so -//! the fixtures in the sibling test module stay reachable. - -#![allow( - clippy::expect_used, - clippy::panic, - clippy::unwrap_used, - clippy::items_after_statements, - clippy::single_match_else, - clippy::single_match -)] - -use super::config::{ - NEUTRALISED_CONFIG, SHELL_NEUTRALISED_CONFIG, normalise_config_key, shell_git_env, -}; -// The fixtures stay in `test.rs` and are shared rather than -// duplicated: both modules are children of `git_operations`, so `pub(super)` -// there makes them reachable here. -use super::test::{error_text, hermetic, init_git_repo, test_tool}; -use super::*; -use tempfile::TempDir; - -// ── run_git_command_in: repository config hardening (issue #5494) ───────── -// -// `run_git_command_in` backs every operation this tool exposes, including -// `status`, which — like `read_workspace_state`'s `run_git` before #5493 — -// invokes `core.fsmonitor` from the repository's own `.git/config`. That file -// lives in `action_dir`, which `file_write` and `git_operations` itself -// (`add`, `commit`, `checkout`) can write to, so it is attacker-controlled -// input, not trusted configuration. - -/// 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. -/// -/// Runs the hook once up front and asserts the marker appears, then removes -/// it — so a later absent marker means the hardening refused the hook, not -/// that the hook itself was silently broken (e.g. by `{:?}`-escaping a path -/// the shell would quote differently than Rust's `Debug` does). -#[cfg(unix)] -fn plant_fsmonitor_hook(dir: &std::path::Path) -> std::path::PathBuf { - let hook = dir.join("hook.sh"); - let marker = dir.join("COMMAND_RAN"); - std::fs::write( - &hook, - format!("#!/bin/sh\ntouch {:?}\nexit 1\n", marker.to_string_lossy()), - ) - .unwrap(); - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); - - // Running a newly-written file directly can race the overlay filesystem in - // CI with ETXTBSY. Invoke the same script through the shell instead; git - // executes the configured hook through its interpreter as well. - std::process::Command::new("sh") - .arg(&hook) - .status() - .unwrap(); - assert!(marker.exists(), "the planted hook does not run at all"); - std::fs::remove_file(&marker).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 = hermetic( - std::process::Command::new("git") - .args(["config", "core.fsmonitor"]) - .arg(&hook) - .current_dir(dir), - ) - .status() - .unwrap() - .success(); - assert!(ok, "failed to plant the hook in the repository config"); - marker -} - -/// Set a repository config key with `git config`, asserting it took. -fn set_config(dir: &std::path::Path, key: &str, value: &str) { - let ok = hermetic( - std::process::Command::new("git") - .args(["config", key, value]) - .current_dir(dir), - ) - .status() - .unwrap() - .success(); - assert!(ok, "failed to set {key} in the test workspace"); -} - -/// Issue #5494. `git status` executes the command named by the workspace's -/// own repository config unless `run_git_command_in` refuses to run under it. -/// Revert the hardening 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(); - init_git_repo(tmp.path()); - let marker = plant_fsmonitor_hook(tmp.path()); - - let tool = test_tool(tmp.path()); - let result = tool.execute(json!({"operation": "status"})).await; - let msg = error_text(&result); - - assert!( - !marker.exists(), - "`git status` executed the command named by the workspace's own \ - repository config — this tool is a code-execution primitive" - ); - assert!( - msg.contains("fsmonitor"), - "the refusal should name the key that caused it, got: {msg}" - ); -} - -/// The allowlist has to leave an ordinary repository working, or the fix is -/// just a different way of breaking the tool. -#[tokio::test] -async fn an_ordinary_repository_still_reports_status() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - std::fs::write(tmp.path().join("tracked.txt"), "hi").unwrap(); - - let tool = test_tool(tmp.path()); - let result = tool.execute(json!({"operation": "status"})).await.unwrap(); - - assert!(!result.is_error, "got: {}", result.output()); - assert!( - result.output().contains("tracked.txt"), - "a plain `git init` workspace must still report status, got: {}", - result.output() - ); -} - -/// A first-draft allowlist that refused any repository carrying an ordinary -/// setting like `core.autocrlf` would report nothing useful for a large class -/// of real workspaces. -#[tokio::test] -async fn an_inert_setting_an_ordinary_repository_carries_is_allowed() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - set_config(tmp.path(), "core.autocrlf", "input"); - set_config(tmp.path(), "gc.auto", "0"); - set_config(tmp.path(), "remote.origin.prune", "true"); - - let tool = test_tool(tmp.path()); - let result = tool.execute(json!({"operation": "status"})).await.unwrap(); - - assert!( - !result.is_error && !result.output().contains("not on the allowlist"), - "an ordinary repository must still report status, got: {}", - result.output() - ); -} - -/// 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. -#[tokio::test] -async fn an_lfs_clone_is_refused_because_its_filter_names_a_program() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - // What `git lfs install` writes. `required` is inert and allowed; the - // three programs are not. - set_config(tmp.path(), "filter.lfs.required", "true"); - set_config(tmp.path(), "filter.lfs.clean", "git-lfs clean -- %f"); - - let tool = test_tool(tmp.path()); - let result = tool.execute(json!({"operation": "status"})).await; - let msg = error_text(&result); - - assert!( - msg.contains("filter.lfs.clean"), - "the refusal must name the key that caused it, got: {msg}" - ); -} - -/// `credential.helper` reads like a preference and is command-valued: a value -/// beginning `!` is run as a shell command. It must be refused however inert -/// it reads. -#[tokio::test] -async fn credential_helper_is_refused_despite_looking_like_a_preference() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - set_config(tmp.path(), "credential.helper", "!echo pwned"); - - let tool = test_tool(tmp.path()); - let result = tool.execute(json!({"operation": "status"})).await; - let msg = error_text(&result); - - assert!( - msg.contains("credential.helper"), - "a command-valued key must be refused however inert it reads, got: {msg}" - ); -} - -/// The refusal must hold for a write operation too, not just `status` — the -/// same repository config runs under `commit`/`add`/`checkout`/`stash`. -#[tokio::test] -async fn refusal_also_covers_write_operations() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - set_config(tmp.path(), "credential.helper", "!echo pwned"); - - let tool = test_tool(tmp.path()); - let result = tool - .execute(json!({"operation": "commit", "message": "test"})) - .await - .unwrap(); - - assert!( - result.is_error && result.output().contains("credential.helper"), - "write operations must be refused under untrusted repo config too, got: {}", - result.output() - ); -} - -/// `core.worktree` redirects the working-tree root every write operation -/// here (`checkout`, `add`, `commit`, `stash`) targets. Left on the -/// allowlist, a repository config could point that root outside -/// `action_dir` and turn a supposedly sandboxed write into one against an -/// arbitrary directory. Nothing this tool does needs the key — worktree -/// isolation goes through `WorkspaceDescriptor` instead. -#[tokio::test] -async fn core_worktree_is_refused_because_it_can_redirect_writes_outside_the_sandbox() { - let tmp = TempDir::new().unwrap(); - let elsewhere = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - set_config( - tmp.path(), - "core.worktree", - &elsewhere.path().to_string_lossy(), - ); - - let tool = test_tool(tmp.path()); - let result = tool.execute(json!({"operation": "status"})).await; - let msg = error_text(&result); - - assert!( - msg.contains("core.worktree"), - "the refusal must name the key that caused it, got: {msg}" - ); -} - -/// `extensions.worktreeConfig` is itself allowlisted as an ordinary setting, -/// but turning it on makes git additionally read `config.worktree` — a -/// second file `--local` alone does not see. A `core.hooksPath` set there is -/// invisible to a `--local`-only inspection and would still run on the next -/// `commit`. The inspection step must read the same merged view git does. -#[tokio::test] -async fn a_hookspath_hidden_in_worktree_scoped_config_is_still_refused() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - set_config(tmp.path(), "extensions.worktreeConfig", "true"); - // What `git config --worktree core.hooksPath ` writes; `set_config` - // only reaches `--local`, so this key is planted directly the same way - // the production inspection step reads it — via a real `git config - // --worktree` invocation — to prove the bypass is closed, not just that - // `set_config` happens to skip it. - let ok = hermetic( - std::process::Command::new("git") - .args(["config", "--worktree", "core.hooksPath"]) - .arg(tmp.path()) - .current_dir(tmp.path()), - ) - .status() - .unwrap() - .success(); - assert!(ok, "failed to set core.hooksPath in worktree-scoped config"); - - let tool = test_tool(tmp.path()); - let result = tool.execute(json!({"operation": "status"})).await; - let msg = error_text(&result); - - assert!( - msg.contains("core.hookspath"), - "a hookspath hidden in worktree-scoped config must still be refused, got: {msg}" - ); -} - -/// The allowlist inspection and the real command are two separate `git` -/// invocations, so a config change landing in the gap between them would be -/// invisible to the first and still reach the second. This test calls -/// `hardened_git` directly — skipping `first_disallowed_repo_config_key` -/// entirely, standing in for that gap — to prove the second invocation does -/// not depend on the first having caught anything: `core.hooksPath` is -/// neutralised at the point of execution regardless of what any inspection -/// saw or missed. -#[cfg(unix)] -#[tokio::test] -async fn hardened_git_neutralises_hookspath_even_if_the_allowlist_check_never_ran() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - set_config(tmp.path(), "user.email", "test@example.com"); - set_config(tmp.path(), "user.name", "Test"); - - let hooks_dir = tmp.path().join("evil-hooks"); - std::fs::create_dir(&hooks_dir).unwrap(); - let marker = tmp.path().join("HOOK_RAN"); - std::fs::write( - hooks_dir.join("pre-commit"), - format!("#!/bin/sh\ntouch {:?}\nexit 0\n", marker.to_string_lossy()), - ) - .unwrap(); - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions( - hooks_dir.join("pre-commit"), - std::fs::Permissions::from_mode(0o755), - ) - .unwrap(); - } - set_config(tmp.path(), "core.hooksPath", &hooks_dir.to_string_lossy()); - - std::fs::write(tmp.path().join("f.txt"), "hi").unwrap(); - let staged = hermetic( - std::process::Command::new("git") - .args(["add", "f.txt"]) - .current_dir(tmp.path()), - ) - .status() - .unwrap() - .success(); - assert!(staged, "failed to stage the test file"); - - let output = super::hardened_git(tmp.path()) - .args(["commit", "-m", "msg"]) - .output() - .await - .unwrap(); - - assert!( - output.status.success(), - "commit should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - !marker.exists(), - "hardened_git ran the repository-configured pre-commit hook — the \ - override that is supposed to hold even without the allowlist check \ - did not" - ); -} - -/// `commit.gpgsign` is on `ALLOWED_REPO_CONFIG` as an ordinary boolean, but -/// left un-neutralised it would let a repository force every commit through -/// this tool to be signed. `output.status.success()` alone does not prove -/// that: a repository that also configures a *working* `gpg.program` would -/// make a signed commit succeed too, so this plants a fake one that always -/// signs successfully and then inspects the commit object itself for a -/// `gpgsig` header — the only assertion that actually distinguishes "signing -/// was skipped" from "signing was attempted and happened to work". -#[cfg(unix)] -#[tokio::test] -async fn hardened_git_neutralises_forced_commit_signing() { - use std::os::unix::fs::PermissionsExt; - - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - set_config(tmp.path(), "user.email", "test@example.com"); - set_config(tmp.path(), "user.name", "Test"); - set_config(tmp.path(), "commit.gpgsign", "true"); - - // A `gpg.program` that always "signs" successfully, so a regression here - // fails by finding a signature, not by the commit merely erroring out — - // the same distinction CodeRabbit's review raised. - let fake_gpg = tmp.path().join("fake-gpg.sh"); - std::fs::write( - &fake_gpg, - "#!/bin/sh\n\ - printf '%s\\n' '[GNUPG:] BEGIN_SIGNING H10' >&2\n\ - cat >/dev/null\n\ - printf -- '-----BEGIN PGP SIGNATURE-----\\n\\nZmFrZQ==\\n-----END PGP SIGNATURE-----\\n'\n\ - printf '%s\\n' '[GNUPG:] SIG_CREATED D 1 10 00 0 0123456789ABCDEF' >&2\n", - ) - .unwrap(); - std::fs::set_permissions(&fake_gpg, std::fs::Permissions::from_mode(0o755)).unwrap(); - set_config(tmp.path(), "gpg.program", &fake_gpg.to_string_lossy()); - - std::fs::write(tmp.path().join("f.txt"), "hi").unwrap(); - let staged = hermetic( - std::process::Command::new("git") - .args(["add", "f.txt"]) - .current_dir(tmp.path()), - ) - .status() - .unwrap() - .success(); - assert!(staged, "failed to stage the test file"); - - let output = super::hardened_git(tmp.path()) - .args(["commit", "-m", "msg"]) - .output() - .await - .unwrap(); - assert!( - output.status.success(), - "commit failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let commit_object = super::hardened_git(tmp.path()) - .args(["cat-file", "-p", "HEAD"]) - .output() - .await - .unwrap(); - assert!(commit_object.status.success()); - let commit_object = String::from_utf8_lossy(&commit_object.stdout); - assert!( - !commit_object.lines().any(|l| l.starts_with("gpgsig ")), - "commit.gpgsign=true must not be honoured, but HEAD carries a \ - signature: {commit_object}" - ); -} - -/// The config-inspection step must fail closed: if `git config --list -/// --local` cannot be read, that is not the same as "nothing to distrust", -/// and running the real command anyway would skip the check entirely. -#[cfg(unix)] -#[tokio::test] -async fn unreadable_repo_config_fails_closed_rather_than_running_anyway() { - use std::os::unix::fs::PermissionsExt; - - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - let config_path = tmp.path().join(".git").join("config"); - std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o000)).unwrap(); - - // Root (and some CI containers run as root) ignores this permission bit - // entirely, which would make the assertion below meaningless rather than - // wrong. Detect that up front instead of failing on an unrelated cause. - let permission_enforced = std::fs::File::open(&config_path).is_err(); - - let result = if permission_enforced { - let tool = test_tool(tmp.path()); - Some(tool.execute(json!({"operation": "status"})).await) - } else { - None - }; - - // Restore permissions before the TempDir is dropped, so cleanup doesn't - // fail on an unreadable file. - std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap(); - - let Some(result) = result else { - eprintln!( - "skipping: file permissions are not enforced against this process (running as root?)" - ); - return; - }; - let msg = error_text(&result); - - assert!( - msg.contains("could not inspect its repository config"), - "an unreadable repo config must refuse, not silently proceed, got: {msg}" - ); -} - -#[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"); - // The subsection itself contains dots; the first and last components - // remain the reliable ones. - assert_eq!( - normalise_config_key("includeIf.gitdir:~/x.y/.path"), - "includeif.path" - ); - // A key with no dot at all is returned unchanged rather than panicking. - assert_eq!(normalise_config_key("bare"), "bare"); -} - -// ── External diff suppression ───────────────────────────────────────────── - -/// An ordinary repository's `diff` must produce its patch. -/// -/// That reads like it could not possibly regress, and it did. Suppression of -/// an external diff was attempted with `-c diff.external=` in -/// `NEUTRALISED_CONFIG`, and an empty value does not disable one — git tries -/// to *execute* the empty string, so **every** diff died with -/// `error: cannot run : No such file or directory` / -/// `fatal: external diff died`, on every repository, hostile or not. The -/// hardening removed the operation instead of hardening it. `--no-ext-diff` -/// on the diff command is the real suppression. -/// -/// This lives in the lib suite deliberately. The integration test that caught -/// it sits in `raw_coverage_all`, and a change to `git_operations.rs` maps to -/// the `openhuman::tools` libtest filter — which never selects that target. So -/// the lane the change picks did not run the test covering the change, and the -/// breakage sat until an unrelated PR happened to select the other filter. -/// A test here runs whenever this file is touched. -/// -/// A repository that *sets* `diff.external` is a separate matter and is -/// already refused before reaching the invocation: the key is on neither -/// allowlist, so `first_disallowed_repo_config_key` rejects the repository -/// outright. `--no-ext-diff` is the second layer, covering the gap between -/// that inspection and the command. -#[tokio::test] -async fn an_ordinary_repository_still_produces_a_diff() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - - // A committer identity has to be set in the repository itself. `hermetic` - // closes the global and system config, which is the point of it — so on a - // CI container with no identity of its own `git commit` fails with - // "Author identity unknown". Both keys are on `ALLOWED_REPO_CONFIG`, so - // setting them does not trip the repository-config refusal. - set_config(tmp.path(), "user.email", "test@example.invalid"); - set_config(tmp.path(), "user.name", "Test"); - - let tracked = tmp.path().join("tracked.txt"); - std::fs::write(&tracked, "first\n").unwrap(); - // `hermetic` closes the ambient git config the way the fixtures do — - // without it a developer's global `commit.gpgsign` or hooks path can fail - // this commit for reasons unrelated to the test. - // Slices, not arrays: `["add", "tracked.txt"]` and `["commit", "-m", "one"]` - // are `[&str; 2]` and `[&str; 3]`, which are different types and cannot - // share an array literal. - for args in [&["add", "tracked.txt"][..], &["commit", "-m", "one"][..]] { - let ok = hermetic( - std::process::Command::new("git") - .args(args) - .current_dir(tmp.path()), - ) - .status() - .unwrap() - .success(); - assert!(ok, "failed to run `git {}` in the test workspace", args[0]); - } - std::fs::write(&tracked, "first\nsecond\n").unwrap(); - - let tool = test_tool(tmp.path()); - let result = tool - .execute(json!({"operation": "diff", "files": "tracked.txt"})) - .await - .expect("a diff on a plain repository must not error out"); - - assert!(!result.is_error, "got: {}", result.output()); - assert!( - result.output().contains("second"), - "the added line must appear in the patch: {}", - result.output() - ); - assert!( - !result.output().contains("external diff died"), - "git must not be handed an empty `diff.external` to execute: {}", - result.output() - ); -} - -/// The shell subset must stay a subset, so widening the policy is a decision. -/// -/// `SHELL_NEUTRALISED_CONFIG` exists because the `shell` tool cannot use the -/// `-c` layer `hardened_git` builds. The risk is drift in one direction: -/// someone adds a newly-dangerous key to `NEUTRALISED_CONFIG`, the shell path -/// silently keeps running without it, and the gap is invisible. This test -/// cannot force the addition — some entries genuinely must not cross, and the -/// doc comment says which and why — but it does force every shell entry to be -/// one the main policy already recognises. -#[test] -fn the_shell_subset_never_invents_a_key_the_main_policy_does_not_have() { - for entry in SHELL_NEUTRALISED_CONFIG { - let known = NEUTRALISED_CONFIG.contains(entry) - // `hardened_git` adds this one as an argument rather than through - // the static list; it is still the same policy. - || *entry == "commit.gpgSign=false"; - assert!( - known, - "`{entry}` is neutralised for the shell but is not part of the \ - git_operations policy it is supposed to mirror" - ); - } -} - -/// The three deliberate exclusions must stay excluded, with their reasons. -/// -/// Each would break ordinary work rather than harden it: an empty -/// `core.sshCommand` is a command git tries to execute (the same trap the -/// `diff.external` note records), while overriding `core.hooksPath` would -/// suppress repository hooks. Re-adding any of them looks like tightening -/// security and is actually an outage, so it should be a conscious edit here -/// first. -#[test] -fn the_shell_subset_keeps_its_documented_exclusions() { - for excluded in ["core.sshCommand=", "diff.external=", "core.hooksPath="] { - assert!( - !SHELL_NEUTRALISED_CONFIG.contains(&excluded), - "`{excluded}` is excluded from the shell subset on purpose — see \ - the doc comment before changing this" - ); - } -} - -#[test] -fn shell_git_environment_forces_the_safe_config_subset() { - let env = shell_git_env(); - let parameters = env - .get(std::ffi::OsStr::new("GIT_CONFIG_PARAMETERS")) - .expect("the shell git environment sets GIT_CONFIG_PARAMETERS") - .to_string_lossy(); - - for expected in SHELL_NEUTRALISED_CONFIG { - let (key, value) = expected.split_once('=').unwrap(); - assert!( - parameters.contains(&format!("'{key}'='{value}'")), - "`{expected}` is missing from GIT_CONFIG_PARAMETERS: {parameters}" - ); - } -} - -/// Exercise the environment through Git itself rather than only checking its -/// serialization. A syntactically plausible `GIT_CONFIG_PARAMETERS` value is -/// not useful if Git rejects it, and `core.fsmonitor=` must actually override -/// the command-valued setting in the repository config. -#[cfg(unix)] -#[test] -fn shell_git_environment_is_accepted_and_neutralises_repository_fsmonitor() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - let marker = plant_fsmonitor_hook(tmp.path()); - - // Prove this Git version and repository really do invoke the planted - // fsmonitor hook. Without this control, an absent marker below could be a - // false positive caused by Git never consulting the setting. - let plain = hermetic( - std::process::Command::new("git") - .arg("status") - .current_dir(tmp.path()), - ) - .output() - .expect("failed to run the unhardened control command"); - assert!( - plain.status.success(), - "the unhardened control command failed: {}", - String::from_utf8_lossy(&plain.stderr) - ); - assert!( - marker.exists(), - "the unhardened control did not execute the planted fsmonitor hook" - ); - std::fs::remove_file(&marker).unwrap(); - - let env = shell_git_env(); - let mut hardened = std::process::Command::new("git"); - hardened.arg("status").current_dir(tmp.path()); - hermetic(&mut hardened).envs(env); - let output = hardened - .output() - .expect("failed to run git with the shell hardening environment"); - - assert!( - output.status.success(), - "Git rejected the shell hardening environment: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - !marker.exists(), - "Git executed repository core.fsmonitor despite the shell hardening environment" - ); -} diff --git a/crates/tinytools-std/src/filesystem/git_operations/mod.rs b/crates/tinytools-std/src/filesystem/git_operations/mod.rs deleted file mode 100644 index fd40694b..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/mod.rs +++ /dev/null @@ -1,663 +0,0 @@ -use super::gate::FsGate; -use async_trait::async_trait; -use serde_json::json; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tinytools::ToolRunContext; -use tinytools::{Tool, ToolCallOptions, ToolResult}; - -pub(crate) mod config; -mod render; - -pub use self::config::shell_git_env; -use self::config::{disallowed_config_refusal, first_disallowed_repo_config_key, hardened_git}; -use self::render::{render_branch_markdown, render_log_markdown, render_status_markdown}; - -/// Git operations tool for structured repository management. -/// Provides safe, parsed git operations with JSON output. -#[derive(Debug)] -pub struct GitOperationsTool { - gate: Arc, - action_dir: PathBuf, -} - -impl GitOperationsTool { - /// Create the tool. - #[must_use] - pub fn new(gate: Arc, action_dir: PathBuf) -> Self { - Self { gate, action_dir } - } - - /// Resolve the working directory for git operations. - /// - /// Returns the per-worker git-worktree checkout when the tinyagents harness - /// threaded a [`WorkspaceDescriptor`] into this call's - /// [`ToolExecutionContext`] — an edit-capable worker running with - /// `isolation = "worktree"`, whose isolated worktree root is carried on the - /// run context (`RunContext::with_workspace`) and surfaced per tool call via - /// `ToolExecutionContext::from_run_context`. Otherwise falls back to the - /// tool's configured `action_dir`, which preserves the non-isolated - /// behaviour exactly. See #3376, #4249 (08.5). - fn effective_action_dir_for_context(&self, context: Option<&dyn ToolRunContext>) -> PathBuf { - if let Some(workspace) = context.and_then(|ctx| ctx.workspace()) { - tracing::debug!( - workspace_root = %workspace.root.display(), - policy_id = %workspace.policy_id, - "[git_operations] using TinyAgents workspace descriptor as action dir" - ); - return workspace.root.clone(); - } - self.action_dir.clone() - } - - /// Sanitize git arguments to prevent injection attacks - fn sanitize_git_args(&self, args: &str) -> anyhow::Result> { - let mut result = Vec::new(); - for arg in args.split_whitespace() { - // Block dangerous git options that could lead to command injection - let arg_lower = arg.to_lowercase(); - if arg_lower.starts_with("--exec=") - || arg_lower.starts_with("--upload-pack=") - || arg_lower.starts_with("--receive-pack=") - || arg_lower.starts_with("--pager=") - || arg_lower.starts_with("--editor=") - || arg_lower == "--no-verify" - || arg_lower.contains("$(") - || arg_lower.contains('`') - || arg.contains('|') - || arg.contains(';') - || arg.contains('>') - { - anyhow::bail!("Blocked potentially dangerous git argument: {arg}"); - } - // Block `-c` config injection (exact match or `-c=...` prefix). - // This must not false-positive on `--cached` or `-cached`. - if arg_lower == "-c" || arg_lower.starts_with("-c=") { - anyhow::bail!("Blocked potentially dangerous git argument: {arg}"); - } - result.push(arg.to_string()); - } - Ok(result) - } - - /// Check if an operation requires write access - fn requires_write_access(&self, operation: &str) -> bool { - matches!( - operation, - "commit" | "add" | "checkout" | "stash" | "reset" | "revert" - ) - } - - /// Check if an operation is read-only - #[allow(dead_code)] // exercised by the tests; kept with `requires_write_access` - fn is_read_only(&self, operation: &str) -> bool { - matches!( - operation, - "status" | "diff" | "log" | "show" | "branch" | "rev-parse" - ) - } - - async fn run_git_command_in(&self, cwd: &Path, args: &[&str]) -> anyhow::Result { - if let Some(key) = first_disallowed_repo_config_key(cwd).await? { - tracing::debug!( - "[git_operations] refusing to run git: dir={}, disallowed_config_key={key}", - cwd.display() - ); - anyhow::bail!("{}", disallowed_config_refusal(cwd, &key)) - } - - let output = hardened_git(cwd).args(args).output().await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("Git command failed: {stderr}"); - } - - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } - - async fn git_status(&self, cwd: &Path, _args: serde_json::Value) -> anyhow::Result { - let output = self - .run_git_command_in(cwd, &["status", "--porcelain=2", "--branch"]) - .await?; - - // Parse git status output into structured format - let mut result = serde_json::Map::new(); - let mut branch = String::new(); - let mut staged = Vec::new(); - let mut unstaged = Vec::new(); - let mut untracked = Vec::new(); - - for line in output.lines() { - if line.starts_with("# branch.head ") { - branch = line.trim_start_matches("# branch.head ").to_string(); - } else if let Some(rest) = line.strip_prefix("1 ") { - // Ordinary changed entry - let mut parts = rest.splitn(3, ' '); - if let (Some(staging), Some(path)) = (parts.next(), parts.next()) - && !staging.is_empty() - { - let status_char = staging.chars().next().unwrap_or(' '); - if status_char != '.' && status_char != ' ' { - staged.push(json!({"path": path, "status": status_char})); - } - let status_char = staging.chars().nth(1).unwrap_or(' '); - if status_char != '.' && status_char != ' ' { - unstaged.push(json!({"path": path, "status": status_char})); - } - } - } else if let Some(rest) = line.strip_prefix("? ") { - untracked.push(rest.to_string()); - } - } - - result.insert("branch".to_string(), json!(branch)); - result.insert("staged".to_string(), json!(staged)); - result.insert("unstaged".to_string(), json!(unstaged)); - result.insert("untracked".to_string(), json!(untracked)); - result.insert( - "clean".to_string(), - json!(staged.is_empty() && unstaged.is_empty() && untracked.is_empty()), - ); - - let mut tr = ToolResult::success(serde_json::to_string_pretty(&result).unwrap_or_default()); - tr.markdown_formatted = Some(render_status_markdown(&result)); - Ok(tr) - } - - async fn git_diff(&self, cwd: &Path, args: serde_json::Value) -> anyhow::Result { - let files = args.get("files").and_then(|v| v.as_str()).unwrap_or("."); - let cached = args - .get("cached") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - - // Validate files argument against injection patterns - self.sanitize_git_args(files)?; - - // `--no-ext-diff` is where external-diff suppression has to live: it is - // a diff-command flag, and the `-c diff.external=` that used to stand in - // for it made git exec the empty string instead of disabling anything. - // - // `--no-textconv` is a SEPARATE mechanism and needs its own flag. - // `--no-ext-diff` covers `diff.external` and `diff..command`; - // it does not touch `diff..textconv`, which a `.gitattributes` - // line (`*.bin diff=evil`) can select and which git then EXECUTES to - // render a binary file as text. Verified against a scratch repo: with - // `--no-ext-diff` alone the textconv script ran; adding - // `--no-textconv` it did not. `hardened_git`'s `-c` list cannot close - // this — driver names are arbitrary, so there is no finite set of keys - // to neutralise, and the suppression has to be a command flag. - let mut git_args = vec!["diff", "--no-ext-diff", "--no-textconv", "--unified=3"]; - if cached { - git_args.push("--cached"); - } - git_args.push("--"); - git_args.push(files); - - let output = self.run_git_command_in(cwd, &git_args).await?; - - // Parse diff into structured hunks - let mut result = serde_json::Map::new(); - let mut hunks = Vec::new(); - let mut current_file = String::new(); - let mut current_hunk = serde_json::Map::new(); - let mut lines = Vec::new(); - - for line in output.lines() { - if line.starts_with("diff --git ") { - if !lines.is_empty() { - current_hunk.insert("lines".to_string(), json!(lines)); - if !current_hunk.is_empty() { - hunks.push(serde_json::Value::Object(current_hunk.clone())); - } - lines = Vec::new(); - current_hunk = serde_json::Map::new(); - } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 4 { - current_file = parts[3].trim_start_matches("b/").to_string(); - current_hunk.insert("file".to_string(), json!(current_file)); - } - } else if line.starts_with("@@ ") { - if !lines.is_empty() { - current_hunk.insert("lines".to_string(), json!(lines)); - if !current_hunk.is_empty() { - hunks.push(serde_json::Value::Object(current_hunk.clone())); - } - lines = Vec::new(); - current_hunk = serde_json::Map::new(); - current_hunk.insert("file".to_string(), json!(current_file)); - } - current_hunk.insert("header".to_string(), json!(line)); - } else if !line.is_empty() { - lines.push(json!({ - "text": line, - "type": if line.starts_with('+') { "add" } - else if line.starts_with('-') { "delete" } - else { "context" } - })); - } - } - - if !lines.is_empty() { - current_hunk.insert("lines".to_string(), json!(lines)); - if !current_hunk.is_empty() { - hunks.push(serde_json::Value::Object(current_hunk)); - } - } - - result.insert("hunks".to_string(), json!(hunks)); - result.insert("file_count".to_string(), json!(hunks.len())); - - Ok(ToolResult::success( - serde_json::to_string_pretty(&result).unwrap_or_default(), - )) - } - - async fn git_log(&self, cwd: &Path, args: serde_json::Value) -> anyhow::Result { - let limit_raw = args - .get("limit") - .and_then(serde_json::Value::as_u64) - .unwrap_or(10); - let limit = usize::try_from(limit_raw).unwrap_or(usize::MAX).min(1000); - let limit_str = limit.to_string(); - - let output = self - .run_git_command_in( - cwd, - &[ - "log", - &format!("-{limit_str}"), - "--pretty=format:%H|%an|%ae|%ad|%s", - "--date=iso", - ], - ) - .await?; - - let mut commits = Vec::new(); - - for line in output.lines() { - let parts: Vec<&str> = line.split('|').collect(); - if parts.len() >= 5 { - commits.push(json!({ - "hash": parts[0], - "author": parts[1], - "email": parts[2], - "date": parts[3], - "message": parts[4] - })); - } - } - - let mut tr = ToolResult::success( - serde_json::to_string_pretty(&json!({ "commits": commits })).unwrap_or_default(), - ); - tr.markdown_formatted = Some(render_log_markdown(&commits)); - Ok(tr) - } - - async fn git_branch(&self, cwd: &Path, _args: serde_json::Value) -> anyhow::Result { - let output = self - .run_git_command_in(cwd, &["branch", "--format=%(refname:short)|%(HEAD)"]) - .await?; - - let mut branches = Vec::new(); - let mut current = String::new(); - - for line in output.lines() { - if let Some((name, head)) = line.split_once('|') { - let is_current = head == "*"; - if is_current { - current = name.to_string(); - } - branches.push(json!({ - "name": name, - "current": is_current - })); - } - } - - let mut tr = ToolResult::success( - serde_json::to_string_pretty(&json!({ - "current": current, - "branches": branches - })) - .unwrap_or_default(), - ); - tr.markdown_formatted = Some(render_branch_markdown(¤t, &branches)); - Ok(tr) - } - - fn truncate_commit_message(message: &str) -> String { - if message.chars().count() > 2000 { - format!("{}...", message.chars().take(1997).collect::()) - } else { - message.to_string() - } - } - - async fn git_commit(&self, cwd: &Path, args: serde_json::Value) -> anyhow::Result { - let message = args - .get("message") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))?; - - // Sanitize commit message - let sanitized = message - .lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .collect::>() - .join("\n"); - - if sanitized.is_empty() { - anyhow::bail!("Commit message cannot be empty"); - } - - // Limit message length - let message = Self::truncate_commit_message(&sanitized); - - let output = self - .run_git_command_in(cwd, &["commit", "-m", &message]) - .await; - - match output { - Ok(_) => Ok(ToolResult::success(format!("Committed: {message}"))), - Err(e) => Ok(ToolResult::error(format!("Commit failed: {e}"))), - } - } - - async fn git_add(&self, cwd: &Path, args: serde_json::Value) -> anyhow::Result { - let paths = args - .get("paths") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'paths' parameter"))?; - - // Validate paths against injection patterns - self.sanitize_git_args(paths)?; - - let output = self.run_git_command_in(cwd, &["add", "--", paths]).await; - - match output { - Ok(_) => Ok(ToolResult::success(format!("Staged: {paths}"))), - Err(e) => Ok(ToolResult::error(format!("Add failed: {e}"))), - } - } - - async fn git_checkout( - &self, - cwd: &Path, - args: serde_json::Value, - ) -> anyhow::Result { - let branch = args - .get("branch") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'branch' parameter"))?; - - // Sanitize branch name - let sanitized = self.sanitize_git_args(branch)?; - - if sanitized.is_empty() || sanitized.len() > 1 { - anyhow::bail!("Invalid branch specification"); - } - - let branch_name = &sanitized[0]; - - // Block dangerous branch names - if branch_name.contains('@') || branch_name.contains('^') || branch_name.contains('~') { - anyhow::bail!("Branch name contains invalid characters"); - } - // An option such as `-f` would discard uncommitted work. - if branch_name.starts_with('-') { - anyhow::bail!("Branch name must not start with '-'"); - } - - // The trailing `--` makes git read the argument as a branch or commit - // only, never as a pathspec: `git checkout .` or `git checkout a.txt` - // would overwrite uncommitted changes. - let output = self - .run_git_command_in(cwd, &["checkout", branch_name, "--"]) - .await; - - match output { - Ok(_) => Ok(ToolResult::success(format!( - "Switched to branch: {branch_name}" - ))), - Err(e) => Ok(ToolResult::error(format!("Checkout failed: {e}"))), - } - } - - async fn git_stash(&self, cwd: &Path, args: serde_json::Value) -> anyhow::Result { - let action = args - .get("action") - .and_then(|v| v.as_str()) - .unwrap_or("push"); - - let output = match action { - "push" | "save" => { - self.run_git_command_in(cwd, &["stash", "push", "-m", "auto-stash"]) - .await - } - "pop" => self.run_git_command_in(cwd, &["stash", "pop"]).await, - "list" => self.run_git_command_in(cwd, &["stash", "list"]).await, - "drop" => { - let index_raw = args - .get("index") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let index = i32::try_from(index_raw) - .map_err(|_| anyhow::anyhow!("stash index too large: {index_raw}"))?; - self.run_git_command_in(cwd, &["stash", "drop", &format!("stash@{{{index}}}")]) - .await - } - _ => anyhow::bail!("Unknown stash action: {action}. Use: push, pop, list, drop"), - }; - - match output { - Ok(out) => Ok(ToolResult::success(out)), - Err(e) => Ok(ToolResult::error(format!("Stash {action} failed: {e}"))), - } - } -} - -#[async_trait] -impl Tool for GitOperationsTool { - fn name(&self) -> &'static str { - "git_operations" - } - - fn description(&self) -> &'static str { - "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash). Provides parsed JSON output and integrates with security policy for autonomy controls." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "operation": { - "type": "string", - "enum": ["status", "diff", "log", "branch", "commit", "add", "checkout", "stash"], - "description": "Git operation to perform" - }, - "message": { - "type": "string", - "description": "Commit message (for 'commit' operation)" - }, - "paths": { - "type": "string", - "description": "File paths to stage (for 'add' operation)" - }, - "branch": { - "type": "string", - "description": "Branch name (for 'checkout' operation)" - }, - "files": { - "type": "string", - "description": "File or path to diff (for 'diff' operation, default: '.')" - }, - "cached": { - "type": "boolean", - "description": "Show staged changes (for 'diff' operation)" - }, - "limit": { - "type": "integer", - "description": "Number of log entries (for 'log' operation, default: 10)" - }, - "action": { - "type": "string", - "enum": ["push", "pop", "list", "drop"], - "description": "Stash action (for 'stash' operation)" - }, - "index": { - "type": "integer", - "description": "Stash index (for 'stash' with 'drop' action)" - } - }, - "required": ["operation"] - }) - } - - fn supports_markdown(&self) -> bool { - true - } - - /// Write git operations (commit/add/checkout/stash/…) route through the - /// human approval gate in ask-before-edit; read operations (status/diff/ - /// log/branch) never prompt. In Full, writes run; read-only is blocked in - /// `execute` via the existing `can_act()` / autonomy check. - fn external_effect_with_args(&self, args: &serde_json::Value) -> bool { - let operation = args.get("operation").and_then(|v| v.as_str()).unwrap_or(""); - self.requires_write_access(operation) && self.gate.write_needs_approval() - } - - async fn execute_with_options( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - ) -> anyhow::Result { - // git_operations always populates `markdown_formatted` for the - // structured sub-operations (status/diff/log/branch). The harness - // picks it up when `prefer_markdown` is on; the JSON content - // block is preserved for callers that want the raw structure. - self.execute_in_context(args, None).await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - self.execute_in_context(args, context).await - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_in_context(args, None).await - } -} - -impl GitOperationsTool { - async fn execute_in_context( - &self, - args: serde_json::Value, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let operation = match args.get("operation").and_then(|v| v.as_str()) { - Some(op) => op, - None => { - return Ok(ToolResult::error("Missing 'operation' parameter")); - } - }; - - let effective_dir = self.effective_action_dir_for_context(context); - // Validate the repository instead of trusting the presence of a `.git` - // path. This handles linked-worktree gitfiles and rejects malformed - // ancestor markers that Git itself cannot open. - // - // Probed with `hardened_git` directly rather than through - // `run_git_command_in`. That helper refuses up front when the - // repository config carries a disallowed key, and routing the probe - // through it made the refusal indistinguishable from "there is no - // repository here": `is_ok_and` discarded the error, so a repo setting - // `core.fsmonitor` / `credential.helper` / `core.worktree` was reported - // as **not a git repository** and the message naming the key was lost - // (#5494). `first_disallowed_repo_config_key` even documents the - // ordering this restores — it expects the caller to have established - // that `dir` is a repository before it runs. - // - // Safe to probe unguarded: `hardened_git` already neutralises every - // config key that names a command, so this invocation cannot be turned - // into an execution primitive, and the real command below still goes - // through the full allow-list check in `run_git_command_in`. - let is_worktree = hardened_git(&effective_dir) - .args(["rev-parse", "--is-inside-work-tree"]) - .output() - .await - .is_ok_and(|output| { - output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true" - }); - if !is_worktree { - // The probe can fail *because* the repository is misconfigured: a - // `core.worktree` redirect git cannot follow, or a config file it - // cannot read. Ask the guard before concluding there is nothing - // here, so a refusal is reported as a refusal — "Not in a git - // repository" is both false and un-actionable for those (#5494). - return Ok( - match first_disallowed_repo_config_key(&effective_dir).await { - Ok(Some(key)) => { - ToolResult::error(disallowed_config_refusal(&effective_dir, &key)) - } - Err(error) => ToolResult::error(error.to_string()), - Ok(None) => ToolResult::error("Not in a git repository"), - }, - ); - } - - // Check autonomy level for write operations - if self.requires_write_access(operation) { - if !self.gate.can_act() { - return Ok(ToolResult::error( - "[policy-blocked] Action blocked: git write operations require higher autonomy level", - )); - } - - if self.gate.is_read_only() { - return Ok(ToolResult::error( - "[policy-blocked] Action blocked: read-only mode", - )); - } - } - - // Record action for rate limiting - if !self.gate.record_action() { - return Ok(ToolResult::error("Action blocked: rate limit exceeded")); - } - - // Execute the requested operation - match operation { - "status" => self.git_status(&effective_dir, args).await, - "diff" => self.git_diff(&effective_dir, args).await, - "log" => self.git_log(&effective_dir, args).await, - "branch" => self.git_branch(&effective_dir, args).await, - "commit" => self.git_commit(&effective_dir, args).await, - "add" => self.git_add(&effective_dir, args).await, - "checkout" => self.git_checkout(&effective_dir, args).await, - "stash" => self.git_stash(&effective_dir, args).await, - _ => Ok(ToolResult::error(format!("Unknown operation: {operation}"))), - } - } -} - -#[cfg(test)] -#[path = "config_tests.rs"] -mod config_test; -#[cfg(test)] -#[path = "ops_tests.rs"] -mod ops_test; -#[cfg(test)] -#[path = "render_tests.rs"] -mod render_test; -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/git_operations/mod_tests.rs b/crates/tinytools-std/src/filesystem/git_operations/mod_tests.rs deleted file mode 100644 index 6aa8b903..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/mod_tests.rs +++ /dev/null @@ -1,455 +0,0 @@ -//! Behavior tests for the `git_operations` tool, driven through a fake [`FsGate`]. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::config::NULL_CONFIG_PATH; -use super::*; -use crate::filesystem::test_support::{AutonomyLevel, TestGate, WorkspaceContext}; -use tempfile::TempDir; - -pub(super) fn test_tool(dir: &std::path::Path) -> GitOperationsTool { - let security = TestGate::with(dir.to_path_buf(), AutonomyLevel::Supervised, 1_000_000); - GitOperationsTool::new(security, dir.to_path_buf()) -} - -#[test] -fn sanitize_git_blocks_injection() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - // Should block dangerous arguments - assert!(tool.sanitize_git_args("--exec=rm -rf /").is_err()); - assert!(tool.sanitize_git_args("$(echo pwned)").is_err()); - assert!(tool.sanitize_git_args("`malicious`").is_err()); - assert!(tool.sanitize_git_args("arg | cat").is_err()); - assert!(tool.sanitize_git_args("arg; rm file").is_err()); -} - -#[test] -fn sanitize_git_blocks_pager_editor_injection() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - assert!(tool.sanitize_git_args("--pager=less").is_err()); - assert!(tool.sanitize_git_args("--editor=vim").is_err()); -} - -#[test] -fn sanitize_git_blocks_config_injection() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - // Exact `-c` flag (config injection) - assert!(tool.sanitize_git_args("-c core.sshCommand=evil").is_err()); - assert!(tool.sanitize_git_args("-c=core.pager=less").is_err()); -} - -#[test] -fn sanitize_git_blocks_no_verify() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - assert!(tool.sanitize_git_args("--no-verify").is_err()); -} - -#[test] -fn sanitize_git_blocks_redirect_in_args() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - assert!(tool.sanitize_git_args("file.txt > /tmp/out").is_err()); -} - -#[test] -fn sanitize_git_cached_not_blocked() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - // --cached must NOT be blocked by the `-c` check - assert!(tool.sanitize_git_args("--cached").is_ok()); - // Other safe flags starting with -c prefix - assert!(tool.sanitize_git_args("-cached").is_ok()); -} - -#[test] -fn sanitize_git_allows_safe() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - // Should allow safe arguments - assert!(tool.sanitize_git_args("main").is_ok()); - assert!(tool.sanitize_git_args("feature/test-branch").is_ok()); - assert!(tool.sanitize_git_args("--cached").is_ok()); - assert!(tool.sanitize_git_args("src/main.rs").is_ok()); - assert!(tool.sanitize_git_args(".").is_ok()); -} - -/// Parity guard for the worktree-isolation action-dir override (#3376, -/// #4249 08.5). A worktree-isolated worker's git operation MUST resolve its CWD -/// from the carried `WorkspaceDescriptor` (the isolated worktree), never the -/// tool's configured `action_dir`. WITHOUT a descriptor it falls back to -/// `action_dir` — the non-isolated path, byte-identical to before. This encodes -/// the behaviour the deleted `worktree_context.rs` task-local used to provide. -#[test] -fn git_resolves_cwd_from_workspace_descriptor() { - let action_tmp = TempDir::new().unwrap(); - let worktree_tmp = TempDir::new().unwrap(); - let tool = test_tool(action_tmp.path()); - - // WITH a descriptor → the worktree root wins. - let tool_ctx = WorkspaceContext::at(worktree_tmp.path()); - assert_eq!( - tool.effective_action_dir_for_context(Some(&tool_ctx)), - worktree_tmp.path().to_path_buf(), - "git with a WorkspaceDescriptor must resolve CWD to the worktree root" - ); - - // WITHOUT a descriptor → configured action_dir (non-isolated parity). - assert_eq!( - tool.effective_action_dir_for_context(None), - action_tmp.path().to_path_buf(), - "git with no descriptor must fall back to the configured action_dir" - ); -} - -#[test] -fn requires_write_detection() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - assert!(tool.requires_write_access("commit")); - assert!(tool.requires_write_access("add")); - assert!(tool.requires_write_access("checkout")); - - assert!(!tool.requires_write_access("status")); - assert!(!tool.requires_write_access("diff")); - assert!(!tool.requires_write_access("log")); -} - -#[test] -fn branch_is_not_write_gated() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - // Branch listing is read-only; it must not require write access - assert!(!tool.requires_write_access("branch")); - assert!(tool.is_read_only("branch")); -} - -#[test] -fn is_read_only_detection() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - assert!(tool.is_read_only("status")); - assert!(tool.is_read_only("diff")); - assert!(tool.is_read_only("log")); - assert!(tool.is_read_only("branch")); - - assert!(!tool.is_read_only("commit")); - assert!(!tool.is_read_only("add")); -} - -#[tokio::test] -async fn blocks_readonly_mode_for_write_ops() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - - let security = TestGate::with(tmp.path().to_path_buf(), AutonomyLevel::ReadOnly, 1_000_000); - let tool = GitOperationsTool::new(security, tmp.path().to_path_buf()); - - let result = tool - .execute(json!({"operation": "commit", "message": "test"})) - .await - .unwrap(); - assert!(result.is_error); - // can_act() returns false for ReadOnly, so we get the "higher autonomy level" message - assert!(result.output().contains("higher autonomy")); -} - -#[tokio::test] -async fn allows_branch_listing_in_readonly_mode() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - - let security = TestGate::with(tmp.path().to_path_buf(), AutonomyLevel::ReadOnly, 1_000_000); - let tool = GitOperationsTool::new(security, tmp.path().to_path_buf()); - - let result = tool.execute(json!({"operation": "branch"})).await.unwrap(); - // Branch listing must not be blocked by read-only autonomy - let error_msg = result.output(); - assert!( - !error_msg.contains("read-only") && !error_msg.contains("higher autonomy"), - "branch listing should not be blocked in read-only mode, got: {error_msg}" - ); -} - -#[tokio::test] -async fn allows_readonly_ops_in_readonly_mode() { - let tmp = TempDir::new().unwrap(); - let security = TestGate::with(tmp.path().to_path_buf(), AutonomyLevel::ReadOnly, 1_000_000); - let tool = GitOperationsTool::new(security, tmp.path().to_path_buf()); - - // This will fail because there's no git repo, but it shouldn't be blocked by autonomy - let result = tool.execute(json!({"operation": "status"})).await.unwrap(); - // The error should be about git (not about autonomy/read-only mode) - assert!(result.is_error, "Expected failure due to missing git repo"); - let error_msg = result.output(); - assert!( - !error_msg.is_empty(), - "Expected a git-related error message" - ); - assert!( - !error_msg.contains("read-only") && !error_msg.contains("autonomy"), - "Error should be about git, not about autonomy restrictions: {error_msg}" - ); -} - -#[tokio::test] -async fn rejects_missing_operation() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - - let result = tool.execute(json!({})).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Missing 'operation'")); -} - -#[tokio::test] -async fn rejects_unknown_operation() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - - let tool = test_tool(tmp.path()); - - let result = tool.execute(json!({"operation": "push"})).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Unknown operation")); -} - -#[test] -fn truncates_multibyte_commit_message_without_panicking() { - let long = "🦀".repeat(2500); - let truncated = GitOperationsTool::truncate_commit_message(&long); - - assert_eq!(truncated.chars().count(), 2000); -} - -// ── truncate_commit_message: short messages pass through unchanged ───────── - -#[test] -fn truncate_short_message_unchanged() { - let msg = "Fix the bug"; - assert_eq!(GitOperationsTool::truncate_commit_message(msg), msg); -} - -#[test] -fn truncate_exact_2000_chars_unchanged() { - let msg = "a".repeat(2000); - let result = GitOperationsTool::truncate_commit_message(&msg); - assert_eq!(result.chars().count(), 2000); - assert!(!result.ends_with("...")); -} - -#[test] -fn truncate_2001_chars_adds_ellipsis() { - let msg = "a".repeat(2001); - let result = GitOperationsTool::truncate_commit_message(&msg); - assert!(result.ends_with("...")); - assert_eq!(result.chars().count(), 2000); -} - -// ── sanitize_git_args: allow leading dash that is not -c ───────────────── - -#[test] -fn sanitize_git_allows_other_flags() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - assert!(tool.sanitize_git_args("--follow").is_ok()); - assert!(tool.sanitize_git_args("-p").is_ok()); - assert!(tool.sanitize_git_args("-n5").is_ok()); -} - -// ── requires_write_access completeness ──────────────────────────────────── - -#[test] -fn requires_write_access_covers_all_write_ops() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - for op in ["commit", "add", "checkout", "stash", "reset", "revert"] { - assert!( - tool.requires_write_access(op), - "'{op}' should require write access" - ); - } -} - -// ── schema validation ───────────────────────────────────────────────────── - -#[test] -fn schema_has_required_operation() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - let schema = tool.parameters_schema(); - let required = schema["required"].as_array().unwrap(); - assert!( - required.contains(&serde_json::json!("operation")), - "schema required should include 'operation'" - ); -} - -#[test] -fn schema_enumerates_operations() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - let schema = tool.parameters_schema(); - let ops = schema["properties"]["operation"]["enum"] - .as_array() - .unwrap(); - let op_names: Vec<&str> = ops.iter().map(|v| v.as_str().unwrap()).collect(); - for expected in [ - "status", "diff", "log", "branch", "commit", "add", "checkout", "stash", - ] { - assert!( - op_names.contains(&expected), - "schema should include '{expected}'" - ); - } -} - -// ── git_operations tool name / description ──────────────────────────────── - -#[test] -fn tool_name_and_description() { - let tmp = TempDir::new().unwrap(); - let tool = test_tool(tmp.path()); - assert_eq!(tool.name(), "git_operations"); - assert_ne!(tool.description().len(), 0); - assert!(tool.description().contains("Git")); -} - -// ── not_in_git_repo returns error (covers the git-repo check) ───────────── - -#[tokio::test] -async fn not_in_git_repo_returns_error() { - let tmp = TempDir::new().unwrap(); - // Do NOT init a git repo - let tool = test_tool(tmp.path()); - let result = tool.execute(json!({"operation": "status"})).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Not in a git repository")); -} - -/// Suppress the developer's own system/global git config on a raw -/// `std::process::Command`, so a machine-local `init.templateDir` or similar -/// cannot write extra keys into a test repository's `.git/config` and make -/// these tests depend on ambient environment. Mirrors [`hardened_git`]'s two -/// env vars; the production code under test applies its own suppression when -/// it later reads this same config, so this only affects setup. -pub(super) fn hermetic(cmd: &mut std::process::Command) -> &mut std::process::Command { - cmd.env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", NULL_CONFIG_PATH) - .env_remove("GIT_CONFIG_PARAMETERS") - .env_remove("GIT_CONFIG_COUNT") -} - -/// Initialise a git repo at `path` and fail the test if `git init` -/// itself didn't succeed (so we don't misread later assertion failures -/// as product bugs when the real problem is a missing/broken git). -pub(super) fn init_git_repo(path: &std::path::Path) { - let output = hermetic( - std::process::Command::new("git") - .args(["init"]) - .current_dir(path), - ) - .output() - .expect("failed to spawn `git init`"); - assert!( - output.status.success(), - "`git init` failed: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -/// Extract the error text from a Result — whether the -/// failure came through `Err(anyhow::Error)` or `Ok(ToolResult::error)`. -pub(super) fn error_text(result: &anyhow::Result) -> String { - match result { - Ok(r) => { - assert!(r.is_error, "expected a tool-error ToolResult"); - r.output().clone() - } - Err(e) => e.to_string(), - } -} - -// ── stash: unknown action returns error ──────────────────────────────────── - -#[tokio::test] -async fn stash_unknown_action_returns_error() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - - let tool = test_tool(tmp.path()); - let result = tool - .execute(json!({"operation": "stash", "action": "squash"})) - .await; - let msg = error_text(&result); - assert!( - msg.contains("Unknown stash action"), - "expected 'Unknown stash action' in error, got: {msg}" - ); -} - -// ── checkout: dangerous characters ──────────────────────────────────────── - -#[tokio::test] -async fn checkout_rejects_dangerous_branch_names() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - - let tool = test_tool(tmp.path()); - - for dangerous in ["main@{1}", "HEAD^", "v1~2"] { - let result = tool - .execute(json!({"operation": "checkout", "branch": dangerous})) - .await; - let msg = error_text(&result); - assert!( - msg.contains("invalid characters") || msg.contains("Invalid branch"), - "expected a dangerous-branch rejection for '{dangerous}', got: {msg}" - ); - } -} - -// ── commit: missing message ──────────────────────────────────────────────── - -#[tokio::test] -async fn commit_missing_message_returns_error() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - - let tool = test_tool(tmp.path()); - let result = tool.execute(json!({"operation": "commit"})).await; - let msg = error_text(&result); - assert!( - msg.contains("Missing 'message' parameter"), - "expected missing-message error, got: {msg}" - ); -} - -// ── add: missing paths ───────────────────────────────────────────────────── - -#[tokio::test] -async fn add_missing_paths_returns_error() { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - - let tool = test_tool(tmp.path()); - let result = tool.execute(json!({"operation": "add"})).await; - let msg = error_text(&result); - assert!( - msg.contains("Missing 'paths' parameter"), - "expected missing-paths error, got: {msg}" - ); -} diff --git a/crates/tinytools-std/src/filesystem/git_operations/ops_tests.rs b/crates/tinytools-std/src/filesystem/git_operations/ops_tests.rs deleted file mode 100644 index 64a4cde8..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/ops_tests.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! End-to-end tests of the `git_operations` sub-operations against real, -//! hermetic repositories: parsing, success paths, failure paths and gate -//! refusals. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::test::{hermetic, init_git_repo, test_tool}; -use super::*; -use crate::filesystem::test_support::{AutonomyLevel, TestGate, WorkspaceContext}; -use tempfile::TempDir; - -fn git(dir: &std::path::Path, args: &[&str]) { - let out = hermetic( - std::process::Command::new("git") - .args(args) - .current_dir(dir), - ) - .output() - .expect("spawn git"); - assert!( - out.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&out.stderr) - ); -} - -/// A repo with one commit of `a.txt` and identity configured locally. -fn repo_with_commit() -> TempDir { - let tmp = TempDir::new().unwrap(); - init_git_repo(tmp.path()); - git(tmp.path(), &["config", "user.email", "t@example.invalid"]); - git(tmp.path(), &["config", "user.name", "Tester"]); - git(tmp.path(), &["checkout", "-b", "main"]); - std::fs::write(tmp.path().join("a.txt"), "one\ntwo\nthree\n").unwrap(); - git(tmp.path(), &["add", "a.txt"]); - git(tmp.path(), &["commit", "-m", "first commit"]); - tmp -} - -async fn run(tool: &GitOperationsTool, args: serde_json::Value) -> ToolResult { - tool.execute(args).await.unwrap() -} - -#[test] -fn supports_markdown_is_advertised() { - let tmp = TempDir::new().unwrap(); - assert!(test_tool(tmp.path()).supports_markdown()); -} - -#[tokio::test] -async fn status_parses_staged_unstaged_and_untracked() { - let tmp = repo_with_commit(); - std::fs::write(tmp.path().join("a.txt"), "one\nTWO\nthree\n").unwrap(); - git(tmp.path(), &["add", "a.txt"]); - std::fs::write(tmp.path().join("a.txt"), "changed again\n").unwrap(); - std::fs::write(tmp.path().join("new.txt"), "n").unwrap(); - let tool = test_tool(tmp.path()); - - let result = run(&tool, json!({"operation": "status"})).await; - assert!(!result.is_error, "{}", result.output()); - let parsed: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); - assert_ne!(parsed["staged"].as_array().unwrap().len(), 0); - assert_ne!(parsed["unstaged"].as_array().unwrap().len(), 0); - assert!( - parsed["untracked"] - .as_array() - .unwrap() - .contains(&json!("new.txt")) - ); - assert!(result.markdown_formatted.is_some()); -} - -#[tokio::test] -async fn diff_reports_hunks_for_unstaged_and_cached_changes() { - let tmp = repo_with_commit(); - std::fs::write(tmp.path().join("a.txt"), "one\nTWO\nthree\n").unwrap(); - std::fs::write(tmp.path().join("b.txt"), "x\n").unwrap(); - git(tmp.path(), &["add", "b.txt"]); - let tool = test_tool(tmp.path()); - - let unstaged = run(&tool, json!({"operation": "diff"})).await; - let parsed: serde_json::Value = serde_json::from_str(&unstaged.output()).unwrap(); - assert!(parsed["file_count"].as_u64().unwrap() >= 1); - assert_eq!(parsed["hunks"][0]["file"], "a.txt"); - let kinds: Vec<&str> = parsed["hunks"] - .as_array() - .unwrap() - .iter() - .flat_map(|h| h["lines"].as_array().unwrap().iter()) - .map(|l| l["type"].as_str().unwrap()) - .collect(); - assert!(kinds.contains(&"add") && kinds.contains(&"delete") && kinds.contains(&"context")); - - let cached = run(&tool, json!({"operation": "diff", "cached": true})).await; - let parsed: serde_json::Value = serde_json::from_str(&cached.output()).unwrap(); - assert_eq!(parsed["hunks"][0]["file"], "b.txt"); -} - -#[tokio::test] -async fn diff_with_several_files_and_hunks_splits_them() { - let tmp = repo_with_commit(); - let long: String = (0..40).fold(String::new(), |acc, i| acc + &format!("line{i}\n")); - std::fs::write(tmp.path().join("big.txt"), &long).unwrap(); - git(tmp.path(), &["add", "big.txt"]); - git(tmp.path(), &["commit", "-m", "big"]); - let edited = long - .replace("line1\n", "LINE1\n") - .replace("line38\n", "LINE38\n"); - std::fs::write(tmp.path().join("big.txt"), edited).unwrap(); - std::fs::write(tmp.path().join("a.txt"), "different\n").unwrap(); - let tool = test_tool(tmp.path()); - - let result = run(&tool, json!({"operation": "diff"})).await; - let parsed: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); - assert!(parsed["file_count"].as_u64().unwrap() >= 3); - assert!( - parsed["hunks"] - .as_array() - .unwrap() - .iter() - .any(|h| h["header"].is_string()) - ); -} - -#[tokio::test] -async fn diff_rejects_injected_file_arguments() { - let tmp = repo_with_commit(); - let tool = test_tool(tmp.path()); - let result = tool - .execute(json!({"operation": "diff", "files": "--exec=evil"})) - .await; - assert!(result.is_err() || result.unwrap().is_error); -} - -#[tokio::test] -async fn log_lists_commits_with_markdown() { - let tmp = repo_with_commit(); - let tool = test_tool(tmp.path()); - let result = run(&tool, json!({"operation": "log", "limit": 5})).await; - assert!(!result.is_error, "{}", result.output()); - let parsed: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); - assert_eq!(parsed["commits"][0]["message"], "first commit"); - assert_eq!(parsed["commits"][0]["author"], "Tester"); - assert!(result.markdown_formatted.unwrap().contains("first commit")); -} - -#[tokio::test] -async fn branch_lists_the_current_branch() { - let tmp = repo_with_commit(); - git(tmp.path(), &["branch", "side"]); - let tool = test_tool(tmp.path()); - let result = run(&tool, json!({"operation": "branch"})).await; - let parsed: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); - assert_eq!(parsed["current"], "main"); - assert_eq!(parsed["branches"].as_array().unwrap().len(), 2); - assert!(result.markdown_formatted.unwrap().contains("← current")); -} - -#[tokio::test] -async fn add_then_commit_succeeds() { - let tmp = repo_with_commit(); - std::fs::write(tmp.path().join("c.txt"), "c").unwrap(); - let tool = test_tool(tmp.path()); - - let added = run(&tool, json!({"operation": "add", "paths": "c.txt"})).await; - assert_eq!(added.output(), "Staged: c.txt"); - - let committed = run( - &tool, - json!({"operation": "commit", "message": " add c \n\n more "}), - ) - .await; - assert!(!committed.is_error, "{}", committed.output()); - assert_eq!(committed.output(), "Committed: add c\nmore"); -} - -#[tokio::test] -async fn add_of_a_missing_path_reports_failure() { - let tmp = repo_with_commit(); - let tool = test_tool(tmp.path()); - let result = run(&tool, json!({"operation": "add", "paths": "nope.txt"})).await; - assert!(result.is_error); - assert!(result.output().starts_with("Add failed:")); -} - -#[tokio::test] -async fn commit_with_nothing_staged_reports_failure() { - let tmp = repo_with_commit(); - let tool = test_tool(tmp.path()); - let result = run(&tool, json!({"operation": "commit", "message": "empty"})).await; - assert!(result.is_error); - assert!(result.output().starts_with("Commit failed:")); -} - -#[tokio::test] -async fn commit_with_blank_message_is_an_error() { - let tmp = repo_with_commit(); - let tool = test_tool(tmp.path()); - let result = tool - .execute(json!({"operation": "commit", "message": " \n "})) - .await; - assert!(result.unwrap_err().to_string().contains("cannot be empty")); -} - -#[test] -fn overlong_commit_messages_are_truncated() { - let long = "x".repeat(2500); - let out = GitOperationsTool::truncate_commit_message(&long); - assert_eq!(out.chars().count(), 2000); - assert!(out.ends_with("...")); - assert_eq!(GitOperationsTool::truncate_commit_message("short"), "short"); -} - -#[tokio::test] -async fn checkout_switches_and_reports_failures() { - let tmp = repo_with_commit(); - git(tmp.path(), &["branch", "side"]); - let tool = test_tool(tmp.path()); - - let ok = run(&tool, json!({"operation": "checkout", "branch": "side"})).await; - assert_eq!(ok.output(), "Switched to branch: side"); - - let missing = run(&tool, json!({"operation": "checkout", "branch": "ghost"})).await; - assert!(missing.is_error); - assert!(missing.output().starts_with("Checkout failed:")); - - let invalid = tool - .execute(json!({"operation": "checkout", "branch": "--evil"})) - .await - .unwrap_err(); - assert!( - invalid.to_string().contains("must not start with '-'"), - "{invalid}" - ); -} - -#[tokio::test] -async fn checkout_of_a_remote_only_branch_still_creates_its_tracking_branch() { - let tmp = repo_with_commit(); - git( - tmp.path(), - &[ - "remote", - "add", - "origin", - "https://example.invalid/repo.git", - ], - ); - git( - tmp.path(), - &["update-ref", "refs/remotes/origin/feature", "HEAD"], - ); - let tool = test_tool(tmp.path()); - - let ok = run(&tool, json!({"operation": "checkout", "branch": "feature"})).await; - assert!(!ok.is_error, "{}", ok.output()); - let head = hermetic( - std::process::Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(tmp.path()), - ) - .output() - .unwrap(); - assert_eq!(String::from_utf8_lossy(&head.stdout).trim(), "feature"); -} - -#[tokio::test] -async fn stash_push_list_pop_and_drop() { - let tmp = repo_with_commit(); - let tool = test_tool(tmp.path()); - std::fs::write(tmp.path().join("a.txt"), "dirty\n").unwrap(); - - let push = run(&tool, json!({"operation": "stash", "action": "push"})).await; - assert!(!push.is_error, "{}", push.output()); - let list = run(&tool, json!({"operation": "stash", "action": "list"})).await; - assert!(list.output().contains("auto-stash")); - let pop = run(&tool, json!({"operation": "stash", "action": "pop"})).await; - assert!(!pop.is_error, "{}", pop.output()); - - git(tmp.path(), &["stash", "push", "-m", "again"]); - let drop = run( - &tool, - json!({"operation": "stash", "action": "drop", "index": 0}), - ) - .await; - assert!(!drop.is_error, "{}", drop.output()); - - let bad = run( - &tool, - json!({"operation": "stash", "action": "drop", "index": 7}), - ) - .await; - assert!(bad.is_error); - assert!(bad.output().starts_with("Stash drop failed:")); - - let huge = tool - .execute(json!({"operation": "stash", "action": "drop", "index": 4_000_000_000u64})) - .await; - assert!(huge.unwrap_err().to_string().contains("too large")); -} - -#[tokio::test] -async fn pop_with_no_stash_reports_failure() { - let tmp = repo_with_commit(); - let tool = test_tool(tmp.path()); - let result = run(&tool, json!({"operation": "stash", "action": "pop"})).await; - assert!(result.is_error); - assert!(result.output().starts_with("Stash pop failed:")); -} - -#[tokio::test] -async fn write_operations_are_refused_when_the_gate_is_read_only() { - let tmp = repo_with_commit(); - let gate = TestGate::with(tmp.path().to_path_buf(), AutonomyLevel::ReadOnly, 1_000_000); - let tool = GitOperationsTool::new(gate, tmp.path().to_path_buf()); - let result = run(&tool, json!({"operation": "add", "paths": "a.txt"})).await; - assert!(result.is_error); - assert!(result.output().contains("higher autonomy level")); -} - -/// A gate that permits acting but reports strict read-only mode. -#[derive(Debug)] -struct ReadOnlyButActing(std::path::PathBuf); - -#[async_trait] -impl FsGate for ReadOnlyButActing { - fn can_act(&self) -> bool { - true - } - fn is_read_only(&self) -> bool { - true - } - fn is_rate_limited(&self) -> bool { - false - } - fn record_action(&self) -> bool { - true - } - fn write_needs_approval(&self) -> bool { - false - } - fn action_dir(&self) -> &Path { - &self.0 - } - fn is_path_string_allowed(&self, _path: &str) -> bool { - true - } - async fn validate_path(&self, path: &str) -> Result { - Ok(PathBuf::from(path)) - } - async fn validate_parent_path(&self, path: &str) -> Result { - Ok(PathBuf::from(path)) - } - fn scoped_to_workspace(&self, root: &Path) -> Arc { - Arc::new(Self(root.to_path_buf())) - } -} - -#[tokio::test] -async fn read_only_mode_blocks_writes_even_when_the_gate_allows_acting() { - let tmp = repo_with_commit(); - let gate: Arc = Arc::new(ReadOnlyButActing(tmp.path().to_path_buf())); - let tool = GitOperationsTool::new(gate, tmp.path().to_path_buf()); - let result = run(&tool, json!({"operation": "add", "paths": "a.txt"})).await; - assert_eq!( - result.output(), - "[policy-blocked] Action blocked: read-only mode" - ); -} - -#[tokio::test] -async fn an_exhausted_action_budget_blocks_the_operation() { - let tmp = repo_with_commit(); - let gate = TestGate::with(tmp.path().to_path_buf(), AutonomyLevel::Supervised, 0); - let tool = GitOperationsTool::new(gate, tmp.path().to_path_buf()); - let result = run(&tool, json!({"operation": "status"})).await; - assert_eq!(result.output(), "Action blocked: rate limit exceeded"); -} - -#[tokio::test] -async fn the_execute_wrappers_agree_and_honour_a_workspace_descriptor() { - let unrelated = TempDir::new().unwrap(); - let repo = repo_with_commit(); - let tool = test_tool(unrelated.path()); - let context = WorkspaceContext::at(repo.path()); - - let with_options = tool - .execute_with_options(json!({"operation": "branch"}), ToolCallOptions::default()) - .await - .unwrap(); - assert!(with_options.is_error, "the unrelated dir is not a repo"); - - let with_context = tool - .execute_with_context( - json!({"operation": "branch"}), - ToolCallOptions::default(), - Some(&context), - ) - .await - .unwrap(); - assert!(!with_context.is_error, "{}", with_context.output()); -} - -/// `checkout` switches branches; it must never treat its argument as a -/// pathspec (`.`, `a.txt`) or an option (`-f`) and discard uncommitted work. -#[tokio::test] -async fn checkout_of_a_path_or_flag_keeps_uncommitted_work() { - let mut lost = Vec::new(); - for branch in [".", "a.txt", "-f"] { - let tmp = repo_with_commit(); - std::fs::write(tmp.path().join("a.txt"), "uncommitted\n").unwrap(); - let tool = test_tool(tmp.path()); - let result = tool - .execute(json!({"operation": "checkout", "branch": branch})) - .await; - let content = std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(); - if content != "uncommitted\n" { - lost.push(format!("{branch:?} -> {:?}", result.map(|r| r.output()))); - } - } - assert!( - lost.is_empty(), - "checkout discarded uncommitted work: {lost:?}" - ); -} diff --git a/crates/tinytools-std/src/filesystem/git_operations/render.rs b/crates/tinytools-std/src/filesystem/git_operations/render.rs deleted file mode 100644 index 124c355a..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/render.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Markdown renderers for [`super::git_operations`] results. -//! -//! Split out of `git_operations.rs` for the Rust layout gate. Pure formatting: -//! these take already-parsed JSON and produce the human-readable summary, with -//! no git invocation of their own. - -pub(super) fn render_status_markdown( - result: &serde_json::Map, -) -> String { - let mut out = String::new(); - if let Some(branch) = result.get("branch").and_then(|v| v.as_str()) { - out.push_str(&format!("**branch**: `{branch}`\n")); - } - let clean = result - .get("clean") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - if clean { - out.push_str("_Working tree clean._\n"); - return out; - } - let push_section = |out: &mut String, label: &str, items: Option<&Vec>| { - if let Some(items) = items - && !items.is_empty() - { - out.push_str(&format!("\n**{label}** ({})\n", items.len())); - for it in items { - if let (Some(p), Some(s)) = ( - it.get("path").and_then(|v| v.as_str()), - it.get("status").and_then(|v| v.as_str()), - ) { - out.push_str(&format!("- `{s}` {p}\n")); - } - } - } - }; - push_section( - &mut out, - "staged", - result.get("staged").and_then(|v| v.as_array()), - ); - push_section( - &mut out, - "unstaged", - result.get("unstaged").and_then(|v| v.as_array()), - ); - if let Some(items) = result.get("untracked").and_then(|v| v.as_array()) - && !items.is_empty() - { - out.push_str(&format!("\n**untracked** ({})\n", items.len())); - for it in items { - if let Some(p) = it.as_str() { - out.push_str(&format!("- {p}\n")); - } - } - } - out -} - -pub(super) fn render_log_markdown(commits: &[serde_json::Value]) -> String { - if commits.is_empty() { - return "_No commits._".to_string(); - } - let mut out = format!("# Commits ({})\n", commits.len()); - for c in commits { - let hash = c.get("hash").and_then(|v| v.as_str()).unwrap_or(""); - let short = hash.get(..hash.len().min(8)).unwrap_or(hash); - let author = c.get("author").and_then(|v| v.as_str()).unwrap_or(""); - let date = c.get("date").and_then(|v| v.as_str()).unwrap_or(""); - let msg = c.get("message").and_then(|v| v.as_str()).unwrap_or(""); - out.push_str(&format!("- `{short}` {msg} _(by {author}, {date})_\n")); - } - out -} - -pub(super) fn render_branch_markdown(current: &str, branches: &[serde_json::Value]) -> String { - let mut out = format!("**current**: `{current}`\n\n## Branches\n"); - for b in branches { - let name = b.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let cur = b - .get("current") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - if cur { - out.push_str(&format!("- **{name}** ← current\n")); - } else { - out.push_str(&format!("- {name}\n")); - } - } - out -} diff --git a/crates/tinytools-std/src/filesystem/git_operations/render_tests.rs b/crates/tinytools-std/src/filesystem/git_operations/render_tests.rs deleted file mode 100644 index f41c5077..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/render_tests.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Tests for the markdown renderers: literal JSON in, literal markdown out. - -#![allow(clippy::unwrap_used)] - -use super::render::{render_branch_markdown, render_log_markdown, render_status_markdown}; -use serde_json::{Value, json}; - -fn status(value: &Value) -> String { - render_status_markdown(value.as_object().unwrap()) -} - -#[test] -fn clean_status_stops_after_the_branch_line() { - let md = - status(&json!({"branch": "main", "clean": true, "staged": [{"path": "a", "status": "M"}]})); - assert_eq!(md, "**branch**: `main`\n_Working tree clean._\n"); -} - -#[test] -fn dirty_status_lists_each_section_with_counts() { - let md = status(&json!({ - "branch": "feat", - "clean": false, - "staged": [{"path": "a.rs", "status": "M"}, {"path": "b.rs", "status": "A"}], - "unstaged": [{"path": "c.rs", "status": "D"}], - "untracked": ["new.txt"], - })); - assert_eq!( - md, - "**branch**: `feat`\n\ - \n**staged** (2)\n- `M` a.rs\n- `A` b.rs\n\ - \n**unstaged** (1)\n- `D` c.rs\n\ - \n**untracked** (1)\n- new.txt\n" - ); -} - -#[test] -fn status_skips_empty_sections_and_malformed_entries() { - let md = status(&json!({ - "staged": [], - "unstaged": [{"path": "only-path"}, {"status": "M"}], - "untracked": [42], - })); - assert_eq!(md, "\n**unstaged** (2)\n\n**untracked** (1)\n"); -} - -#[test] -fn status_without_a_branch_or_clean_flag_renders_nothing() { - assert_eq!(status(&json!({})), ""); -} - -#[test] -fn log_renders_short_hashes_and_defaults_missing_fields() { - assert_eq!(render_log_markdown(&[]), "_No commits._"); - let md = render_log_markdown(&[ - json!({"hash": "0123456789abcdef", "author": "Ann", "date": "2026-01-02", "message": "fix"}), - json!({"hash": "abc"}), - json!({}), - ]); - assert_eq!( - md, - "# Commits (3)\n\ - - `01234567` fix _(by Ann, 2026-01-02)_\n\ - - `abc` _(by , )_\n\ - - `` _(by , )_\n" - ); -} - -#[test] -fn branches_mark_the_current_one() { - let md = render_branch_markdown( - "main", - &[ - json!({"name": "main", "current": true}), - json!({"name": "dev"}), - json!({}), - ], - ); - assert_eq!( - md, - "**current**: `main`\n\n## Branches\n- **main** ← current\n- dev\n- \n" - ); -} diff --git a/crates/tinytools-std/src/filesystem/glob_search/extra_tests.rs b/crates/tinytools-std/src/filesystem/glob_search/extra_tests.rs deleted file mode 100644 index 379abf91..00000000 --- a/crates/tinytools-std/src/filesystem/glob_search/extra_tests.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Budget, root-validation, truncation and policy-filter tests for `glob`. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::{TestGate, WorkspaceContext, WrapGate}; -use tempfile::TempDir; - -#[test] -fn glob_is_concurrency_safe() { - let dir = TempDir::new().unwrap(); - let tool = GlobTool::new(TestGate::supervised(dir.path().to_path_buf())); - assert!(tool.is_concurrency_safe(&json!({}))); -} - -#[tokio::test] -async fn rate_limit_and_exhausted_budget_are_refused() { - let dir = TempDir::new().unwrap(); - let limited = GlobTool::new(TestGate::with( - dir.path().to_path_buf(), - crate::filesystem::test_support::AutonomyLevel::Supervised, - 0, - )); - let r = limited.execute(json!({"pattern": "*"})).await.unwrap(); - assert!(r.output().contains("too many actions in the last hour")); - - let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) - .no_budget() - .arc(); - let r = GlobTool::new(gate) - .execute(json!({"pattern": "*"})) - .await - .unwrap(); - assert!(r.output().contains("action budget exhausted")); -} - -#[tokio::test] -async fn a_root_that_vanishes_after_validation_is_reported() { - let dir = TempDir::new().unwrap(); - let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) - .fixed_path(dir.path().join("gone")) - .arc(); - let r = GlobTool::new(gate) - .execute(json!({"pattern": "*"})) - .await - .unwrap(); - assert!(r.is_error); - assert!(r.output().contains("is not accessible")); -} - -#[tokio::test] -async fn a_missing_action_dir_falls_back_to_its_raw_path() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("a.txt"), "").unwrap(); - let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) - .action_dir_override(dir.path().join("does-not-exist")) - .fixed_path(dir.path().to_path_buf()) - .arc(); - let r = GlobTool::new(gate) - .execute(json!({"pattern": "*.txt"})) - .await - .unwrap(); - assert!(!r.is_error, "{}", r.output()); - assert!(r.output().contains("a.txt")); -} - -#[tokio::test] -async fn results_report_truncation() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("a.txt"), "").unwrap(); - std::fs::write(dir.path().join("b.txt"), "").unwrap(); - let tool = GlobTool::new(TestGate::supervised(dir.path().to_path_buf())); - let ctx = WorkspaceContext::at(dir.path()); - let r = tool - .execute_with_context( - json!({"pattern": "*.txt", "max_results": 1}), - ToolCallOptions::default(), - Some(&ctx), - ) - .await - .unwrap(); - assert!( - r.output().starts_with("1 match(es) (truncated at 1)"), - "{}", - r.output() - ); -} - -#[cfg(unix)] -#[tokio::test] -async fn hits_the_gate_refuses_are_filtered_out() { - let dir = TempDir::new().unwrap(); - let outside = TempDir::new().unwrap(); - std::fs::write(outside.path().join("secret.txt"), "s").unwrap(); - std::fs::write(dir.path().join("ok.txt"), "").unwrap(); - std::os::unix::fs::symlink( - outside.path().join("secret.txt"), - dir.path().join("link.txt"), - ) - .unwrap(); - let tool = GlobTool::new(TestGate::supervised(dir.path().to_path_buf())); - let r = tool.execute(json!({"pattern": "*.txt"})).await.unwrap(); - assert!(r.output().contains("ok.txt")); - assert!(!r.output().contains("link.txt"), "{}", r.output()); -} - -fn seed_many(dir: &std::path::Path, n: usize) { - std::fs::create_dir_all(dir.join("sub")).unwrap(); - for i in 0..n { - std::fs::write(dir.join(format!("sub/f{i}.txt")), "x").unwrap(); - } -} - -#[tokio::test] -async fn glob_asks_the_gate_once_per_search_however_many_hits() { - let dir = TempDir::new().unwrap(); - seed_many(dir.path(), 40); - let wrap = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())); - let calls = wrap.batch_calls(); - let tool = GlobTool::new(wrap.arc()); - let r = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap(); - assert!(!r.is_error, "{}", r.output()); - assert!(r.output().starts_with("40 match(es)"), "{}", r.output()); - assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); -} - -#[tokio::test] -async fn glob_refuses_every_hit_when_the_batch_answer_has_the_wrong_length() { - let dir = TempDir::new().unwrap(); - seed_many(dir.path(), 5); - let wrap = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())).wrong_length_batch(); - let tool = GlobTool::new(wrap.arc()); - let r = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap(); - assert!(!r.is_error, "{}", r.output()); - assert_eq!(r.output().trim(), "0 match(es)"); -} - -#[tokio::test] -async fn glob_batch_filtering_matches_the_per_hit_verdicts() { - let dir = TempDir::new().unwrap(); - seed_many(dir.path(), 6); - let gate = TestGate::supervised(dir.path().to_path_buf()); - let tool = GlobTool::new(gate.clone()); - let r = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap(); - let out = r.output(); - let mut got: Vec<&str> = out.lines().skip(1).collect(); - got.sort_unstable(); - let mut want: Vec = (0..6).map(|i| format!("sub/f{i}.txt")).collect(); - want.retain(|p| gate.is_path_string_allowed(p)); - want.sort_unstable(); - assert_eq!(got, want); - assert_eq!(got.len(), 6); -} diff --git a/crates/tinytools-std/src/filesystem/glob_search/mod.rs b/crates/tinytools-std/src/filesystem/glob_search/mod.rs deleted file mode 100644 index d37a74e5..00000000 --- a/crates/tinytools-std/src/filesystem/glob_search/mod.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! `glob` — find files by glob pattern. -//! -//! Coding-harness baseline tool (issue #1205): pure file discovery -//! by pattern (e.g. `src/**/*.rs`). -//! -//! ## Path-root contract (issue #3357) -//! -//! `glob` resolves its search root through the **same** seam the reader tools -//! use — [`FsGate::validate_path`] — and filters every hit through -//! [`FsGate::paths_string_allowed`] (one batch call per search). This guarantees the core -//! invariant a discovery tool must hold: **every path `glob` returns is a path -//! `file_read`/`grep`/`list` can open, and nothing else.** -//! -//! Previously `glob` walked `workspace_dir` (internal product state) and -//! returned paths relative to it, while the readers resolve relative paths -//! against `action_dir`. The two roots normally differ, so a path `glob` -//! returned was meaningless to the readers — every follow-up read failed with -//! "No such file or directory (os error 2)" (#3357). Walking `workspace_dir` -//! also let the agent enumerate internal state (memory DBs, sessions, tokens). -//! -//! The optional `path` argument lets the agent search any location it is -//! allowed to read (the action sandbox by default, or a granted `trusted_root`) -//! — it is resolved through `validate_path`, so it can never widen access -//! beyond what the readers already permit. - -use super::gate::{FsGate, gate_for_context}; -use async_trait::async_trait; -use glob::Pattern; -use serde_json::json; -use std::path::Path; -use std::sync::Arc; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; -use walkdir::WalkDir; - -const DEFAULT_MAX_RESULTS: usize = 500; - -/// Find files by glob pattern, newest first. -#[derive(Debug)] -pub struct GlobTool { - gate: Arc, -} - -impl GlobTool { - /// Create the tool. - #[must_use] - pub fn new(gate: Arc) -> Self { - Self { gate } - } -} - -#[async_trait] -impl Tool for GlobTool { - fn name(&self) -> &'static str { - "glob" - } - - fn description(&self) -> &'static str { - "Find files matching a glob pattern (e.g. `src/**/*.rs`), searching the action sandbox unless `path` says otherwise. Returns paths newest-first, ready to pass straight to `file_read` / `grep`." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Glob pattern, e.g. `**/*.rs` or `src/**/*.{ts,tsx}` (single brace expansion not supported — list patterns separately). Matched against paths relative to the search directory." - }, - "path": { - "type": "string", - "description": "Directory to search within. Defaults to the working directory (action sandbox). Must be a location you're allowed to read; relative paths resolve against the working directory." - }, - "max_results": { - "type": "integer", - "description": "Cap on returned paths (default 500).", - "minimum": 1 - } - }, - "required": ["pattern"] - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::ReadOnly - } - - /// Pure read — safe to fan out across parallel `glob` calls. - fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { - true - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_in_context(args, None).await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - self.execute_in_context(args, context).await - } -} - -impl GlobTool { - async fn execute_in_context( - &self, - args: serde_json::Value, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let pattern_str = args - .get("pattern") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' parameter"))?; - // Default the search root to "." — which `validate_path` resolves to the - // action sandbox (action_dir), the same root file_read/grep/list use. - let search_path = args - .get("path") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("."); - let max_results = args - .get("max_results") - .and_then(serde_json::Value::as_u64) - .map_or(DEFAULT_MAX_RESULTS, |n| (n as usize).max(1)); - - 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", - )); - } - - let pattern = match Pattern::new(pattern_str) { - Ok(p) => p, - Err(e) => return Ok(ToolResult::error(format!("Invalid glob pattern: {e}"))), - }; - - let path_policy = gate_for_context(&self.gate, context, "glob"); - - log::debug!( - "[tools:glob] search start: pattern='{pattern_str}' path='{search_path}' max_results={max_results}" - ); - - // Resolve + authorize the search root through the readers' validation - // seam. A disallowed or missing root yields a clear, path-naming error - // instead of the opaque "No such file or directory" the old workspace_dir - // root produced (#3357). - let base = match path_policy.validate_path(search_path).await { - Ok(p) => p, - Err(e) => { - log::debug!("[tools:glob] search path rejected: path='{search_path}' error={e}"); - return Ok(ToolResult::error(format!( - "glob search path '{search_path}' is not accessible: {e}" - ))); - } - }; - // `validate_path` can authorize a *file* as well as a directory. Walking a - // file root yields the file itself, whose path relative to `base` is empty, - // so the pattern never matches and the agent gets a misleading "0 match(es)" - // instead of a clear error. Reject non-directory roots explicitly. - match tokio::fs::metadata(&base).await { - Ok(meta) if meta.is_dir() => {} - Ok(_) => { - log::debug!("[tools:glob] search path is not a directory: path='{search_path}'"); - return Ok(ToolResult::error(format!( - "glob search path '{search_path}' is not a directory" - ))); - } - Err(e) => { - log::debug!( - "[tools:glob] search path not accessible: path='{search_path}' error={e}" - ); - return Ok(ToolResult::error(format!( - "glob search path '{search_path}' is not accessible: {e}" - ))); - } - } - log::debug!( - "[tools:glob] resolved search root: '{}' (action_dir='{}')", - base.display(), - path_policy.action_dir().display() - ); - - // Canonical action sandbox, used to decide whether a hit is rendered - // relative (inside the sandbox → directly file_read-able by relative - // path) or absolute (in a granted trusted root → file_read-able as-is). - // `base` is already canonical (validate_path canonicalizes), so if this - // fallback fires the two roots become asymmetric and strip_prefix below - // may miss for an in-sandbox hit, rendering it absolute. Harmless: the - // absolute path is still readable and the per-hit filter still applies. - let action_root = tokio::fs::canonicalize(path_policy.action_dir()) - .await - .unwrap_or_else(|e| { - log::trace!( - "[tools:glob] action_dir canonicalize fallback: path='{}' error={e}", - path_policy.action_dir().display() - ); - path_policy.action_dir().to_path_buf() - }); - - let result = tokio::task::spawn_blocking(move || { - collect_matches( - &base, - &action_root, - path_policy.as_ref(), - &pattern, - max_results, - ) - }) - .await - .map_err(|e| anyhow::anyhow!("scan task failed: {e}"))?; - - let (paths, truncated) = result; - log::debug!( - "[tools:glob] scan complete: {} match(es) truncated={}", - paths.len(), - truncated - ); - let header = if truncated { - format!("{} match(es) (truncated at {max_results})", paths.len()) - } else { - format!("{} match(es)", paths.len()) - }; - - let mut body = String::with_capacity(paths.len() * 32 + header.len() + 1); - body.push_str(&header); - for p in paths { - body.push('\n'); - body.push_str(&p); - } - Ok(ToolResult::success(body)) - } -} - -/// Walk `base`, returning glob matches as paths the reader tools can open. -/// -/// - The pattern is matched against each file's path **relative to `base`** -/// (the searched directory), the conventional glob behavior. -/// - The returned string is **relative to `action_root`** when the hit lives -/// inside the action sandbox (so `file_read("src/a.rs")` works), otherwise -/// the **absolute** path (so `file_read` resolves it as-is via a trusted -/// root). -/// - Every hit is filtered through [`FsGate::paths_string_allowed`] (one batch -/// call per search) on that exact returned string, so `glob` never surfaces a path the readers -/// would reject (internal state, forbidden trees, symlink escapes). -/// -/// `max_results` bounds the returned list, not the walk: the full tree under -/// `base` is always traversed and only the output is truncated (pre-existing -/// behavior — the cap caps results, not work). -fn collect_matches( - base: &Path, - action_root: &Path, - gate: &dyn FsGate, - pattern: &Pattern, - max_results: usize, -) -> (Vec, bool) { - let mut candidates: Vec<(std::time::SystemTime, String)> = Vec::new(); - - for entry in WalkDir::new(base) - .follow_links(false) - .into_iter() - .filter_entry(|e| !is_skipped(e.file_name().to_string_lossy().as_ref())) - .filter_map(std::result::Result::ok) - { - if !entry.file_type().is_file() { - continue; - } - let abs = entry.path(); - - // Match the pattern against the path relative to the searched root. - let rel_to_base = match abs.strip_prefix(base) { - Ok(p) => p.to_string_lossy().replace('\\', "/"), - Err(_) => continue, - }; - if !pattern.matches(&rel_to_base) { - continue; - } - - // Render the path the way the agent should hand it to file_read: - // sandbox-relative when inside action_dir, absolute otherwise. - let display = match abs.strip_prefix(action_root) { - Ok(rel) => rel.to_string_lossy().replace('\\', "/"), - Err(_) => abs.to_string_lossy().to_string(), - }; - - let mtime = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); - candidates.push((mtime, display)); - } - - // Fail-closed: only surface paths the readers would also accept. The whole - // walk is asked in ONE batch so a host that answers over a bus pays a - // single round trip per search, not one per hit. - let displays: Vec = candidates.iter().map(|(_, p)| p.clone()).collect(); - let verdicts = gate.paths_string_allowed(&displays); - if verdicts.len() != candidates.len() { - log::warn!( - "[tools:glob] gate answered {} verdict(s) for {} path(s); refusing every hit", - verdicts.len(), - candidates.len() - ); - candidates.clear(); - } - let mut hits: Vec<(std::time::SystemTime, String)> = Vec::with_capacity(candidates.len()); - for ((mtime, display), allowed) in candidates.into_iter().zip(verdicts) { - if allowed { - hits.push((mtime, display)); - } else { - log::trace!("[tools:glob] path filtered by policy: '{display}'"); - } - } - - // Newest first. - hits.sort_by_key(|item| std::cmp::Reverse(item.0)); - let truncated = hits.len() > max_results; - let paths: Vec = hits.into_iter().take(max_results).map(|(_, p)| p).collect(); - (paths, truncated) -} - -fn is_skipped(name: &str) -> bool { - matches!( - name, - ".git" | "node_modules" | "target" | ".next" | "dist" | "build" | ".cache" - ) -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; - -#[cfg(test)] -#[path = "extra_tests.rs"] -mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/glob_search/mod_tests.rs b/crates/tinytools-std/src/filesystem/glob_search/mod_tests.rs deleted file mode 100644 index 1d66445f..00000000 --- a/crates/tinytools-std/src/filesystem/glob_search/mod_tests.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! Behavior tests for the `glob` tool, driven through a fake [`FsGate`]. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::TestGate; - -fn test_security(workspace: std::path::PathBuf) -> Arc { - TestGate::supervised(workspace) -} - -/// Gate with an action sandbox and extra granted roots: the production shape, -/// and the configuration that surfaced #3357. -fn test_security_split( - action_dir: std::path::PathBuf, - _workspace_dir: std::path::PathBuf, - extra_roots: Vec, -) -> Arc { - TestGate::split(action_dir, extra_roots) -} - -#[test] -fn glob_name() { - let tool = GlobTool::new(test_security(std::env::temp_dir())); - assert_eq!(tool.name(), "glob"); -} - -#[tokio::test] -async fn glob_matches_extension() { - let dir = std::env::temp_dir().join("openhuman_test_glob_ext"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(dir.join("src/sub")) - .await - .unwrap(); - tokio::fs::write(dir.join("src/a.rs"), "// a") - .await - .unwrap(); - tokio::fs::write(dir.join("src/sub/b.rs"), "// b") - .await - .unwrap(); - tokio::fs::write(dir.join("src/c.txt"), "c").await.unwrap(); - - let tool = GlobTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"pattern": "**/*.rs"})).await.unwrap(); - assert!(!result.is_error); - let output = result.output(); - assert!(output.contains("src/a.rs")); - assert!(output.contains("src/sub/b.rs")); - assert!(!output.contains("c.txt")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn glob_invalid_pattern() { - let dir = std::env::temp_dir().join("openhuman_test_glob_invalid"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = GlobTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"pattern": "**["})).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Invalid glob")); - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn glob_skips_node_modules() { - let dir = std::env::temp_dir().join("openhuman_test_glob_skip"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(dir.join("node_modules")) - .await - .unwrap(); - tokio::fs::write(dir.join("node_modules/lib.js"), "") - .await - .unwrap(); - tokio::fs::write(dir.join("app.js"), "").await.unwrap(); - - let tool = GlobTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"pattern": "**/*.js"})).await.unwrap(); - let output = result.output(); - assert!(output.contains("app.js")); - assert!(!output.contains("node_modules")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -/// Regression for #3357: glob roots at `action_dir` (so its hits are readable), -/// and never surfaces files living under the internal `workspace_dir`. -#[tokio::test] -async fn glob_roots_at_action_dir_and_excludes_workspace() { - let root = std::env::temp_dir().join("openhuman_test_glob_split"); - let action = root.join("action"); - let workspace = root.join("workspace"); - let _ = tokio::fs::remove_dir_all(&root).await; - tokio::fs::create_dir_all(&action).await.unwrap(); - tokio::fs::create_dir_all(&workspace).await.unwrap(); - tokio::fs::write(action.join("keep.txt"), "keep") - .await - .unwrap(); - tokio::fs::write(workspace.join("secret.txt"), "secret") - .await - .unwrap(); - - let security = test_security_split(action.clone(), workspace.clone(), vec![]); - let tool = GlobTool::new(security.clone()); - let result = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap(); - assert!(!result.is_error, "{}", result.output()); - let output = result.output(); - // Action-sandbox file is found, rendered relative... - assert!(output.contains("keep.txt"), "missing keep.txt: {output}"); - // ...and the internal workspace file is NOT enumerated. - assert!( - !output.contains("secret.txt"), - "leaked workspace file: {output}" - ); - - // The glob hit must be directly readable by the reader tools. - assert!( - security.validate_path("keep.txt").await.is_ok(), - "glob hit not resolvable by validate_path" - ); - - let _ = tokio::fs::remove_dir_all(&root).await; -} - -/// "As wide as the readers": glob can search any granted trusted root via -/// the `path` arg, returning absolute paths that the readers accept as-is. -#[tokio::test] -async fn glob_searches_named_trusted_root() { - let root = std::env::temp_dir().join("openhuman_test_glob_trusted"); - let action = root.join("action"); - let workspace = root.join("workspace"); - let granted = root.join("granted"); - let _ = tokio::fs::remove_dir_all(&root).await; - tokio::fs::create_dir_all(&action).await.unwrap(); - tokio::fs::create_dir_all(&workspace).await.unwrap(); - tokio::fs::create_dir_all(&granted).await.unwrap(); - tokio::fs::write(granted.join("data.csv"), "x") - .await - .unwrap(); - - let security = test_security_split(action.clone(), workspace.clone(), vec![granted.clone()]); - let tool = GlobTool::new(security.clone()); - let granted_abs = granted.to_string_lossy().to_string(); - let result = tool - .execute(json!({"pattern": "**/*.csv", "path": granted_abs})) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - let output = result.output(); - assert!(output.contains("data.csv"), "missing data.csv: {output}"); - // Rendered absolute (outside the action sandbox). - assert!( - output.contains(&granted.to_string_lossy().to_string()), - "expected absolute path: {output}" - ); - - let _ = tokio::fs::remove_dir_all(&root).await; -} - -/// A search root the policy disallows yields a clear error, not ENOENT. -#[tokio::test] -async fn glob_rejects_disallowed_search_path() { - let root = std::env::temp_dir().join("openhuman_test_glob_reject"); - let action = root.join("action"); - let workspace = root.join("workspace"); - let _ = tokio::fs::remove_dir_all(&root).await; - tokio::fs::create_dir_all(&action).await.unwrap(); - tokio::fs::create_dir_all(&workspace).await.unwrap(); - - let security = test_security_split(action.clone(), workspace.clone(), vec![]); - let tool = GlobTool::new(security); - // Absolute path outside any trusted root, under workspace_only. - let result = tool - .execute(json!({"pattern": "**/*", "path": "/etc"})) - .await - .unwrap(); - assert!(result.is_error); - assert!( - result.output().contains("not accessible"), - "{}", - result.output() - ); - - let _ = tokio::fs::remove_dir_all(&root).await; -} - -/// Security backstop: a symlink inside the sandbox pointing OUTSIDE it must -/// never leak the target. Two layers cover this and this test exercises -/// both: the walk runs with `follow_links(false)`, so the symlink entry is -/// dropped by the `is_file()` gate (a symlink is not a regular file) and is -/// never descended; and `is_path_string_allowed` is the fail-closed per-hit -/// backstop — assert directly that it rejects the escape's resolved string, -/// since that is the check `collect_matches` leans on if the walk gate is -/// ever bypassed. A legitimate in-sandbox file is still found. -#[cfg(unix)] -#[tokio::test] -async fn glob_does_not_leak_symlink_escape() { - let root = std::env::temp_dir().join("openhuman_test_glob_symlink"); - let action = root.join("action"); - let workspace = root.join("workspace"); - let outside = root.join("outside"); - let _ = tokio::fs::remove_dir_all(&root).await; - tokio::fs::create_dir_all(action.join("sub")).await.unwrap(); - tokio::fs::create_dir_all(&workspace).await.unwrap(); - tokio::fs::create_dir_all(&outside).await.unwrap(); - tokio::fs::write(action.join("sub/ok.txt"), "ok") - .await - .unwrap(); - tokio::fs::write(outside.join("secret.txt"), "secret") - .await - .unwrap(); - // Symlink inside the sandbox pointing at the outside (untrusted) tree. - std::os::unix::fs::symlink(&outside, action.join("escape")).unwrap(); - - // `outside` is deliberately NOT registered as a trusted root. - let security = test_security_split(action.clone(), workspace.clone(), vec![]); - let tool = GlobTool::new(security.clone()); - let result = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap(); - assert!(!result.is_error, "{}", result.output()); - let output = result.output(); - // The in-sandbox file is found, the escape target is not enumerated. - assert!( - output.contains("ok.txt"), - "missing in-sandbox file: {output}" - ); - assert!( - !output.contains("secret"), - "leaked symlink-escape target: {output}" - ); - // The fail-closed backstop, exercised directly: the resolved escape - // path is rejected as a string regardless of how it was reached. - let escaped = outside.join("secret.txt").to_string_lossy().to_string(); - assert!( - !security.is_path_string_allowed(&escaped), - "policy must reject the escape target string: {escaped}" - ); - - let _ = tokio::fs::remove_dir_all(&root).await; -} - -/// A `path` that resolves to a *file* (not a directory) yields a clear -/// "not a directory" error rather than a misleading "0 match(es)". -#[tokio::test] -async fn glob_rejects_file_search_path() { - let dir = std::env::temp_dir().join("openhuman_test_glob_file_root"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("file.txt"), "x").await.unwrap(); - - let tool = GlobTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"pattern": "**/*", "path": "file.txt"})) - .await - .unwrap(); - assert!(result.is_error, "{}", result.output()); - assert!( - result.output().contains("not a directory"), - "{}", - result.output() - ); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} diff --git a/crates/tinytools-std/src/filesystem/grep/extra_tests.rs b/crates/tinytools-std/src/filesystem/grep/extra_tests.rs deleted file mode 100644 index cc7afecb..00000000 --- a/crates/tinytools-std/src/filesystem/grep/extra_tests.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Budget, delegation and unreadable-file tests for `grep`. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::{TestGate, WorkspaceContext, WrapGate}; -use tempfile::TempDir; - -#[test] -fn grep_is_concurrency_safe() { - let dir = TempDir::new().unwrap(); - let tool = GrepTool::new(TestGate::supervised(dir.path().to_path_buf())); - assert!(tool.is_concurrency_safe(&json!({}))); -} - -#[tokio::test] -async fn rate_limit_and_exhausted_budget_are_refused() { - let dir = TempDir::new().unwrap(); - let limited = GrepTool::new(TestGate::with( - dir.path().to_path_buf(), - crate::filesystem::test_support::AutonomyLevel::Supervised, - 0, - )); - let r = limited.execute(json!({"pattern": "x"})).await.unwrap(); - assert!(r.output().contains("too many actions in the last hour")); - - let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) - .no_budget() - .arc(); - let r = GrepTool::new(gate) - .execute(json!({"pattern": "x"})) - .await - .unwrap(); - assert!(r.output().contains("action budget exhausted")); -} - -#[tokio::test] -async fn non_utf8_files_are_skipped_and_others_still_match() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("bin.dat"), [0xff, 0xfe, 0x00, 0xc3]).unwrap(); - std::fs::write(dir.path().join("t.txt"), "needle here\n").unwrap(); - let tool = GrepTool::new(TestGate::supervised(dir.path().to_path_buf())); - let ctx = WorkspaceContext::at(dir.path()); - let r = tool - .execute_with_context( - json!({"pattern": "needle"}), - ToolCallOptions::default(), - Some(&ctx), - ) - .await - .unwrap(); - assert!(r.output().contains("t.txt"), "{}", r.output()); - assert!(!r.output().contains("bin.dat")); -} - -// grep must not return lines from files the gate refuses to read. -#[tokio::test] -async fn hits_the_gate_refuses_are_not_returned() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join(".env"), "API_KEY=sk-secret").unwrap(); - std::fs::write(dir.path().join("ok.txt"), "API_KEY=placeholder").unwrap(); - let gate = crate::filesystem::test_support::WrapGate::new(TestGate::supervised( - dir.path().to_path_buf(), - )) - .deny_suffix(".env") - .arc(); - let r = GrepTool::new(gate) - .execute(json!({"pattern": "API_KEY"})) - .await - .unwrap(); - assert!(r.output().contains("ok.txt"), "{}", r.output()); - assert!(!r.output().contains("sk-secret"), "{}", r.output()); -} diff --git a/crates/tinytools-std/src/filesystem/grep/mod.rs b/crates/tinytools-std/src/filesystem/grep/mod.rs deleted file mode 100644 index 1c402caf..00000000 --- a/crates/tinytools-std/src/filesystem/grep/mod.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! `grep` — regex search across files in the workspace. -//! -//! Coding-harness baseline tool (issue #1205): a first-class -//! file-navigation primitive that lets the agent search for a regex -//! across the workspace without falling through to `shell`. Uses the -//! same path-sandboxing + rate-limiting as `file_read`. - -use super::gate::{FsGate, gate_for_context}; -use async_trait::async_trait; -use regex::Regex; -use serde_json::json; -use std::path::Path; -use std::sync::Arc; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; -use walkdir::WalkDir; - -const DEFAULT_MAX_MATCHES: usize = 200; -const MAX_LINE_BYTES: usize = 2_000; -const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; - -/// Search file contents with a regular expression. -#[derive(Debug)] -pub struct GrepTool { - gate: Arc, -} - -impl GrepTool { - /// Create the tool. - #[must_use] - pub fn new(gate: Arc) -> Self { - Self { gate } - } -} - -#[async_trait] -impl Tool for GrepTool { - fn name(&self) -> &'static str { - "grep" - } - - fn description(&self) -> &'static str { - "Search file contents in the workspace with a regular expression. \ - Returns up to `max_matches` matches (default 200) as `path:line:text` lines." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Regular expression (Rust `regex` crate syntax)." - }, - "path": { - "type": "string", - "description": "Optional sub-path to restrict the search to (relative to workspace).", - "default": "." - }, - "max_matches": { - "type": "integer", - "description": "Cap on matches returned (default 200).", - "minimum": 1 - }, - "case_insensitive": { - "type": "boolean", - "description": "If true, compile the regex with the `i` flag.", - "default": false - } - }, - "required": ["pattern"] - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::ReadOnly - } - - /// Pure read — safe to fan out across parallel `grep` calls. - fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { - true - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_in_context(args, None).await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - self.execute_in_context(args, context).await - } -} - -impl GrepTool { - async fn execute_in_context( - &self, - args: serde_json::Value, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let pattern = args - .get("pattern") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' parameter"))?; - let sub_path = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); - let max_matches = args - .get("max_matches") - .and_then(serde_json::Value::as_u64) - .map_or(DEFAULT_MAX_MATCHES, |n| (n as usize).max(1)); - let case_insensitive = args - .get("case_insensitive") - .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", - )); - } - - let regex = match build_regex(pattern, case_insensitive) { - Ok(r) => r, - Err(e) => return Ok(ToolResult::error(format!("Invalid regex: {e}"))), - }; - - let path_policy = gate_for_context(&self.gate, context, "grep"); - - // Security check: validate path string, resolve symlinks, confirm workspace containment. - let resolved_root = match path_policy.validate_path(sub_path).await { - Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(msg)), - }; - - // Canonical, like `resolved_root`, so in-sandbox hits render (and are - // asked of the gate) relative to the sandbox, as glob does. - let workspace = tokio::fs::canonicalize(path_policy.action_dir()) - .await - .unwrap_or_else(|_| path_policy.action_dir().to_path_buf()); - let result = tokio::task::spawn_blocking(move || { - scan_for_matches( - &resolved_root, - &workspace, - path_policy.as_ref(), - ®ex, - max_matches, - ) - }) - .await - .map_err(|e| anyhow::anyhow!("scan task failed: {e}"))?; - - let (matches, scanned, truncated) = result; - let header = if truncated { - format!( - "{} match(es) (truncated at {max_matches}); scanned {scanned} file(s)", - matches.len() - ) - } else { - format!("{} match(es); scanned {scanned} file(s)", matches.len()) - }; - - let mut body = String::with_capacity(matches.len() * 80 + header.len() + 1); - body.push_str(&header); - for m in &matches { - body.push('\n'); - body.push_str(m); - } - Ok(ToolResult::success(body)) - } -} - -fn build_regex(pattern: &str, case_insensitive: bool) -> Result { - if case_insensitive { - regex::RegexBuilder::new(pattern) - .case_insensitive(true) - .build() - } else { - Regex::new(pattern) - } -} - -fn scan_for_matches( - root: &Path, - workspace: &Path, - gate: &dyn FsGate, - regex: &Regex, - max_matches: usize, -) -> (Vec, usize, bool) { - let mut files: Vec<(std::path::PathBuf, String)> = Vec::new(); - for entry in WalkDir::new(root) - .follow_links(false) - .into_iter() - .filter_entry(|e| !is_skipped(e.file_name().to_string_lossy().as_ref())) - .filter_map(std::result::Result::ok) - { - if !entry.file_type().is_file() { - continue; - } - let Ok(meta) = entry.metadata() else { continue }; - if meta.len() > MAX_FILE_BYTES { - continue; - } - let path = entry.into_path(); - let rel = match path.strip_prefix(workspace) { - Ok(rel) => rel.to_string_lossy().replace('\\', "/"), - Err(_) => path.to_string_lossy().to_string(), - }; - files.push((path, rel)); - } - - // Fail-closed: only read files the readers would also accept (a host may - // deny `.env` and the like inside the workspace). One batch per search, - // as glob does. - let rels: Vec = files.iter().map(|(_, rel)| rel.clone()).collect(); - let verdicts = gate.paths_string_allowed(&rels); - if verdicts.len() != files.len() { - log::warn!( - "[tools:grep] gate answered {} verdict(s) for {} path(s); refusing every file", - verdicts.len(), - files.len() - ); - files.clear(); - } - - let mut matches: Vec = Vec::new(); - let mut scanned = 0usize; - let mut truncated = false; - 'outer: for ((path, rel), allowed) in files.into_iter().zip(verdicts) { - if !allowed { - log::trace!("[tools:grep] path filtered by policy: '{rel}'"); - continue; - } - let Ok(contents) = std::fs::read_to_string(&path) else { - continue; - }; - scanned += 1; - for (lineno, line) in contents.lines().enumerate() { - if regex.is_match(line) { - // `MAX_LINE_BYTES` is a BYTE budget — use the byte-aware - // helper. The earlier migration to `truncate_with_suffix` - // mis-typed this as a char budget; for multi-byte text - // (CJK / emoji) the rendered line could balloon to ~3× - // the intended cap. Per CodeRabbit critical review on - // PR #1549. - let display_line = super::text::truncate_at_byte_boundary(line, MAX_LINE_BYTES); - matches.push(format!("{rel}:{}:{display_line}", lineno + 1)); - if matches.len() >= max_matches { - truncated = true; - break 'outer; - } - } - } - } - (matches, scanned, truncated) -} - -fn is_skipped(name: &str) -> bool { - matches!( - name, - ".git" | "node_modules" | "target" | ".next" | "dist" | "build" | ".cache" - ) -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; - -#[cfg(test)] -#[path = "extra_tests.rs"] -mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/grep/mod_tests.rs b/crates/tinytools-std/src/filesystem/grep/mod_tests.rs deleted file mode 100644 index 5dcc763c..00000000 --- a/crates/tinytools-std/src/filesystem/grep/mod_tests.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! Behavior tests for the `grep` tool, driven through a fake [`FsGate`]. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::TestGate; - -fn test_security(workspace: std::path::PathBuf) -> Arc { - TestGate::supervised(workspace) -} - -#[test] -fn grep_name_and_schema() { - let tool = GrepTool::new(test_security(std::env::temp_dir())); - assert_eq!(tool.name(), "grep"); - let schema = tool.parameters_schema(); - assert!(schema["properties"]["pattern"].is_object()); - assert!( - schema["required"] - .as_array() - .unwrap() - .contains(&json!("pattern")) - ); -} - -#[tokio::test] -async fn grep_finds_matches() { - let dir = std::env::temp_dir().join("openhuman_test_grep_finds"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "alpha\nbravo\ncharlie") - .await - .unwrap(); - tokio::fs::write(dir.join("b.txt"), "alpha2").await.unwrap(); - - let tool = GrepTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"pattern": "^alpha"})).await.unwrap(); - assert!(!result.is_error); - let output = result.output(); - assert!(output.contains("a.txt:1:alpha")); - assert!(output.contains("b.txt:1:alpha2")); - assert!(!output.contains("bravo")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn grep_invalid_regex() { - let dir = std::env::temp_dir().join("openhuman_test_grep_invalid"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let tool = GrepTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"pattern": "([unclosed"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Invalid regex")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn grep_case_insensitive() { - let dir = std::env::temp_dir().join("openhuman_test_grep_ci"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("c.txt"), "Hello World") - .await - .unwrap(); - - let tool = GrepTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"pattern": "hello", "case_insensitive": true})) - .await - .unwrap(); - assert!(!result.is_error); - assert!(result.output().contains("c.txt:1:Hello World")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn grep_blocks_path_traversal() { - let tool = GrepTool::new(test_security(std::env::temp_dir())); - let result = tool - .execute(json!({"pattern": ".", "path": "../.."})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("not allowed")); -} - -#[tokio::test] -async fn grep_skips_node_modules_and_git() { - let dir = std::env::temp_dir().join("openhuman_test_grep_skip"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(dir.join("node_modules")) - .await - .unwrap(); - tokio::fs::create_dir_all(dir.join(".git")).await.unwrap(); - tokio::fs::write(dir.join("node_modules/x.txt"), "needle") - .await - .unwrap(); - tokio::fs::write(dir.join(".git/x.txt"), "needle") - .await - .unwrap(); - tokio::fs::write(dir.join("real.txt"), "needle") - .await - .unwrap(); - - let tool = GrepTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"pattern": "needle"})).await.unwrap(); - assert!(!result.is_error); - let output = result.output(); - assert!(output.contains("real.txt")); - assert!(!output.contains("node_modules")); - assert!(!output.contains(".git")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn grep_respects_max_matches() { - let dir = std::env::temp_dir().join("openhuman_test_grep_max"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - let mut text = String::new(); - for _ in 0..50 { - text.push_str("hit\n"); - } - tokio::fs::write(dir.join("many.txt"), text).await.unwrap(); - - let tool = GrepTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({"pattern": "hit", "max_matches": 5})) - .await - .unwrap(); - assert!(!result.is_error); - assert!(result.output().contains("truncated")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -/// A file over `MAX_FILE_BYTES` is silently excluded rather than read — this -/// pins that the exclusion is at least honest about it: the file does not -/// count toward `scanned`, so the report never claims to have searched bytes -/// it skipped. It does not surface the file was too large; a caller who -/// expected a match in it sees only a lower `scanned` count with no reason. -#[tokio::test] -async fn grep_excludes_an_oversized_file_and_does_not_count_it_scanned() { - let dir = std::env::temp_dir().join("openhuman_test_grep_oversized"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - - let small = format!("{}\nneedle\n", "line ".repeat(10)); - tokio::fs::write(dir.join("small.txt"), &small) - .await - .unwrap(); - let huge = "needle\n".repeat(1_000_000); - tokio::fs::write(dir.join("huge.txt"), &huge).await.unwrap(); - - let tool = GrepTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"pattern": "needle"})).await.unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert!( - result.output().contains("small.txt"), - "the small file's match must still be reported: {}", - result.output() - ); - assert!( - !result.output().contains("huge.txt"), - "the oversized file must not be scanned at all: {}", - result.output() - ); - assert!( - result.output().contains("scanned 1 file"), - "the scanned count must not include the file it skipped for size: {}", - result.output() - ); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} diff --git a/crates/tinytools-std/src/filesystem/image_info/mod.rs b/crates/tinytools-std/src/filesystem/image_info/mod.rs deleted file mode 100644 index ece10980..00000000 --- a/crates/tinytools-std/src/filesystem/image_info/mod.rs +++ /dev/null @@ -1,218 +0,0 @@ -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..SOF15 carry the frame size; C4 (DHT), C8 (JPG) and - // CC (DAC) share the range but are ordinary segments. - if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) { - 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 = match tokio::fs::metadata(&resolved).await { - Ok(m) => m, - Err(e) => { - return Ok(ToolResult::error(format!( - "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 = match tokio::fs::read(&resolved).await { - Ok(b) => b, - Err(e) => return Ok(ToolResult::error(format!("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)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/image_info/mod_tests.rs b/crates/tinytools-std/src/filesystem/image_info/mod_tests.rs deleted file mode 100644 index 12556a2a..00000000 --- a/crates/tinytools-std/src/filesystem/image_info/mod_tests.rs +++ /dev/null @@ -1,372 +0,0 @@ -#![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_ne!(tool.description().len(), 0); - 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 result = tool - .execute(json!({"path": dir.to_string_lossy()})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Failed to read image file")); -} - -// Every SOF marker (C0-CF except DHT C4, JPG C8 and DAC CC) carries the -// frame size; the other three are segments to skip. -#[test] -fn jpeg_dimensions_come_from_every_sof_marker_and_skip_c4_c8_cc() { - let sof = |marker: u8| { - vec![ - 0xFF, 0xD8, 0xFF, marker, 0x00, 0x11, 0x08, 0x01, 0xE0, 0x02, 0x80, - ] - }; - for marker in [ - 0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF, - ] { - assert_eq!( - ImageInfoTool::extract_dimensions(&sof(marker), "jpeg"), - Some((640, 480)), - "SOF {marker:#X}" - ); - } - for marker in [0xC4, 0xC8, 0xCC] { - let mut bytes = vec![0xFF, 0xD8, 0xFF, marker, 0x00, 0x04, 0x01, 0xE0]; - bytes.extend_from_slice(&sof(0xC0)[2..]); - assert_eq!( - ImageInfoTool::extract_dimensions(&bytes, "jpeg"), - Some((640, 480)), - "{marker:#X} must be skipped" - ); - } -} diff --git a/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs b/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs deleted file mode 100644 index 1da54927..00000000 --- a/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Budget, delegation and entry-cap tests for `list_files`. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::{TestGate, WorkspaceContext, WrapGate}; -use tempfile::TempDir; - -#[tokio::test] -async fn rate_limit_and_exhausted_budget_are_refused() { - let dir = TempDir::new().unwrap(); - let limited = ListFilesTool::new(TestGate::with( - dir.path().to_path_buf(), - crate::filesystem::test_support::AutonomyLevel::Supervised, - 0, - )); - let r = limited.execute(json!({})).await.unwrap(); - assert!(r.output().contains("too many actions in the last hour")); - - let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) - .no_budget() - .arc(); - let r = ListFilesTool::new(gate).execute(json!({})).await.unwrap(); - assert!(r.output().contains("action budget exhausted")); -} - -#[tokio::test] -async fn execute_with_context_delegates() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("f.txt"), "").unwrap(); - let tool = ListFilesTool::new(TestGate::supervised(dir.path().to_path_buf())); - let ctx = WorkspaceContext::at(dir.path()); - let r = tool - .execute_with_context(json!({}), ToolCallOptions::default(), Some(&ctx)) - .await - .unwrap(); - assert!(r.output().contains("file\tf.txt")); -} - -#[tokio::test] -async fn listing_stops_at_the_entry_cap() { - let dir = TempDir::new().unwrap(); - for i in 0..(MAX_ENTRIES + 5) { - std::fs::write(dir.path().join(format!("f{i:05}")), "").unwrap(); - } - let tool = ListFilesTool::new(TestGate::supervised(dir.path().to_path_buf())); - let r = tool.execute(json!({})).await.unwrap(); - assert!( - r.output() - .starts_with(&format!("{MAX_ENTRIES} entr(ies) in .")) - ); -} - -#[tokio::test] -async fn a_capped_listing_says_it_was_truncated() { - let dir = TempDir::new().unwrap(); - for i in 0..=MAX_ENTRIES { - std::fs::write(dir.path().join(format!("f{i:05}")), "").unwrap(); - } - let tool = ListFilesTool::new(TestGate::supervised(dir.path().to_path_buf())); - let r = tool.execute(json!({})).await.unwrap(); - assert!(r.output().ends_with("[truncated]"), "{}", &r.output()[..80]); - - std::fs::remove_file(dir.path().join("f00000")).unwrap(); - let r = tool.execute(json!({})).await.unwrap(); - assert!(!r.output().contains("[truncated]")); -} diff --git a/crates/tinytools-std/src/filesystem/list_files/mod.rs b/crates/tinytools-std/src/filesystem/list_files/mod.rs deleted file mode 100644 index fe639a1c..00000000 --- a/crates/tinytools-std/src/filesystem/list_files/mod.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! `list` — directory listing. -//! -//! Coding-harness baseline tool (issue #1205): non-recursive directory -//! listing keyed by a workspace-relative path. Distinguishes files, -//! directories, and symlinks. Path sandboxing matches `file_read`. - -use super::gate::{FsGate, gate_for_context}; -use async_trait::async_trait; -use serde_json::json; -use std::fmt::Write; -use std::sync::Arc; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; - -const MAX_ENTRIES: usize = 1_000; - -/// List the entries of a workspace directory. -#[derive(Debug)] -pub struct ListFilesTool { - gate: Arc, -} - -impl ListFilesTool { - /// Create the tool. - #[must_use] - pub fn new(gate: Arc) -> Self { - Self { gate } - } -} - -#[async_trait] -impl Tool for ListFilesTool { - fn name(&self) -> &'static str { - "list" - } - - fn description(&self) -> &'static str { - "List entries in a workspace directory (non-recursive). Each line is \ - `\t` where kind is `dir`, `file`, or `link`." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory path relative to the workspace (default `.`).", - "default": "." - } - } - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::ReadOnly - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_in_context(args, None).await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - self.execute_in_context(args, context).await - } -} - -impl ListFilesTool { - async fn execute_in_context( - &self, - args: serde_json::Value, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); - - 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", - )); - } - - // Security check: validate path string, resolve symlinks, confirm workspace containment. - let path_policy = gate_for_context(&self.gate, context, "list"); - let resolved = match path_policy.validate_path(path).await { - Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(msg)), - }; - - let mut read = match tokio::fs::read_dir(&resolved).await { - Ok(r) => r, - Err(e) => return Ok(ToolResult::error(format!("Failed to read directory: {e}"))), - }; - - let mut entries: Vec<(String, String)> = Vec::new(); - let mut truncated = false; - loop { - match read.next_entry().await { - Ok(Some(entry)) => { - let name = entry.file_name().to_string_lossy().into_owned(); - let kind = match entry.file_type().await { - Ok(t) if t.is_symlink() => "link", - Ok(t) if t.is_dir() => "dir", - Ok(_) => "file", - Err(_) => "unknown", - }; - entries.push((kind.to_string(), name)); - if entries.len() >= MAX_ENTRIES { - truncated = matches!(read.next_entry().await, Ok(Some(_))); - break; - } - } - Ok(None) => break, - Err(e) => { - return Ok(ToolResult::error(format!( - "Failed to enumerate directory: {e}" - ))); - } - } - } - - entries.sort_by(|a, b| a.1.cmp(&b.1)); - - let mut body = format!("{} entr(ies) in {path}", entries.len()); - for (kind, name) in entries { - body.push('\n'); - body.push_str(&kind); - body.push('\t'); - body.push_str(&name); - } - if truncated { - let _ = write!( - body, - "\n... listing stopped at {MAX_ENTRIES} entries [truncated]" - ); - } - Ok(ToolResult::success(body)) - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; - -#[cfg(test)] -#[path = "extra_tests.rs"] -mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/list_files/mod_tests.rs b/crates/tinytools-std/src/filesystem/list_files/mod_tests.rs deleted file mode 100644 index 6b6f1762..00000000 --- a/crates/tinytools-std/src/filesystem/list_files/mod_tests.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Behavior tests for the `list_files` tool, driven through a fake [`FsGate`]. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::TestGate; - -fn test_security(workspace: std::path::PathBuf) -> Arc { - TestGate::supervised(workspace) -} - -#[test] -fn list_name() { - let tool = ListFilesTool::new(test_security(std::env::temp_dir())); - assert_eq!(tool.name(), "list"); -} - -#[tokio::test] -async fn list_lists_files_and_dirs() { - let dir = std::env::temp_dir().join("openhuman_test_list"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(dir.join("sub")).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "x").await.unwrap(); - - let tool = ListFilesTool::new(test_security(dir.clone())); - let result = tool.execute(json!({})).await.unwrap(); - assert!(!result.is_error); - let output = result.output(); - assert!(output.contains("file\ta.txt")); - assert!(output.contains("dir\tsub")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn list_blocks_path_traversal() { - let tool = ListFilesTool::new(test_security(std::env::temp_dir())); - let result = tool.execute(json!({"path": "../../etc"})).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("not allowed")); -} - -#[tokio::test] -async fn list_missing_dir() { - let dir = std::env::temp_dir().join("openhuman_test_list_missing"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = ListFilesTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"path": "nope"})).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Failed to resolve")); - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn list_a_plain_file_reports_the_read_dir_failure() { - // Distinct from `list_missing_dir`: the path resolves (the file exists), - // so `validate_path` succeeds; the failure is `read_dir` itself refusing - // a non-directory (ENOTDIR), one step later in the same function. - let dir = std::env::temp_dir().join("openhuman_test_list_not_a_dir"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("plain.txt"), "not a directory") - .await - .unwrap(); - - let tool = ListFilesTool::new(test_security(dir.clone())); - let result = tool.execute(json!({"path": "plain.txt"})).await.unwrap(); - assert!( - result.is_error, - "listing a plain file must be a refusal, not an empty-looking success" - ); - assert!(result.output().contains("Failed to read directory")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} diff --git a/crates/tinytools-std/src/filesystem/mod.rs b/crates/tinytools-std/src/filesystem/mod.rs deleted file mode 100644 index e95054d4..00000000 --- a/crates/tinytools-std/src/filesystem/mod.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Filesystem tools: read/write/edit/patch, grep/glob/list, diffs, CSV export, -//! 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 -//! trait ([`FsGate`]) is the whole seam; nothing here knows what an autonomy -//! level, an approval prompt or a workspace boundary is. - -// 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::unnecessary_debug_formatting -)] - -mod apply_patch; -mod csv_export; -mod edit_file; -mod file_read; -mod file_sink; -mod file_write; -mod gate; -mod git_operations; -mod glob_search; -mod grep; -mod image_info; -mod list_files; -mod read_diff; -mod read_only; -mod run_linter; -mod run_tests; -mod text; -mod update_memory_md; -mod workspace_state; - -#[cfg(test)] -#[path = "gate_tests.rs"] -mod gate_test; -#[cfg(test)] -mod test_support; - -pub use apply_patch::ApplyPatchTool; -pub use csv_export::CsvExportTool; -pub use edit_file::EditFileTool; -pub use file_read::FileReadTool; -pub use file_write::FileWriteTool; -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 read_only::{is_read_only_call, is_read_only_command}; -pub use run_linter::RunLinterTool; -pub use run_tests::RunTestsTool; -pub use update_memory_md::UpdateMemoryMdTool; -pub use workspace_state::WorkspaceStateTool; - -#[cfg(test)] -#[path = "contract_tests.rs"] -mod contract_test; diff --git a/crates/tinytools-std/src/filesystem/read_diff/mod.rs b/crates/tinytools-std/src/filesystem/read_diff/mod.rs deleted file mode 100644 index d894a7ae..00000000 --- a/crates/tinytools-std/src/filesystem/read_diff/mod.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Tool: `read_diff` — structured git diff output for the Critic archetype. - -use async_trait::async_trait; -use serde_json::json; -use std::path::PathBuf; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; - -/// Returns `git diff` output in a structured format. -#[derive(Debug)] -pub struct ReadDiffTool { - workspace_dir: PathBuf, -} - -impl ReadDiffTool { - #[must_use] - /// Create the tool. - pub fn new(workspace_dir: PathBuf) -> Self { - Self { workspace_dir } - } - - fn workspace_dir_for_context(&self, context: Option<&dyn ToolRunContext>) -> PathBuf { - if let Some(workspace) = context.and_then(|ctx| ctx.workspace()) { - tracing::debug!( - workspace_root = %workspace.root.display(), - policy_id = %workspace.policy_id, - "[read_diff] using TinyAgents workspace descriptor as workspace dir" - ); - return workspace.root.clone(); - } - self.workspace_dir.clone() - } -} - -#[async_trait] -impl Tool for ReadDiffTool { - fn exposure(&self) -> tinytools::ToolExposure { - tinytools::ToolExposure::Deferred - } - - fn name(&self) -> &'static str { - "read_diff" - } - - fn description(&self) -> &'static str { - "Get the git diff of current changes. Can diff staged, unstaged, or against a \ - specific base branch/commit. Returns file paths and hunks." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "base": { - "type": "string", - "description": "Base ref to diff against (e.g. 'main', 'HEAD~3'). Default: unstaged changes." - }, - "staged": { - "type": "boolean", - "description": "Show staged changes only (--cached). Default: false." - }, - "path_filter": { - "type": "string", - "description": "Limit diff to a specific path or glob." - } - } - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::ReadOnly - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_with_context(args, ToolCallOptions::default(), None) - .await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let workspace_dir = self.workspace_dir_for_context(context); - let base = args.get("base").and_then(|v| v.as_str()); - let staged = args - .get("staged") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let path_filter = args.get("path_filter").and_then(|v| v.as_str()); - - // `base` comes from the model: a value starting with `-` would reach git - // as an option (`--output=` writes a file from this read-only tool). - // No git revision starts with `-`. - if base.is_some_and(|b| b.starts_with('-')) { - return Ok(ToolResult::error("base must be a revision, not an option")); - } - - // Same diff hardening as `git_operations`: a repository-configured - // external diff or textconv driver must not run. - let mut git_args = vec!["diff", "--no-ext-diff", "--no-textconv", "--stat", "-p"]; - - if staged { - git_args.push("--cached"); - } - - let base_str = base.map(std::string::ToString::to_string); - if let Some(ref bs) = base_str { - git_args.push(bs); - } - - if let Some(pf) = path_filter { - git_args.push("--"); - git_args.push(pf); - } - - tracing::debug!( - workspace = %workspace_dir.display(), - ?git_args, - "[read_diff] running git diff" - ); - - let output = crate::filesystem::git_operations::config::hardened_git(&workspace_dir) - .args(&git_args) - .output() - .await?; - - if output.status.success() { - let diff = String::from_utf8_lossy(&output.stdout); - tracing::debug!("[read_diff] success, diff length={}", diff.len()); - if diff.trim().is_empty() { - Ok(ToolResult::success("No changes found.")) - } else { - Ok(ToolResult::success(diff.to_string())) - } - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - tracing::debug!("[read_diff] failed: {stderr}"); - Ok(ToolResult::error(stderr.to_string())) - } - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/read_diff/mod_tests.rs b/crates/tinytools-std/src/filesystem/read_diff/mod_tests.rs deleted file mode 100644 index 0ecd36da..00000000 --- a/crates/tinytools-std/src/filesystem/read_diff/mod_tests.rs +++ /dev/null @@ -1,160 +0,0 @@ -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use serde_json::json; -use tempfile::TempDir; - -fn make_tool(dir: &TempDir) -> ReadDiffTool { - ReadDiffTool::new(dir.path().to_path_buf()) -} - -#[test] -fn name_is_correct() { - let tmp = TempDir::new().unwrap(); - assert_eq!(make_tool(&tmp).name(), "read_diff"); -} - -#[test] -fn description_is_non_empty() { - let tmp = TempDir::new().unwrap(); - assert_ne!(make_tool(&tmp).description().len(), 0); -} - -#[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 execute_returns_error_for_non_git_dir() { - let tmp = TempDir::new().unwrap(); - let result = make_tool(&tmp).execute(json!({})).await.unwrap(); - // Non-git dir: git will fail, tool returns error - assert!(result.is_error); -} - -#[tokio::test] -async fn execute_no_changes_in_clean_git_repo() { - let tmp = TempDir::new().unwrap(); - // Init a git repo and make an initial commit so there's nothing to diff - let _ = std::process::Command::new("git") - .args(["init"]) - .current_dir(tmp.path()) - .output(); - let _ = std::process::Command::new("git") - .args(["commit", "--allow-empty", "-m", "init"]) - .current_dir(tmp.path()) - .env("GIT_AUTHOR_NAME", "test") - .env("GIT_AUTHOR_EMAIL", "t@t.com") - .env("GIT_COMMITTER_NAME", "test") - .env("GIT_COMMITTER_EMAIL", "t@t.com") - .output(); - let result = make_tool(&tmp).execute(json!({})).await.unwrap(); - assert!(!result.is_error); - assert!(result.output().contains("No changes found.")); -} - -fn git_in(dir: &std::path::Path, args: &[&str]) { - let out = std::process::Command::new("git") - .args(["-c", "user.email=t@example.invalid", "-c", "user.name=T"]) - .args(args) - .current_dir(dir) - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .output() - .unwrap(); - assert!( - out.status.success(), - "{}", - String::from_utf8_lossy(&out.stderr) - ); -} - -fn repo_with_change() -> TempDir { - let tmp = TempDir::new().unwrap(); - git_in(tmp.path(), &["init"]); - std::fs::write(tmp.path().join("a.txt"), "one\n").unwrap(); - std::fs::write(tmp.path().join("b.txt"), "one\n").unwrap(); - git_in(tmp.path(), &["add", "."]); - git_in(tmp.path(), &["commit", "-m", "init"]); - std::fs::write(tmp.path().join("a.txt"), "two\n").unwrap(); - std::fs::write(tmp.path().join("b.txt"), "two\n").unwrap(); - tmp -} - -#[tokio::test] -async fn path_filter_limits_the_diff() { - let tmp = repo_with_change(); - let result = make_tool(&tmp) - .execute(json!({"path_filter": "a.txt"})) - .await - .unwrap(); - assert!(!result.is_error); - assert!(result.output().contains("a.txt")); - assert!(!result.output().contains("b.txt")); -} - -#[tokio::test] -async fn base_ref_and_staged_flags_are_passed_to_git() { - let tmp = repo_with_change(); - git_in(tmp.path(), &["add", "a.txt"]); - let staged = make_tool(&tmp) - .execute(json!({"staged": true})) - .await - .unwrap(); - assert!(staged.output().contains("a.txt")); - assert!(!staged.output().contains("b.txt")); - - let against_head = make_tool(&tmp) - .execute(json!({"base": "HEAD"})) - .await - .unwrap(); - assert!(against_head.output().contains("b.txt")); - - let bad_base = make_tool(&tmp) - .execute(json!({"base": "no-such-ref"})) - .await - .unwrap(); - assert!(bad_base.is_error); -} - -#[tokio::test] -async fn a_workspace_descriptor_overrides_the_configured_directory() { - use crate::filesystem::test_support::WorkspaceContext; - let elsewhere = TempDir::new().unwrap(); - let repo = repo_with_change(); - let context = WorkspaceContext::at(repo.path()); - let result = make_tool(&elsewhere) - .execute_with_context(json!({}), ToolCallOptions::default(), Some(&context)) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert!(result.output().contains("a.txt")); -} - -/// A model-chosen `base` that looks like an option must never reach git as -/// one: `--output=` would let this read-only tool write a file. -#[tokio::test] -async fn a_base_that_looks_like_an_option_is_rejected() { - let tmp = repo_with_change(); - let result = make_tool(&tmp) - .execute(json!({"base": "--output=out.txt"})) - .await - .unwrap(); - assert!( - !tmp.path().join("out.txt").exists(), - "read_diff wrote a file via --output" - ); - assert!(result.is_error, "{}", result.output()); -} diff --git a/crates/tinytools-std/src/filesystem/read_only.rs b/crates/tinytools-std/src/filesystem/read_only.rs deleted file mode 100644 index 7a619081..00000000 --- a/crates/tinytools-std/src/filesystem/read_only.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Which tool calls only read files the agent can read again. -//! -//! A host skips an LLM summary of tool output for these. A summary of source code is a paraphrase, and an agent about to -//! edit a file needs its exact text: on live coding runs every summarized -//! `cat` of a source file was followed by `juice_retrieve` of the whole -//! original or a fresh `sed -n` read of the file. The summary bought nothing -//! and cost a model call plus up to `TinyJuice`'s 8-second summary timeout, -//! which it hit on most attempts. -//! -//! The test is deliberately narrow: a file-reading tool, or a shell command -//! made only of `cd` and plain reads (`cat`, `head`, `tail`, `nl`, `sed -n` -//! with a script that cannot write or run, `echo`/`printf` separators), with -//! no redirects, heredocs, substitutions or other programs. Anything else (a -//! build, a test run, a search) keeps the normal pipeline: its output is not a -//! file on disk. - -use serde_json::Value; - -/// Tools whose whole job is returning a file's contents. -const FILE_READ_TOOLS: &[&str] = &["file_read", "read_file", "read", "view_file"]; - -/// Shell tools, by the names hosts and common harness adapters use. -const SHELL_TOOLS: &[&str] = &["shell", "bash", "exec", "run_command", "terminal"]; - -/// Programs that print a file (`sed` only as a plain `-n` read). -const READERS: &[&str] = &["cat", "head", "tail", "nl", "sed"]; - -/// Programs allowed beside the readers: navigation and separators. -const NEUTRAL: &[&str] = &["cd", "echo", "printf", "true", "pwd"]; - -/// Whether this call only prints files the agent can re-read (see the module -/// docs). `use_skill` is followed into the tool it wraps. -#[must_use] -pub fn is_read_only_call(tool_name: &str, args: &Value) -> bool { - let (name, args) = if tool_name == "use_skill" { - match (args.get("tool").and_then(Value::as_str), args.get("args")) { - (Some(inner), Some(inner_args)) => (inner, inner_args), - _ => return false, - } - } else { - (tool_name, args) - }; - if FILE_READ_TOOLS.contains(&name) { - return true; - } - if !SHELL_TOOLS.contains(&name) { - return false; - } - args.get("command") - .or_else(|| args.get("cmd")) - .and_then(Value::as_str) - .is_some_and(is_read_only_command) -} - -/// Whether a shell command line only prints files: every segment (split on -/// `&&`, `||`, `;`, `|` and newlines) runs a reader or a neutral program, at -/// least one runs a reader, and nothing redirects, substitutes or feeds a -/// heredoc. -#[must_use] -pub fn is_read_only_command(command: &str) -> bool { - if command.contains(['>', '<', '`', '$']) { - return false; - } - let mut reads = false; - for segment in command - .split(['\n', ';', '|', '&']) - .map(str::trim) - .filter(|s| !s.is_empty()) - { - let mut words = segment.split_whitespace(); - let Some(program) = words.next() else { - continue; - }; - let program = program.rsplit('/').next().unwrap_or(program); - if program == "sed" - && !is_plain_sed_read( - segment - .split_once(char::is_whitespace) - .map_or("", |(_, args)| args), - ) - { - return false; - } - if READERS.contains(&program) { - reads = true; - } else if !NEUTRAL.contains(&program) { - return false; - } - } - reads -} - -/// Whether `sed`'s arguments only print: its only flag is `-n`, and its -/// script (the first other argument, quotes honoured) has no `w`/`W` -/// (write), `e` (execute) or `r` (read another file) command. -/// -/// ponytail: the script check refuses those letters anywhere, so a regex -/// such as `/error/p` is refused too. That only costs the summary skip; -/// parse sed's command grammar if it starts to matter. -fn is_plain_sed_read(args: &str) -> bool { - let mut quiet = false; - let mut script = None; - let mut rest = args.trim_start(); - while !rest.is_empty() { - let (word, tail) = if let Some(quote @ ('\'' | '"')) = rest.chars().next() { - let Some(end) = rest[1..].find(quote) else { - return false; - }; - (&rest[1..=end], &rest[end + 2..]) - } else { - rest.split_once(char::is_whitespace).unwrap_or((rest, "")) - }; - if word.starts_with('-') { - if word != "-n" { - return false; - } - quiet = true; - } else if script.is_none() { - script = Some(word); - } - rest = tail.trim_start(); - } - quiet && script.is_some_and(|s| !s.contains(['w', 'W', 'e', 'r'])) -} - -#[cfg(test)] -#[path = "read_only_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/filesystem/read_only_tests.rs b/crates/tinytools-std/src/filesystem/read_only_tests.rs deleted file mode 100644 index 5f622e02..00000000 --- a/crates/tinytools-std/src/filesystem/read_only_tests.rs +++ /dev/null @@ -1,78 +0,0 @@ -use super::*; -use serde_json::json; - -#[test] -fn plain_reads_of_files_are_file_reads() { - for command in [ - "cat vm/vmExprFunction.go", - "cd /app && cat vm/vmExprFunction.go", - "cd /app && cat -n parser/lexer.go", - "cd /app && sed -n '120,300p' vm/vmExprFunction.go", - "cd /app && cat parser/Makefile && echo \"=== parser.go.y ===\" && cat parser/parser.go.y", - "cd /app && sed -n '125,150p' ast/expr.go && echo \"=== vmExprFunction.go ===\" && cat vm/vmExprFunction.go", - "head -50 README.md; tail -20 CHANGELOG.md", - "cat -n src/lib.rs | sed -n '1,80p'", - ] { - assert!(is_read_only_command(command), "{command}"); - } -} - -#[test] -fn anything_that_runs_writes_or_searches_is_not() { - for command in [ - "go test ./vm/...", - "cd /app && git diff", - "grep -rn FuncExpr .", - "cat foo.go > bar.go", - "sed -i 's/a/b/' foo.go", - "sed --in-place 's/a/b/' foo.go", - "sed --in-place=.bak -n 's/old/new/p' file", - "cat <<'EOF' > x.go\npackage x\nEOF", - "cat $(git ls-files)", - "cat README.md && printf '%s\\n' \"$BUILD_LOG\"", - "cd /app && cat a.go && go build ./...", - "echo hello", - "cd /app", - ] { - assert!(!is_read_only_command(command), "{command}"); - } -} - -#[test] -fn file_read_tools_and_wrapped_calls_are_recognised() { - assert!(is_read_only_call("file_read", &json!({"path": "a.rs"}))); - assert!(is_read_only_call("shell", &json!({"command": "cat a.rs"}))); - assert!(!is_read_only_call( - "shell", - &json!({"command": "cargo test"}) - )); - assert!(!is_read_only_call( - "web_fetch", - &json!({"url": "https://example.com"}) - )); - assert!(is_read_only_call( - "use_skill", - &json!({"tool": "shell", "args": {"command": "cat a.rs"}}) - )); - assert!(!is_read_only_call("use_skill", &json!({"tool": "shell"}))); -} - -// sed is a plain read only with `-n` and a script with no `w`/`W` (write), -// `e` (execute) or `r` command; anything else may write, run or not be a read. -#[test] -fn sed_counts_only_with_n_and_a_script_that_cannot_write_or_run() { - assert!(is_read_only_command("sed -n '/fn main/p' src/lib.rs")); - for command in [ - "sed -n '1 w out.txt' notes.md", - "sed -n 's/a/b/w out.txt' notes.md", - "sed -n 'W out.txt' notes.md", - "sed -n '1e touch pwned' notes.md", - "sed -n 's/x/y/e' notes.md", - "sed -n '1r other.txt' notes.md", - "sed '1,5p' notes.md", - "sed -E -n '1p' notes.md", - "sed -n -e '1p' notes.md", - ] { - assert!(!is_read_only_command(command), "{command}"); - } -} diff --git a/crates/tinytools-std/src/filesystem/run_linter/mod.rs b/crates/tinytools-std/src/filesystem/run_linter/mod.rs deleted file mode 100644 index 857f55e5..00000000 --- a/crates/tinytools-std/src/filesystem/run_linter/mod.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! Tool: `run_linter` — run linting tools for the Critic archetype. - -use async_trait::async_trait; -use serde_json::json; -use std::path::PathBuf; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; - -/// Runs linters (cargo clippy, eslint) and returns structured findings. -#[derive(Debug)] -pub struct RunLinterTool { - workspace_dir: PathBuf, -} - -impl RunLinterTool { - #[must_use] - /// Create the tool. - pub fn new(workspace_dir: PathBuf) -> Self { - Self { workspace_dir } - } - - fn workspace_dir_for_context(&self, context: Option<&dyn ToolRunContext>) -> PathBuf { - if let Some(workspace) = context.and_then(|ctx| ctx.workspace()) { - tracing::debug!( - workspace_root = %workspace.root.display(), - policy_id = %workspace.policy_id, - "[run_linter] using TinyAgents workspace descriptor as workspace dir" - ); - return workspace.root.clone(); - } - self.workspace_dir.clone() - } -} - -#[async_trait] -impl Tool for RunLinterTool { - fn exposure(&self) -> tinytools::ToolExposure { - tinytools::ToolExposure::Deferred - } - - fn name(&self) -> &'static str { - "run_linter" - } - - fn description(&self) -> &'static str { - "Run linting tools on the codebase. Supports 'clippy' for Rust and 'eslint' for \ - TypeScript/JavaScript. Returns warnings and errors." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "linter": { - "type": "string", - "enum": ["clippy", "eslint", "auto"], - "description": "Which linter to run. 'auto' detects from project files.", - "default": "auto" - }, - "path": { - "type": "string", - "description": "Limit linting to a specific path." - } - } - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Execute - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_with_context(args, ToolCallOptions::default(), None) - .await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let workspace_dir = self.workspace_dir_for_context(context); - let linter = args - .get("linter") - .and_then(|v| v.as_str()) - .unwrap_or("auto"); - - let linter = if linter == "auto" { - if workspace_dir.join("Cargo.toml").exists() { - "clippy" - } else if workspace_dir.join("package.json").exists() { - "eslint" - } else { - return Ok(ToolResult::error( - "Could not detect project type for linting.", - )); - } - } else { - linter - }; - - let mut cmd = match linter { - "clippy" => { - let mut cmd = tokio::process::Command::new("cargo"); - cmd.args([ - "clippy", - "--message-format=short", - "--", - "-W", - "clippy::all", - ]) - .current_dir(&workspace_dir); - cmd - } - "eslint" => { - let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); - if path.starts_with('/') || path.contains("..") { - return Ok(ToolResult::error( - "path must be a relative path within the workspace \ - (no absolute paths or '..')", - )); - } - // A value starting with `-` would reach eslint as an option - // (`--output-file=` writes a file). No path needs one. - if path.starts_with('-') { - return Ok(ToolResult::error("path must be a path, not an option")); - } - let mut cmd = tokio::process::Command::new("npx"); - cmd.args(["eslint", "--format", "compact", path]) - .current_dir(&workspace_dir); - cmd - } - other => { - return Ok(ToolResult::error(format!("Unknown linter: {other}"))); - } - }; - crate::timeout::hide_console_window(cmd.as_std_mut()); - let output = cmd.output().await?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - let combined = if stdout.is_empty() { - stderr.to_string() - } else { - format!("{stdout}\n{stderr}") - }; - - if output.status.success() { - Ok(ToolResult::success(combined)) - } else { - Ok(ToolResult::error(format!( - "Linter exited with code {:?}\n{}", - output.status.code(), - combined - ))) - } - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/run_linter/mod_tests.rs b/crates/tinytools-std/src/filesystem/run_linter/mod_tests.rs deleted file mode 100644 index 2fb8012e..00000000 --- a/crates/tinytools-std/src/filesystem/run_linter/mod_tests.rs +++ /dev/null @@ -1,112 +0,0 @@ -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use serde_json::json; -use tempfile::TempDir; - -fn make_tool(dir: &TempDir) -> RunLinterTool { - RunLinterTool::new(dir.path().to_path_buf()) -} - -#[test] -fn name_is_correct() { - let tmp = TempDir::new().unwrap(); - assert_eq!(make_tool(&tmp).name(), "run_linter"); -} - -#[test] -fn description_is_non_empty() { - let tmp = TempDir::new().unwrap(); - assert_ne!(make_tool(&tmp).description().len(), 0); -} - -#[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_execute() { - let tmp = TempDir::new().unwrap(); - assert_eq!(make_tool(&tmp).permission_level(), PermissionLevel::Execute); -} - -#[tokio::test] -async fn auto_returns_error_when_no_project_files() { - let tmp = TempDir::new().unwrap(); - let result = make_tool(&tmp) - .execute(json!({"linter": "auto"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Could not detect project type")); -} - -#[tokio::test] -async fn unknown_linter_returns_error() { - let tmp = TempDir::new().unwrap(); - let result = make_tool(&tmp) - .execute(json!({"linter": "rubocop"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Unknown linter")); -} - -#[tokio::test] -async fn eslint_rejects_absolute_path() { - let tmp = TempDir::new().unwrap(); - // Create a package.json so linter resolves to eslint - std::fs::write(tmp.path().join("package.json"), "{}").unwrap(); - let result = make_tool(&tmp) - .execute(json!({"linter": "eslint", "path": "/etc/passwd"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("relative path")); -} - -#[tokio::test] -async fn eslint_rejects_path_traversal() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("package.json"), "{}").unwrap(); - let result = make_tool(&tmp) - .execute(json!({"linter": "eslint", "path": "../secret"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("relative path")); -} - -#[tokio::test] -async fn a_workspace_descriptor_overrides_the_configured_directory() { - use crate::filesystem::test_support::WorkspaceContext; - let configured = TempDir::new().unwrap(); - let isolated = TempDir::new().unwrap(); - let context = WorkspaceContext::at(isolated.path()); - // Neither directory has project files; the answer only proves the call - // ran, and the descriptor branch is what resolved the directory. - let result = make_tool(&configured) - .execute_with_context(json!({}), ToolCallOptions::default(), Some(&context)) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Could not detect project type")); -} - -// A path starting with `-` would reach eslint as an option -// (`--output-file=` writes a file). No path needs a leading `-`. -#[tokio::test] -async fn eslint_rejects_an_option_shaped_path() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("package.json"), "{}").unwrap(); - let result = make_tool(&tmp) - .execute(json!({"linter": "eslint", "path": "--output-file=pwned.txt"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("not an option")); - assert!(!tmp.path().join("pwned.txt").exists()); -} diff --git a/crates/tinytools-std/src/filesystem/run_tests/mod.rs b/crates/tinytools-std/src/filesystem/run_tests/mod.rs deleted file mode 100644 index a522a680..00000000 --- a/crates/tinytools-std/src/filesystem/run_tests/mod.rs +++ /dev/null @@ -1,200 +0,0 @@ -//! Tool: `run_tests` — run test suites for the Critic archetype. - -use async_trait::async_trait; -use serde_json::json; -use std::path::PathBuf; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; - -/// Runs test suites (cargo test, vitest) and returns pass/fail with output. -#[derive(Debug)] -pub struct RunTestsTool { - workspace_dir: PathBuf, -} - -impl RunTestsTool { - #[must_use] - /// Create the tool. - pub fn new(workspace_dir: PathBuf) -> Self { - Self { workspace_dir } - } - - fn workspace_dir_for_context(&self, context: Option<&dyn ToolRunContext>) -> PathBuf { - if let Some(workspace) = context.and_then(|ctx| ctx.workspace()) { - tracing::debug!( - workspace_root = %workspace.root.display(), - policy_id = %workspace.policy_id, - "[run_tests] using TinyAgents workspace descriptor as workspace dir" - ); - return workspace.root.clone(); - } - self.workspace_dir.clone() - } -} - -#[async_trait] -impl Tool for RunTestsTool { - fn exposure(&self) -> tinytools::ToolExposure { - tinytools::ToolExposure::Deferred - } - - fn name(&self) -> &'static str { - "run_tests" - } - - fn description(&self) -> &'static str { - "Run the project test suite. Supports 'cargo_test' for Rust and 'vitest' for \ - TypeScript/JavaScript. Returns pass/fail results with output." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "runner": { - "type": "string", - "enum": ["cargo_test", "vitest", "auto"], - "description": "Which test runner to use. 'auto' detects from project files.", - "default": "auto" - }, - "filter": { - "type": "string", - "description": "Filter to run specific tests (e.g. test name or module)." - }, - "timeout_secs": { - "type": "integer", - "description": "Timeout in seconds (default: 120).", - "default": 120 - } - } - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Execute - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_with_context(args, ToolCallOptions::default(), None) - .await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let workspace_dir = self.workspace_dir_for_context(context); - let runner = args - .get("runner") - .and_then(|v| v.as_str()) - .unwrap_or("auto"); - let filter = args.get("filter").and_then(|v| v.as_str()); - let timeout_secs = args - .get("timeout_secs") - .and_then(serde_json::Value::as_u64) - .unwrap_or(120); - - let runner = if runner == "auto" { - if workspace_dir.join("Cargo.toml").exists() { - "cargo_test" - } else if workspace_dir.join("package.json").exists() { - "vitest" - } else { - return Ok(ToolResult::error( - "Could not detect project type for testing.", - )); - } - } else { - runner - }; - - let mut cmd = match runner { - "cargo_test" => { - let mut c = tokio::process::Command::new("cargo"); - c.arg("test"); - if let Some(f) = filter { - c.arg(f); - } - c - } - "vitest" => { - let mut c = tokio::process::Command::new("npx"); - c.args(["vitest", "run"]); - if let Some(f) = filter { - c.arg(f); - } - c - } - other => { - return Ok(ToolResult::error(format!("Unknown test runner: {other}"))); - } - }; - - tracing::debug!( - workspace = %workspace_dir.display(), - "[run_tests] runner={runner}, filter={filter:?}, timeout={timeout_secs}s" - ); - - cmd.current_dir(&workspace_dir); - cmd.kill_on_drop(true); - - let output = - match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), cmd.output()) - .await - { - Ok(Ok(output)) => output, - Ok(Err(e)) => { - return Ok(ToolResult::error(format!( - "failed to spawn test runner: {e}" - ))); - } - Err(_) => { - return Ok(ToolResult::error(format!( - "test execution timed out after {timeout_secs}s" - ))); - } - }; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = format!("{stdout}\n{stderr}"); - - // Truncate on a safe UTF-8 char boundary. - let truncated = if combined.len() > 8000 { - let safe_end = combined - .char_indices() - .take_while(|(i, _)| *i <= 8000) - .last() - .map_or(0, |(i, c)| i + c.len_utf8()); - format!( - "{}...\n[truncated, {} total chars]", - &combined[..safe_end], - combined.len() - ) - } else { - combined - }; - - tracing::debug!( - "[run_tests] exit_code={:?}, output_len={}", - output.status.code(), - truncated.len() - ); - - if output.status.success() { - Ok(ToolResult::success(truncated)) - } else { - Ok(ToolResult::error(format!( - "Tests exited with code {:?}\n\n{}", - output.status.code(), - truncated - ))) - } - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/run_tests/mod_tests.rs b/crates/tinytools-std/src/filesystem/run_tests/mod_tests.rs deleted file mode 100644 index 10d3cba9..00000000 --- a/crates/tinytools-std/src/filesystem/run_tests/mod_tests.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! Behavior tests for the `run_tests` tool. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use super::*; -use crate::filesystem::test_support::WorkspaceContext; -use serde_json::json; -use tempfile::TempDir; - -fn make_tool(dir: &TempDir) -> RunTestsTool { - RunTestsTool::new(dir.path().to_path_buf()) -} - -/// A throwaway cargo package whose single test body is `test_body`. -fn cargo_project(test_body: &str) -> TempDir { - let tmp = TempDir::new().unwrap(); - std::fs::write( - tmp.path().join("Cargo.toml"), - "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n", - ) - .unwrap(); - std::fs::create_dir(tmp.path().join("src")).unwrap(); - std::fs::write( - tmp.path().join("src/lib.rs"), - format!("#[test]\nfn fixture_test() {{\n{test_body}\n}}\n"), - ) - .unwrap(); - tmp -} - -#[test] -fn name_and_description_are_pinned() { - let tmp = TempDir::new().unwrap(); - let tool = make_tool(&tmp); - assert_eq!(tool.name(), "run_tests"); - assert_eq!( - tool.description(), - "Run the project test suite. Supports 'cargo_test' for Rust and 'vitest' for \ - TypeScript/JavaScript. Returns pass/fail results with output." - ); -} - -#[test] -fn schema_is_pinned() { - let tmp = TempDir::new().unwrap(); - assert_eq!( - make_tool(&tmp).parameters_schema(), - json!({ - "type": "object", - "properties": { - "runner": { - "type": "string", - "enum": ["cargo_test", "vitest", "auto"], - "description": "Which test runner to use. 'auto' detects from project files.", - "default": "auto" - }, - "filter": { - "type": "string", - "description": "Filter to run specific tests (e.g. test name or module)." - }, - "timeout_secs": { - "type": "integer", - "description": "Timeout in seconds (default: 120).", - "default": 120 - } - } - }) - ); -} - -#[test] -fn permission_level_and_exposure() { - let tmp = TempDir::new().unwrap(); - let tool = make_tool(&tmp); - assert_eq!(tool.permission_level(), PermissionLevel::Execute); - assert_eq!(tool.exposure(), tinytools::ToolExposure::Deferred); -} - -#[tokio::test] -async fn auto_returns_error_when_no_project_files() { - let tmp = TempDir::new().unwrap(); - let result = make_tool(&tmp).execute(json!({})).await.unwrap(); - assert!(result.is_error); - assert!( - result - .output() - .contains("Could not detect project type for testing.") - ); -} - -#[tokio::test] -async fn unknown_runner_returns_error() { - let tmp = TempDir::new().unwrap(); - let result = make_tool(&tmp) - .execute(json!({"runner": "pytest"})) - .await - .unwrap(); - assert!(result.is_error); - assert_eq!(result.output(), "Unknown test runner: pytest"); -} - -#[tokio::test] -async fn auto_detects_a_node_project_as_vitest() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("package.json"), "{}").unwrap(); - // The runner may or may not be installed; either way the tool must have - // picked `vitest` rather than reporting an undetectable project. - let result = make_tool(&tmp) - .execute(json!({"timeout_secs": 1})) - .await - .unwrap(); - assert!(!result.output().contains("Could not detect project type")); -} - -#[tokio::test] -async fn passing_cargo_project_reports_success() { - let tmp = cargo_project("assert_eq!(1 + 1, 2);"); - let result = make_tool(&tmp) - .execute(json!({"runner": "cargo_test", "filter": "fixture_test"})) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert!(result.output().contains("test result: ok")); -} - -#[tokio::test] -async fn failing_cargo_project_reports_the_exit_code_and_truncates_output() { - let tmp = cargo_project("panic!(\"{}\", \"x\".repeat(20_000));"); - let result = make_tool(&tmp).execute(json!({})).await.unwrap(); - assert!(result.is_error); - let output = result.output(); - assert!(output.starts_with("Tests exited with code Some(101)")); - assert!(output.contains("[truncated, ")); -} - -#[tokio::test] -async fn a_timeout_is_reported() { - let tmp = cargo_project("assert!(true);"); - let result = make_tool(&tmp) - .execute(json!({"runner": "cargo_test", "timeout_secs": 0})) - .await - .unwrap(); - assert!(result.is_error); - assert_eq!(result.output(), "test execution timed out after 0s"); -} - -#[tokio::test] -async fn a_workspace_descriptor_overrides_the_configured_directory() { - let configured = TempDir::new().unwrap(); - let isolated = TempDir::new().unwrap(); - std::fs::write(isolated.path().join("package.json"), "{}").unwrap(); - let context = WorkspaceContext::at(isolated.path()); - // The configured directory has no project files; only the isolated - // workspace does, so detection succeeding proves the override was used. - let result = make_tool(&configured) - .execute_with_context( - json!({"timeout_secs": 1}), - ToolCallOptions::default(), - Some(&context), - ) - .await - .unwrap(); - assert!(!result.output().contains("Could not detect project type")); -} diff --git a/crates/tinytools-std/src/filesystem/test_support.rs b/crates/tinytools-std/src/filesystem/test_support.rs deleted file mode 100644 index d8b36e59..00000000 --- a/crates/tinytools-std/src/filesystem/test_support.rs +++ /dev/null @@ -1,398 +0,0 @@ -//! A small, self-contained [`FsGate`] for the filesystem tools' unit tests. -//! -//! It models just enough of a host policy to exercise the tools: an autonomy -//! tier, an action budget, an approval flag, and a set of allowed roots that -//! paths must resolve into. 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::path::{Component, Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use async_trait::async_trait; - -use super::gate::FsGate; - -/// 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 that changes state may run. - ReadOnly, - /// Writes ask for approval. - Supervised, - /// Writes run unprompted. - Full, -} - -/// A fake host policy. -#[derive(Debug)] -pub struct TestGate { - autonomy: AutonomyLevel, - action_dir: PathBuf, - roots: Vec, - max_actions: usize, - used: AtomicUsize, -} - -impl TestGate { - /// Supervised gate whose only root is `dir`. - pub fn supervised(dir: PathBuf) -> Arc { - Self::with(dir, AutonomyLevel::Supervised, 1_000_000) - } - - /// Gate over `dir` with an explicit tier and action budget. - pub fn with(dir: PathBuf, autonomy: AutonomyLevel, max_actions: usize) -> Arc { - Arc::new(Self { - autonomy, - roots: vec![dir.clone()], - action_dir: dir, - max_actions, - used: AtomicUsize::new(0), - }) - } - - /// Supervised gate resolving relative paths against `action_dir`, with - /// `extra_roots` also readable and writable. - pub fn split(action_dir: PathBuf, extra_roots: Vec) -> Arc { - let mut roots = vec![action_dir.clone()]; - roots.extend(extra_roots); - Arc::new(Self { - autonomy: AutonomyLevel::Supervised, - roots, - action_dir, - max_actions: 1_000_000, - used: AtomicUsize::new(0), - }) - } - - fn full_path(&self, path: &str) -> PathBuf { - if Path::new(path).is_absolute() { - PathBuf::from(path) - } else { - self.action_dir.join(path) - } - } - - /// Canonicalize the deepest existing ancestor of `full` and test it - /// against the roots, so a symlink out of a root is caught. - fn resolved_inside_roots(&self, full: &Path) -> bool { - let mut existing = full.to_path_buf(); - while !existing.exists() { - match existing.parent() { - Some(parent) => existing = parent.to_path_buf(), - None => return false, - } - } - let Ok(canonical) = existing.canonicalize() else { - return false; - }; - self.roots.iter().any(|root| { - root.canonicalize() - .is_ok_and(|root| canonical.starts_with(root)) - }) - } - - fn blocked(path: &str) -> String { - format!( - "{POLICY_BLOCKED_MARKER} Path not allowed by security policy: {path}. Do not retry \ - this path; use an allowed location (the workspace or a granted folder)." - ) - } -} - -#[async_trait] -impl FsGate for TestGate { - fn can_act(&self) -> bool { - self.autonomy != AutonomyLevel::ReadOnly - } - - fn is_read_only(&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 write_needs_approval(&self) -> bool { - self.autonomy == AutonomyLevel::Supervised - } - - fn action_dir(&self) -> &Path { - &self.action_dir - } - - fn is_path_string_allowed(&self, path: &str) -> bool { - if path.contains('\0') { - return false; - } - let candidate = Path::new(path); - if candidate - .components() - .any(|component| component == Component::ParentDir) - { - return false; - } - self.resolved_inside_roots(&self.full_path(path)) - } - - async fn validate_path(&self, path: &str) -> Result { - if !self.is_path_string_allowed(path) { - return Err(Self::blocked(path)); - } - tokio::fs::canonicalize(self.full_path(path)) - .await - .map_err(|e| format!("Failed to resolve path '{path}': {e}")) - } - - async fn validate_parent_path(&self, path: &str) -> Result { - if !self.is_path_string_allowed(path) { - return Err(Self::blocked(path)); - } - let full = self.full_path(path); - let parent = full - .parent() - .ok_or_else(|| format!("Invalid path (no parent): {path}"))?; - let file_name = full - .file_name() - .ok_or_else(|| format!("Invalid path (no filename): {path}"))?; - let mut existing = parent.to_path_buf(); - while !existing.exists() { - match existing.parent() { - Some(next) => existing = next.to_path_buf(), - None => break, - } - } - let canonical = tokio::fs::canonicalize(&existing) - .await - .map_err(|e| format!("Failed to resolve parent of '{path}': {e}"))?; - let suffix = parent.strip_prefix(&existing).unwrap_or(Path::new("")); - Ok(canonical.join(suffix).join(file_name)) - } - - fn scoped_to_workspace(&self, root: &Path) -> Arc { - let mut roots = self.roots.clone(); - roots.push(root.to_path_buf()); - Arc::new(Self { - autonomy: self.autonomy, - action_dir: root.to_path_buf(), - roots, - max_actions: self.max_actions, - used: AtomicUsize::new(self.used.load(Ordering::SeqCst)), - }) - } -} - -/// A run context that carries an isolated workspace, as a harness threads one -/// into a worktree-isolated worker. -#[derive(Debug)] -pub struct WorkspaceContext(pub tinytools::WorkspaceDescriptor); - -impl WorkspaceContext { - /// Context whose workspace is rooted at `root`. - pub fn at(root: &Path) -> Self { - Self( - tinytools::WorkspaceDescriptor::new(root.to_path_buf()).with_policy_id("test-worktree"), - ) - } -} - -impl tinytools::ToolRunContext for WorkspaceContext { - fn workspace(&self) -> Option<&tinytools::WorkspaceDescriptor> { - Some(&self.0) - } -} - -/// A gate whose budget check passes but whose `record_action` refuses, the -/// interleaving where another call spends the last unit between the two. -#[derive(Debug)] -pub struct RacyGate(pub Arc); - -#[async_trait] -impl FsGate for RacyGate { - fn can_act(&self) -> bool { - self.0.can_act() - } - fn is_read_only(&self) -> bool { - self.0.is_read_only() - } - fn is_rate_limited(&self) -> bool { - false - } - fn record_action(&self) -> bool { - false - } - fn write_needs_approval(&self) -> bool { - self.0.write_needs_approval() - } - fn action_dir(&self) -> &Path { - self.0.action_dir() - } - fn is_path_string_allowed(&self, path: &str) -> bool { - self.0.is_path_string_allowed(path) - } - async fn validate_path(&self, path: &str) -> Result { - self.0.validate_path(path).await - } - async fn validate_parent_path(&self, path: &str) -> Result { - self.0.validate_parent_path(path).await - } - fn scoped_to_workspace(&self, root: &Path) -> Arc { - self.0.scoped_to_workspace(root) - } -} - -/// A [`TestGate`] wrapper that can force conditions the plain fake cannot -/// reach: a budget that reports "not limited" yet refuses to record, a path -/// resolver pinned to one answer, or an action directory that does not exist. -#[derive(Debug)] -pub struct WrapGate { - inner: Arc, - no_budget: bool, - fixed_path: Option, - action_dir: Option, - batch_calls: Arc, - wrong_length_batch: bool, - deny_suffix: Option<&'static str>, -} - -impl WrapGate { - /// Wrap `inner` without overriding anything yet. - pub fn new(inner: Arc) -> Self { - Self { - inner, - no_budget: false, - fixed_path: None, - action_dir: None, - batch_calls: Arc::new(AtomicUsize::new(0)), - wrong_length_batch: false, - deny_suffix: None, - } - } - - /// Refuse any path ending in `suffix`, as a host policy denies `.env`. - #[must_use] - pub fn deny_suffix(mut self, suffix: &'static str) -> Self { - self.deny_suffix = Some(suffix); - self - } - - fn denied(&self, path: &str) -> bool { - self.deny_suffix.is_some_and(|s| path.ends_with(s)) - } - - /// Answer every batch with one verdict too few. - #[must_use] - pub fn wrong_length_batch(mut self) -> Self { - self.wrong_length_batch = true; - self - } - - /// Counter of `paths_string_allowed` calls this gate has received. - pub fn batch_calls(&self) -> Arc { - Arc::clone(&self.batch_calls) - } - - /// `is_rate_limited` stays `false` but `record_action` returns `false`. - #[must_use] - pub fn no_budget(mut self) -> Self { - self.no_budget = true; - self - } - - /// Both validators answer `Ok(path)` regardless of input. - #[must_use] - pub fn fixed_path(mut self, path: PathBuf) -> Self { - self.fixed_path = Some(path); - self - } - - /// Report `dir` as the action directory. - #[must_use] - pub fn action_dir_override(mut self, dir: PathBuf) -> Self { - self.action_dir = Some(dir); - self - } - - /// Erase into the trait object the tools take. - pub fn arc(self) -> Arc { - Arc::new(self) - } -} - -#[async_trait] -impl FsGate for WrapGate { - fn can_act(&self) -> bool { - self.inner.can_act() - } - - fn is_read_only(&self) -> bool { - self.inner.is_read_only() - } - - fn is_rate_limited(&self) -> bool { - !self.no_budget && self.inner.is_rate_limited() - } - - fn record_action(&self) -> bool { - !self.no_budget && self.inner.record_action() - } - - fn write_needs_approval(&self) -> bool { - self.inner.write_needs_approval() - } - - fn action_dir(&self) -> &Path { - self.action_dir - .as_deref() - .unwrap_or_else(|| self.inner.action_dir()) - } - - fn is_path_string_allowed(&self, path: &str) -> bool { - !self.denied(path) && self.inner.is_path_string_allowed(path) - } - - fn paths_string_allowed(&self, paths: &[String]) -> Vec { - self.batch_calls.fetch_add(1, Ordering::SeqCst); - let mut answers: Vec = paths - .iter() - .map(|path| !self.denied(path) && self.inner.is_path_string_allowed(path)) - .collect(); - if self.wrong_length_batch { - answers.pop(); - } - answers - } - - async fn validate_path(&self, path: &str) -> Result { - match &self.fixed_path { - Some(fixed) => Ok(fixed.clone()), - None => self.inner.validate_path(path).await, - } - } - - async fn validate_parent_path(&self, path: &str) -> Result { - match &self.fixed_path { - Some(fixed) => Ok(fixed.clone()), - None => self.inner.validate_parent_path(path).await, - } - } - - fn scoped_to_workspace(&self, root: &Path) -> Arc { - self.inner.scoped_to_workspace(root) - } -} diff --git a/crates/tinytools-std/src/filesystem/text.rs b/crates/tinytools-std/src/filesystem/text.rs deleted file mode 100644 index c8a84c78..00000000 --- a/crates/tinytools-std/src/filesystem/text.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Small text helpers shared by the filesystem tools. - -/// Truncate `s` to at most `max_bytes` bytes, appending a single-character -/// ellipsis `…` (3 bytes) if truncated. The returned string's total byte -/// length never exceeds `max_bytes`. -pub(super) fn truncate_at_byte_boundary(s: &str, max_bytes: usize) -> String { - if s.len() <= max_bytes { - return s.to_string(); - } - let ellipsis = "…"; - let ellipsis_len = ellipsis.len(); - if max_bytes < ellipsis_len { - return String::new(); - } - let mut end = max_bytes - ellipsis_len; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } - format!("{}{}", &s[..end], ellipsis) -} - -#[cfg(test)] -#[path = "text_test_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/text_test_tests.rs b/crates/tinytools-std/src/filesystem/text_test_tests.rs deleted file mode 100644 index abd85f2f..00000000 --- a/crates/tinytools-std/src/filesystem/text_test_tests.rs +++ /dev/null @@ -1,25 +0,0 @@ -use super::truncate_at_byte_boundary; - -#[test] -fn short_strings_pass_through() { - assert_eq!(truncate_at_byte_boundary("abc", 3), "abc"); -} - -#[test] -fn long_strings_end_in_an_ellipsis_within_budget() { - let out = truncate_at_byte_boundary("abcdefghij", 6); - assert_eq!(out, "abc…"); - assert!(out.len() <= 6); -} - -#[test] -fn never_splits_a_multibyte_character() { - // 'é' is two bytes; a budget landing mid-character backs up. - let out = truncate_at_byte_boundary("ééééé", 6); - assert_eq!(out, "é…"); -} - -#[test] -fn a_budget_smaller_than_the_ellipsis_yields_nothing() { - assert_eq!(truncate_at_byte_boundary("abcdef", 2), ""); -} diff --git a/crates/tinytools-std/src/filesystem/update_memory_md/mod.rs b/crates/tinytools-std/src/filesystem/update_memory_md/mod.rs deleted file mode 100644 index 072cda4f..00000000 --- a/crates/tinytools-std/src/filesystem/update_memory_md/mod.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! Tool: `update_memory_md` — append or update sections in MEMORY.md or SKILL.md. - -use async_trait::async_trait; -use serde_json::json; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, LazyLock, Mutex}; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; - -/// Allowed workspace markdown files this tool may modify. -const ALLOWED_FILES: &[&str] = &["MEMORY.md", "SKILL.md"]; - -/// Process-global registry of per-workspace write locks (#4458). -/// -/// `update_memory_md` performs a read-modify-write on `MEMORY.md`/`SKILL.md`. -/// The per-run `MemoryProtocolTracker` has zero cross-run awareness, so two -/// concurrent runs (parallel forks, cron) racing the same workspace file would -/// otherwise clobber each other's append. We serialize every write to a given -/// workspace directory through a shared async mutex, keyed by the canonicalized -/// (falling back to raw) workspace path, so concurrent index updates queue -/// instead of racing. Combined with the temp-file + atomic-rename write below, -/// a killed process can never leave a truncated file. -static WORKSPACE_WRITE_LOCKS: LazyLock>>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -/// Return (creating if needed) the shared async write lock for `workspace_dir`. -/// -/// The lock key is the canonicalized workspace path when it resolves (so two -/// spellings of the same directory share one lock), else the raw path. -fn workspace_write_lock(workspace_dir: &Path) -> Arc> { - let key = workspace_dir - .canonicalize() - .unwrap_or_else(|_| workspace_dir.to_path_buf()); - let mut map = WORKSPACE_WRITE_LOCKS - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - Arc::clone( - map.entry(key) - .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))), - ) -} - -/// Acquire an **inter-process** advisory write lock for `workspace_dir` (#4458). -/// -/// [`WORKSPACE_WRITE_LOCKS`] only serializes writers inside this OS process, but -/// cron launches work via separate `tokio::process::Command` subprocesses that -/// don't share that mutex — so two cron runs could still clobber the same -/// `MEMORY.md` mid read-modify-write. This takes an `fs2` exclusive `flock` on a -/// sentinel `.memory-write.lock` file in the workspace; the returned `File` -/// holds the lock until it is dropped (end of the write). `flock` acquisition -/// blocks, so it runs on a blocking thread. -async fn acquire_cross_process_write_lock(workspace_dir: &Path) -> anyhow::Result { - let lock_path = workspace_dir.join(".memory-write.lock"); - tokio::task::spawn_blocking(move || { - use fs2::FileExt; - if let Some(parent) = lock_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - // Reject a symlinked lock file: a project-controlled `.memory-write.lock` - // symlink (including a dangling one) could otherwise redirect this - // create/open/lock to a path OUTSIDE the already-containment-checked - // workspace, bypassing the symlink hardening applied to MEMORY.md / - // SKILL.md. If it exists it must be a regular file. - if let Ok(meta) = std::fs::symlink_metadata(&lock_path) - && meta.file_type().is_symlink() - { - return Err(anyhow::anyhow!( - "workspace lock file {lock_path:?} is a symlink; refusing to follow it" - )); - } - let mut opts = std::fs::OpenOptions::new(); - opts.create(true).write(true).truncate(false); - #[cfg(unix)] - { - // O_NOFOLLOW closes the TOCTOU window: if a symlink is swapped in - // after the check above, the open fails (ELOOP) rather than follows. - use std::os::unix::fs::OpenOptionsExt; - opts.custom_flags(libc::O_NOFOLLOW); - } - let file = opts - .open(&lock_path) - .map_err(|e| anyhow::anyhow!("open workspace lock file {lock_path:?}: {e}"))?; - file.lock_exclusive() - .map_err(|e| anyhow::anyhow!("acquire workspace write flock: {e}"))?; - Ok::(file) - }) - .await - .map_err(|e| anyhow::anyhow!("workspace lock task join failed: {e}"))? -} - -/// [`super::file_sink::atomic_write`], with the error naming `file`. -/// Callers MUST hold the per-workspace write lock so the read-modify-write is -/// serialized end-to-end. -async fn atomic_write(path: &Path, file: &str, content: &str) -> anyhow::Result<()> { - super::file_sink::atomic_write(path, content.as_bytes()) - .await - .map_err(|e| anyhow::anyhow!("Failed to atomically write {file}: {e}")) -} - -/// Appends or replaces a named section in MEMORY.md or SKILL.md. -/// -/// Supports two actions: -/// - `append`: adds `content` to the end of the file. -/// - `replace_section`: locates the first `## {section_title}` heading and -/// replaces the body (lines until the next `##` heading or EOF) with `content`. -#[derive(Debug)] -pub struct UpdateMemoryMdTool { - workspace_dir: PathBuf, -} - -impl UpdateMemoryMdTool { - #[must_use] - /// Create the tool. - pub fn new(workspace_dir: PathBuf) -> Self { - Self { workspace_dir } - } - - fn workspace_dir_for_context(&self, context: Option<&dyn ToolRunContext>) -> PathBuf { - if let Some(workspace) = context.and_then(|ctx| ctx.workspace()) { - tracing::debug!( - workspace_root = %workspace.root.display(), - policy_id = %workspace.policy_id, - "[update_memory_md] using TinyAgents workspace descriptor as workspace dir" - ); - return workspace.root.clone(); - } - self.workspace_dir.clone() - } -} - -#[async_trait] -impl Tool for UpdateMemoryMdTool { - fn name(&self) -> &'static str { - "update_memory_md" - } - - fn description(&self) -> &'static str { - "Append or update sections in MEMORY.md or SKILL.md workspace files. \ - Use 'append' to add new notes at the end, or 'replace_section' to \ - overwrite the body under a named '## Section' heading." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "required": ["file", "action", "content"], - "properties": { - "file": { - "type": "string", - "enum": ["MEMORY.md", "SKILL.md"], - "description": "Which workspace markdown file to modify." - }, - "action": { - "type": "string", - "enum": ["append", "replace_section"], - "description": "'append' adds content at the end; \ - 'replace_section' replaces the body of the named section." - }, - "section_title": { - "type": "string", - "description": "Required for 'replace_section': the heading text (without '## ')." - }, - "content": { - "type": "string", - "description": "The markdown text to write." - } - } - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_with_context(args, ToolCallOptions::default(), None) - .await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let workspace_dir = self.workspace_dir_for_context(context); - let file = args - .get("file") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'file' parameter"))?; - - let action = args - .get("action") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?; - - let content = args - .get("content") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?; - - // Guard: only allow MEMORY.md and SKILL.md. - if !ALLOWED_FILES.contains(&file) { - return Ok(ToolResult::error(format!( - "File '{file}' is not allowed. Permitted files: MEMORY.md, SKILL.md" - ))); - } - - let target_path = workspace_dir.join(file); - - // Prevent symlink-based workspace escape. - let workspace_canon = self - .workspace_dir_for_context(context) - .canonicalize() - .map_err(|e| anyhow::anyhow!("Failed to canonicalize workspace: {e}"))?; - // Check parent dir exists and canonicalize to detect symlinks. - let parent = target_path.parent().unwrap_or(&workspace_dir); - let parent_canon = parent - .canonicalize() - .unwrap_or_else(|_| parent.to_path_buf()); - if !parent_canon.starts_with(&workspace_canon) { - return Ok(ToolResult::error(format!( - "File path '{file}' resolves outside workspace" - ))); - } - - tracing::debug!("[update_memory_md] action={action} file={file} path={target_path:?}"); - - // #4458: serialize the whole read-modify-write against concurrent runs - // targeting the same workspace. The guard is held across read + atomic - // write so no interleaving append can be lost. - let lock = workspace_write_lock(&workspace_dir); - let _guard = lock.lock().await; - // Also take a cross-process advisory lock so cron subprocesses (which - // don't share the in-process mutex above) can't clobber the same file - // mid-RMW. Held across read + atomic write; released on drop. - let _file_lock = acquire_cross_process_write_lock(&workspace_dir).await?; - tracing::debug!( - workspace = %workspace_dir.display(), - "[update_memory_md] acquired per-workspace write lock (in-process + cross-process flock)" - ); - - match action { - "append" => self.do_append(&target_path, file, content).await, - "replace_section" => { - let section_title = args - .get("section_title") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - anyhow::anyhow!("'section_title' is required for 'replace_section' action") - })?; - self.do_replace_section(&target_path, file, section_title, content) - .await - } - other => Ok(ToolResult::error(format!( - "Unknown action '{other}'. Use 'append' or 'replace_section'." - ))), - } - } -} - -impl UpdateMemoryMdTool { - /// Append `content` to the end of `path`, creating the file if it does not exist. - async fn do_append( - &self, - path: &std::path::Path, - file: &str, - content: &str, - ) -> anyhow::Result { - // Read existing content (empty string if file not found). - let existing = read_or_empty(path).await?; - - let separator = if existing.is_empty() || existing.ends_with('\n') { - "" - } else { - "\n" - }; - let new_content = format!("{existing}{separator}{content}\n"); - - atomic_write(path, file, &new_content).await?; - - let bytes = new_content.len(); - tracing::info!( - "[update_memory_md] appended {} bytes to {file}", - content.len() - ); - - Ok(ToolResult::success(format!( - "Appended {} bytes to {file} ({bytes} bytes total).", - content.len() - ))) - } - - /// Replace the body of the section headed `## {section_title}` in `path`. - /// - /// If the section is not found it is appended as a new section at the end. - async fn do_replace_section( - &self, - path: &std::path::Path, - file: &str, - section_title: &str, - content: &str, - ) -> anyhow::Result { - let existing = read_or_empty(path).await?; - let heading = format!("## {section_title}"); - - let lines: Vec<&str> = existing.lines().collect(); - let section_start = lines.iter().position(|l| l.trim() == heading.as_str()); - - let new_file_content = if let Some(start_idx) = section_start { - // Find where the next ## heading begins (or end of file). - let body_start = start_idx + 1; - let next_heading = lines[body_start..] - .iter() - .position(|l| l.starts_with("## ")) - .map(|rel| body_start + rel); - - let before: String = lines[..=start_idx].join("\n"); - let after: String = match next_heading { - Some(end_idx) => { - let tail = lines[end_idx..].join("\n"); - format!("\n{tail}") - } - None => String::new(), - }; - - // Ensure content is separated from the heading by a blank line. - let body = if content.trim().is_empty() { - String::new() - } else { - format!("\n{content}") - }; - - format!("{before}{body}{after}\n") - } else { - // Section not found — append it. - tracing::debug!( - "[update_memory_md] section '{section_title}' not found in {file}, appending" - ); - let separator = if existing.is_empty() || existing.ends_with('\n') { - "" - } else { - "\n" - }; - format!("{existing}{separator}{heading}\n{content}\n") - }; - - atomic_write(path, file, &new_file_content).await?; - - tracing::info!( - "[update_memory_md] replaced section '{}' in {file} ({} bytes written)", - section_title, - new_file_content.len() - ); - - Ok(ToolResult::success(format!( - "Section '{}' updated in {file} ({} bytes).", - section_title, - new_file_content.len() - ))) - } -} - -/// Read file to string, returning an empty string when the file does not exist. -async fn read_or_empty(path: &std::path::Path) -> anyhow::Result { - match tokio::fs::read_to_string(path).await { - Ok(s) => Ok(s), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), - Err(e) => Err(anyhow::anyhow!("Failed to read {}: {e}", path.display())), - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/update_memory_md/mod_tests.rs b/crates/tinytools-std/src/filesystem/update_memory_md/mod_tests.rs deleted file mode 100644 index 509e690a..00000000 --- a/crates/tinytools-std/src/filesystem/update_memory_md/mod_tests.rs +++ /dev/null @@ -1,357 +0,0 @@ -#![allow( - clippy::expect_used, - clippy::panic, - clippy::unwrap_used, - clippy::items_after_statements, - clippy::single_match_else, - clippy::single_match -)] - -use super::*; - -fn make_tool(dir: &std::path::Path) -> UpdateMemoryMdTool { - UpdateMemoryMdTool::new(dir.to_path_buf()) -} - -#[tokio::test] -async fn append_creates_file_if_missing() { - let dir = tempfile::tempdir().unwrap(); - let tool = make_tool(dir.path()); - let result = tool - .execute(json!({ - "file": "MEMORY.md", - "action": "append", - "content": "first note" - })) - .await - .unwrap(); - assert!(!result.is_error, "{:?}", result.output()); - let text = std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(); - assert!(text.contains("first note")); -} - -#[tokio::test] -async fn append_adds_to_existing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("MEMORY.md"); - std::fs::write(&path, "existing\n").unwrap(); - let tool = make_tool(dir.path()); - tool.execute(json!({ - "file": "MEMORY.md", - "action": "append", - "content": "second note" - })) - .await - .unwrap(); - let text = std::fs::read_to_string(&path).unwrap(); - assert!(text.contains("existing")); - assert!(text.contains("second note")); -} - -#[tokio::test] -async fn replace_section_overwrites_body() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("MEMORY.md"); - std::fs::write(&path, "## Lessons\nold body\n## Other\nkept\n").unwrap(); - let tool = make_tool(dir.path()); - tool.execute(json!({ - "file": "MEMORY.md", - "action": "replace_section", - "section_title": "Lessons", - "content": "new body" - })) - .await - .unwrap(); - let text = std::fs::read_to_string(&path).unwrap(); - assert!(text.contains("new body"), "new body missing: {text}"); - assert!( - !text.contains("old body"), - "old body should be gone: {text}" - ); - assert!(text.contains("## Other"), "other section missing: {text}"); - assert!(text.contains("kept"), "other section body missing: {text}"); -} - -#[tokio::test] -async fn replace_section_appends_when_not_found() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("SKILL.md"); - std::fs::write(&path, "# Header\n").unwrap(); - let tool = make_tool(dir.path()); - tool.execute(json!({ - "file": "SKILL.md", - "action": "replace_section", - "section_title": "New Section", - "content": "brand new" - })) - .await - .unwrap(); - let text = std::fs::read_to_string(&path).unwrap(); - assert!(text.contains("## New Section"), "heading missing: {text}"); - assert!(text.contains("brand new"), "content missing: {text}"); -} - -#[tokio::test] -async fn replace_section_with_empty_content() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("MEMORY.md"); - std::fs::write(&path, "## Notes\nold stuff\n## End\ndone\n").unwrap(); - let tool = make_tool(dir.path()); - tool.execute(json!({ - "file": "MEMORY.md", - "action": "replace_section", - "section_title": "Notes", - "content": "" - })) - .await - .unwrap(); - let text = std::fs::read_to_string(&path).unwrap(); - assert!( - !text.contains("old stuff"), - "old body should be gone: {text}" - ); - assert!(text.contains("## End"), "other section missing: {text}"); -} - -#[tokio::test] -async fn append_to_empty_memory_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("MEMORY.md"); - std::fs::write(&path, "").unwrap(); - let tool = make_tool(dir.path()); - let result = tool - .execute(json!({ - "file": "MEMORY.md", - "action": "append", - "content": "first line" - })) - .await - .unwrap(); - assert!(!result.is_error, "unexpected error: {}", result.output()); - let text = std::fs::read_to_string(&path).unwrap(); - assert!(text.contains("first line")); -} - -#[tokio::test] -async fn replace_section_creates_memory_file_if_missing() { - let dir = tempfile::tempdir().unwrap(); - let tool = make_tool(dir.path()); - let result = tool - .execute(json!({ - "file": "MEMORY.md", - "action": "replace_section", - "section_title": "First", - "content": "hello" - })) - .await - .unwrap(); - assert!(!result.is_error, "unexpected error: {}", result.output()); - let text = std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(); - assert!(text.contains("## First")); - assert!(text.contains("hello")); -} - -#[tokio::test] -async fn rejects_unknown_action() { - let dir = tempfile::tempdir().unwrap(); - let tool = make_tool(dir.path()); - let result = tool - .execute(json!({ - "file": "MEMORY.md", - "action": "delete_all", - "content": "x" - })) - .await - .unwrap(); - assert!(result.is_error); -} - -#[tokio::test] -async fn replace_section_missing_section_title_errors() { - let dir = tempfile::tempdir().unwrap(); - let tool = make_tool(dir.path()); - let result = tool - .execute(json!({ - "file": "MEMORY.md", - "action": "replace_section", - "content": "x" - })) - .await; - // May return Err or Ok with is_error - match result { - Ok(r) => assert!(r.is_error), - Err(_) => {} // also acceptable - } -} - -#[test] -fn tool_name_and_description() { - let dir = tempfile::tempdir().unwrap(); - let tool = make_tool(dir.path()); - assert_eq!(tool.name(), "update_memory_md"); - assert_ne!(tool.description().len(), 0); -} - -#[test] -fn parameters_schema_has_required_fields() { - let dir = tempfile::tempdir().unwrap(); - let tool = make_tool(dir.path()); - let schema = tool.parameters_schema(); - let required = schema["required"].as_array().unwrap(); - assert!(required.contains(&json!("file"))); - assert!(required.contains(&json!("action"))); -} - -#[tokio::test] -async fn rejects_disallowed_file() { - let dir = tempfile::tempdir().unwrap(); - let tool = make_tool(dir.path()); - let result = tool - .execute(json!({ - "file": "../../etc/passwd", - "action": "append", - "content": "evil" - })) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("not allowed")); -} - -#[tokio::test] -async fn missing_parameters_are_errors() { - let dir = tempfile::tempdir().unwrap(); - let tool = make_tool(dir.path()); - for (args, needle) in [ - (json!({"action": "append", "content": "x"}), "'file'"), - (json!({"file": "MEMORY.md", "content": "x"}), "'action'"), - ( - json!({"file": "MEMORY.md", "action": "append"}), - "'content'", - ), - ] { - let err = tool.execute(args).await.unwrap_err(); - assert!(err.to_string().contains(needle), "{err}"); - } -} - -#[tokio::test] -async fn a_missing_workspace_fails_to_canonicalize() { - let dir = tempfile::tempdir().unwrap(); - let tool = make_tool(&dir.path().join("does-not-exist")); - let err = tool - .execute(json!({"file": "MEMORY.md", "action": "append", "content": "x"})) - .await - .unwrap_err(); - assert!(err.to_string().contains("Failed to canonicalize workspace")); -} - -#[tokio::test] -async fn an_unreadable_target_is_reported() { - let dir = tempfile::tempdir().unwrap(); - // A directory named MEMORY.md cannot be read as text (and is not NotFound). - std::fs::create_dir(dir.path().join("MEMORY.md")).unwrap(); - let err = make_tool(dir.path()) - .execute(json!({"file": "MEMORY.md", "action": "append", "content": "x"})) - .await - .unwrap_err(); - assert!(err.to_string().contains("Failed to read"), "{err}"); -} - -#[tokio::test] -async fn atomic_write_reports_staging_and_rename_failures() { - let dir = tempfile::tempdir().unwrap(); - let missing_dir_target = dir.path().join("nope").join("MEMORY.md"); - let err = atomic_write(&missing_dir_target, "MEMORY.md", "x") - .await - .unwrap_err(); - assert!(err.to_string().contains("stage temp file"), "{err}"); - - // Renaming a file over a directory fails after the temp file was staged. - let dir_target = dir.path().join("MEMORY.md"); - std::fs::create_dir(&dir_target).unwrap(); - let err = atomic_write(&dir_target, "MEMORY.md", "x") - .await - .unwrap_err(); - assert!(err.to_string().contains("Failed to atomically write")); - let leftovers: Vec<_> = std::fs::read_dir(dir.path()) - .unwrap() - .filter_map(Result::ok) - .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp")) - .collect(); - assert!(leftovers.is_empty()); -} - -#[cfg(unix)] -#[tokio::test] -async fn a_symlinked_lock_file_is_refused() { - let dir = tempfile::tempdir().unwrap(); - let outside = tempfile::tempdir().unwrap(); - std::os::unix::fs::symlink( - outside.path().join("target"), - dir.path().join(".memory-write.lock"), - ) - .unwrap(); - let err = make_tool(dir.path()) - .execute(json!({"file": "MEMORY.md", "action": "append", "content": "x"})) - .await - .unwrap_err(); - assert!(err.to_string().contains("is a symlink"), "{err}"); -} - -#[tokio::test] -async fn a_workspace_descriptor_overrides_the_configured_directory() { - use crate::filesystem::test_support::WorkspaceContext; - let configured = tempfile::tempdir().unwrap(); - let isolated = tempfile::tempdir().unwrap(); - let context = WorkspaceContext::at(isolated.path()); - let result = make_tool(configured.path()) - .execute_with_context( - json!({"file": "MEMORY.md", "action": "append", "content": "hi"}), - ToolCallOptions::default(), - Some(&context), - ) - .await - .unwrap(); - assert!(!result.is_error); - assert!(isolated.path().join("MEMORY.md").exists()); - assert!(!configured.path().join("MEMORY.md").exists()); -} - -#[tokio::test] -async fn append_separates_from_content_lacking_a_trailing_newline() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("MEMORY.md"), "no newline").unwrap(); - make_tool(dir.path()) - .execute(json!({"file": "MEMORY.md", "action": "append", "content": "next"})) - .await - .unwrap(); - assert_eq!( - std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(), - "no newline\nnext\n" - ); -} - -#[tokio::test] -async fn replace_section_before_another_heading_keeps_the_tail() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("MEMORY.md"), "## A\nold\n## B\nkeep\n").unwrap(); - make_tool(dir.path()) - .execute(json!({"file": "MEMORY.md", "action": "replace_section", "section_title": "A", "content": "new"})) - .await - .unwrap(); - let text = std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(); - assert!(text.contains("## A\nnew\n## B\nkeep"), "{text}"); -} - -#[tokio::test] -async fn replace_section_appends_after_a_file_without_trailing_newline() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("MEMORY.md"), "intro").unwrap(); - make_tool(dir.path()) - .execute(json!({"file": "MEMORY.md", "action": "replace_section", "section_title": "New", "content": "body"})) - .await - .unwrap(); - let text = std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(); - assert_eq!(text, "intro\n## New\nbody\n"); -} diff --git a/crates/tinytools-std/src/filesystem/workspace_state/mod.rs b/crates/tinytools-std/src/filesystem/workspace_state/mod.rs deleted file mode 100644 index 7af822a0..00000000 --- a/crates/tinytools-std/src/filesystem/workspace_state/mod.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! 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)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/workspace_state/mod_tests.rs b/crates/tinytools-std/src/filesystem/workspace_state/mod_tests.rs deleted file mode 100644 index a815bf74..00000000 --- a/crates/tinytools-std/src/filesystem/workspace_state/mod_tests.rs +++ /dev/null @@ -1,289 +0,0 @@ -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_ne!(make_tool(&tmp).description().len(), 0); -} - -#[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 deleted file mode 100644 index 8a7a344e..00000000 --- a/crates/tinytools-std/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Host-independent building blocks for agent tools. -//! -//! `tinytools` is the vocabulary a tool is written against and deliberately -//! carries no behavior. This crate holds the small, reusable mechanisms that -//! several hosts' tools share, none of which encodes a host's policy: -//! -//! - [`file_state`] — cross-agent read/write stamps and per-path locks, so a -//! 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. -//! - [`timeout`] — tool timeout bounds, process-group kill and child reaping. -//! -//! # Example -//! -//! Probe `PATH` directly, or hand the host the read-only tool that does the -//! same for a model: -//! -//! ``` -//! use tinytools::{PermissionLevel, Tool}; -//! use tinytools_std::detect_tools::{DetectToolsTool, find_on_path}; -//! -//! // A missing binary is `None`, never an error. -//! assert_eq!(find_on_path("definitely-not-a-real-binary-7f3a"), None); -//! -//! let tool = DetectToolsTool::new(); -//! assert_eq!(tool.name(), "detect_tools"); -//! assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); -//! ``` - -pub mod detect_tools; -pub mod file_state; -pub mod filesystem; -pub mod network; -pub mod timeout; -pub mod url_guard; diff --git a/crates/tinytools-std/src/network/contract_tests.rs b/crates/tinytools-std/src/network/contract_tests.rs deleted file mode 100644 index 33ac31f0..00000000 --- a/crates/tinytools-std/src/network/contract_tests.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! 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 deleted file mode 100644 index 6561084f..00000000 --- a/crates/tinytools-std/src/network/curl.rs +++ /dev/null @@ -1,476 +0,0 @@ -//! `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::{ValidatedUrl, normalize_allowed_domains}; - -use super::gate::validate_destination; -use async_trait::async_trait; -use futures_util::StreamExt; -use sha2::{Digest, Sha256}; -use std::fmt::Write as _; -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"); - } - - // Writes follow symlinks, so refuse any symlinked component below the - // workspace (dest_subdir, then dest). The walk stops at the first - // component that does not exist yet: create_dir_all makes real dirs. - // ponytail: check-then-create/rename window; closing it fully needs - // openat/O_NOFOLLOW per component. - let mut cur = self.workspace_dir.clone(); - for component in Path::new(&self.dest_subdir) - .components() - .chain(p.components()) - { - cur.push(component); - match std::fs::symlink_metadata(&cur) { - Ok(meta) if meta.file_type().is_symlink() => { - anyhow::bail!("dest_path may not pass through a symlink"); - } - Ok(_) => {} - Err(_) => break, - } - } - - Ok(resolved) - } - - async fn validate_url( - &self, - raw_url: &str, - headers: &[(String, String)], - ) -> anyhow::Result { - validate_destination( - &*self.gate, - raw_url, - &self.allowed_domains, - "GET", - headers, - None, - ) - .await - } - - 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 request_headers: Vec<(String, String)> = headers_val - .as_object() - .map(|obj| { - obj.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect() - }) - .unwrap_or_default(); - let target = match self.validate_url(url, &request_headers).await { - Ok(v) => v, - Err(e) => { - tracing::debug!(target: "[curl]", url = %url, reason = %e, "url validation failed"); - return Ok(ToolResult::error(e.to_string())); - } - }; - - let url = target.url.clone(); - - // 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); - // Connect only to the vetted addresses; the URL keeps its host name. - let builder = builder.resolve_to_addrs(&target.host, &target.addrs); - 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(); - - // Stream into a fresh temp file beside the target and rename it over - // the target on success: a failed download leaves any existing file - // intact, and rename replaces a final symlink instead of following it. - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_nanos()); - let file_name = dest_path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - let tmp_path = - dest_path.with_file_name(format!(".{file_name}.{}.{nonce}.part", std::process::id())); - let mut file = match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&tmp_path) - .await - { - Ok(f) => f, - Err(e) => { - tracing::error!(target: "[curl]", dest = %dest_path.display(), reason = %e, "temp 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(&tmp_path).await { - tracing::debug!(target: "[curl]", tmp = %tmp_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(&tmp_path).await { - tracing::debug!(target: "[curl]", tmp = %tmp_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(&tmp_path).await { - tracing::debug!(target: "[curl]", tmp = %tmp_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(&tmp_path).await { - tracing::debug!(target: "[curl]", tmp = %tmp_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}"))); - } - drop(file); - if let Err(e) = fs::rename(&tmp_path, &dest_path).await { - if let Err(rm) = fs::remove_file(&tmp_path).await { - tracing::debug!(target: "[curl]", tmp = %tmp_path.display(), reason = %rm, "cleanup remove_file failed"); - } - tracing::error!(target: "[curl]", dest = %dest_path.display(), reason = %e, "rename failed"); - return Ok(ToolResult::error(format!( - "Failed to move download into place: {e}" - ))); - } - - let mut sha256 = String::with_capacity(64); - for byte in hasher.finalize() { - let _ = write!(sha256, "{byte:02x}"); - } - - 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_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/network/curl_tests.rs b/crates/tinytools-std/src/network/curl_tests.rs deleted file mode 100644 index ee4ec286..00000000 --- a/crates/tinytools-std/src/network/curl_tests.rs +++ /dev/null @@ -1,340 +0,0 @@ -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)]); -} - -/// A symlinked component under the downloads root would let a download land -/// outside the workspace, so the destination is refused, both at the link and -/// beneath it. -#[cfg(unix)] -#[test] -fn resolve_dest_refuses_a_symlinked_component() { - let tmp = TempDir::new().unwrap(); - let outside = TempDir::new().unwrap(); - std::fs::create_dir(tmp.path().join("downloads")).unwrap(); - std::os::unix::fs::symlink(outside.path(), tmp.path().join("downloads/out")).unwrap(); - let t = tool(&tmp, vec![]); - assert!(t.resolve_dest("out").is_err()); - assert!(t.resolve_dest("out/f").is_err()); - assert!(t.resolve_dest("ok/f").is_ok()); -} diff --git a/crates/tinytools-std/src/network/fixtures/curl.json b/crates/tinytools-std/src/network/fixtures/curl.json deleted file mode 100644 index 0cfc5856..00000000 --- a/crates/tinytools-std/src/network/fixtures/curl.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 deleted file mode 100644 index c09b9e97..00000000 --- a/crates/tinytools-std/src/network/fixtures/http_request.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 deleted file mode 100644 index 7cea38c0..00000000 --- a/crates/tinytools-std/src/network/fixtures/pushover.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index a715af69..00000000 --- a/crates/tinytools-std/src/network/fixtures/web_fetch.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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": "Cap the returned text at this many bytes (default 1_000_000). Bounds the OUTPUT — the extracted markdown, or the raw body with raw:true — never the markup the extractor reads, so lowering it cannot cost you content the page actually had.", - "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 deleted file mode 100644 index 696148a1..00000000 --- a/crates/tinytools-std/src/network/gate.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! 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. - -/// What these tools call themselves on the wire. -/// -/// reqwest sends no `User-Agent` unless told to, and a missing one is not a -/// cosmetic omission. GitHub's REST API refuses the request outright: -/// -/// ```text -/// 403 Request forbidden by administrative rules. -/// Please make sure your request has a User-Agent header -/// ``` -/// -/// Reproducible on demand — the same URL in the same second answers 403 with -/// no header and 200 with one — so every `api.github.com` call through these -/// tools failed, always, and the 403 was then read as a credentials problem. -/// Several other APIs require one too, and anonymous traffic is the first a -/// rate limiter penalises. -/// -/// Identifying rather than disguised: a server that wants to throttle or block -/// this traffic should be able to name it. -pub(super) const USER_AGENT: &str = concat!("tinytools/", env!("CARGO_PKG_VERSION")); - -/// 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; - - /// Let the host authorize the destination of a request and say which socket - /// addresses the connection may use. - /// - /// Called after the lexical checks (scheme, allowlist, literal private - /// host) and before any DNS or connection. The answer decides how the tool - /// proceeds: - /// - /// * `None` (the default): the host takes no part, and the tool resolves and - /// vets the host itself ([`validate_url_with_dns_check`]). - /// * `Some(Ok(addrs))`: the host authorized the destination. The tool - /// connects only to `addrs` (no second DNS lookup) while keeping the URL's - /// host for the `Host` header, SNI and certificate checks. Addresses that - /// are private or local are refused regardless, so a host cannot widen the - /// tool beyond the public internet. - /// * `Some(Err(reason))`: the host refused; the tool returns `reason`. - /// - /// A host that already holds a decision engine for destinations (a security - /// module that resolves, applies an address floor and pins) answers here so - /// there is one URL guard, not one per tool. - /// - /// [`validate_url_with_dns_check`]: crate::url_guard::validate_url_with_dns_check - fn authorize_destination<'a>( - &'a self, - destination: &'a Destination<'a>, - ) -> DestinationFuture<'a> { - let _ = destination; - Box::pin(std::future::ready(None)) - } -} - -/// A request a tool is about to make, as the host sees it when it authorizes -/// the destination. Borrowed from the call; nothing here is retained. -#[derive(Debug, Clone, Copy)] -pub struct Destination<'a> { - /// The URL that passed the lexical checks. - pub url: &'a str, - /// Its host (a name or an IPv4 literal). - pub host: &'a str, - /// Its port (the scheme default when the URL names none). - pub port: u16, - /// HTTP method. - pub method: &'a str, - /// Caller-supplied headers. - pub headers: &'a [(String, String)], - /// Request body, if any. - pub body: Option<&'a str>, -} - -/// The host's answer to [`NetGate::authorize_destination`]; see there. -pub type DestinationFuture<'a> = std::pin::Pin< - Box< - dyn std::future::Future, String>>> - + Send - + 'a, - >, ->; - -/// Lexically validate `raw_url`, then resolve it: through the gate when the -/// host authorizes destinations, otherwise with the shared DNS check. The -/// returned addresses are the only ones the tool may connect to. -pub(super) async fn validate_destination( - gate: &dyn NetGate, - raw_url: &str, - allowed_domains: &[String], - method: &str, - headers: &[(String, String)], - body: Option<&str>, -) -> anyhow::Result { - use crate::url_guard::{ - ValidatedUrl, connect_host, extract_host, extract_port, is_private_or_local_host, - validate_url, validate_url_with_dns_check, - }; - let url = validate_url(raw_url, allowed_domains)?; - let host = extract_host(&url)?; - let port = extract_port(&url)?; - let destination = Destination { - url: &url, - host: &host, - port, - method, - headers, - body, - }; - match gate.authorize_destination(&destination).await { - None => validate_url_with_dns_check(raw_url, allowed_domains).await, - Some(Err(reason)) => anyhow::bail!("{reason}"), - Some(Ok(addrs)) => { - if addrs.is_empty() { - anyhow::bail!("the host authorized '{host}' without an address"); - } - if let Some(blocked) = addrs - .iter() - .find(|addr| is_private_or_local_host(&addr.ip().to_string())) - { - anyhow::bail!( - "Blocked local/private address {} for '{host}'", - blocked.ip() - ); - } - Ok(ValidatedUrl { - host: connect_host(&url)?, - url, - addrs, - }) - } - } -} - -/// 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()) -} - -/// Reads at most `cap + 1` bytes of `response`'s body, so a caller can tell -/// a body over the cap from one exactly at it without reading the rest. -/// -/// # Errors -/// -/// Returns the transport error if a chunk fails to arrive. -pub(super) async fn read_body_capped( - mut response: reqwest::Response, - cap: usize, -) -> reqwest::Result> { - let limit = cap.saturating_add(1); - let mut body = Vec::new(); - while body.len() < limit - && let Some(chunk) = response.chunk().await? - { - let take = chunk.len().min(limit - body.len()); - body.extend_from_slice(&chunk[..take]); - } - Ok(body) -} - -/// 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, -} - -#[cfg(test)] -#[path = "gate_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/network/gate_tests.rs b/crates/tinytools-std/src/network/gate_tests.rs deleted file mode 100644 index 1ac7a718..00000000 --- a/crates/tinytools-std/src/network/gate_tests.rs +++ /dev/null @@ -1,183 +0,0 @@ -use super::*; -use std::net::SocketAddr; -use std::sync::Mutex; - -/// What the gate was asked: url, host, port, method, headers, body. -type Asked = ( - String, - String, - u16, - String, - Vec<(String, String)>, - Option, -); - -/// A gate that answers `authorize_destination` with a canned result and keeps -/// what it was asked. -#[derive(Debug)] -struct Authorizing { - answer: Option, String>>, - seen: Mutex>, -} - -impl Authorizing { - fn new(answer: Option, String>>) -> Self { - Self { - answer, - seen: Mutex::new(Vec::new()), - } - } -} - -impl NetGate for Authorizing { - fn can_act(&self) -> bool { - true - } - fn is_rate_limited(&self) -> bool { - false - } - fn record_action(&self) -> bool { - true - } - fn network_needs_approval(&self) -> bool { - false - } - fn local_only_block(&self, _host: &str) -> Option { - None - } - fn disclose(&self, _host: &str, _has_body: bool, _has_headers: bool) {} - 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::new() - } - fn authorize_destination<'a>( - &'a self, - destination: &'a Destination<'a>, - ) -> DestinationFuture<'a> { - if let Ok(mut seen) = self.seen.lock() { - seen.push(( - destination.url.to_string(), - destination.host.to_string(), - destination.port, - destination.method.to_string(), - destination.headers.to_vec(), - destination.body.map(str::to_string), - )); - } - Box::pin(std::future::ready(self.answer.clone())) - } -} - -fn public() -> SocketAddr { - "93.184.216.34:443".parse().unwrap() -} - -#[tokio::test] -async fn a_gate_that_takes_no_part_leaves_the_tools_own_check_in_force() { - // The trait default is `None`, so the shared DNS check runs; an IP literal - // needs no lookup and is returned as the single pinned address. - let gate = Authorizing::new(None); - let target = validate_destination(&gate, "http://93.184.216.34/x", &[], "GET", &[], None) - .await - .unwrap(); - assert_eq!(target.addrs, vec!["93.184.216.34:80".parse().unwrap()]); - let blocked = validate_destination(&gate, "http://169.254.169.254/", &[], "GET", &[], None) - .await - .unwrap_err(); - assert!(blocked.to_string().contains("Blocked"), "{blocked}"); -} - -#[tokio::test] -async fn an_authorizing_gate_supplies_the_addresses_and_sees_the_request() { - let gate = Authorizing::new(Some(Ok(vec![public()]))); - let headers = vec![("X-A".to_string(), "1".to_string())]; - let target = validate_destination( - &gate, - "https://example.test/path?q=1", - &[], - "POST", - &headers, - Some("payload"), - ) - .await - .unwrap(); - assert_eq!(target.url, "https://example.test/path?q=1"); - assert_eq!(target.host, "example.test"); - assert_eq!(target.addrs, vec![public()]); - let seen = gate.seen.lock().unwrap(); - assert_eq!( - seen[0], - ( - "https://example.test/path?q=1".to_string(), - "example.test".to_string(), - 443, - "POST".to_string(), - headers, - Some("payload".to_string()) - ) - ); -} - -#[tokio::test] -async fn the_gates_refusal_reason_reaches_the_caller() { - let gate = Authorizing::new(Some(Err("egress denied: Privacy".to_string()))); - let error = validate_destination(&gate, "https://example.test/", &[], "GET", &[], None) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "egress denied: Privacy"); -} - -#[tokio::test] -async fn lexical_checks_run_before_the_gate_is_asked() { - let gate = Authorizing::new(Some(Ok(vec![public()]))); - for url in [ - "ftp://example.test/", - "http://127.0.0.1/", - "http://localhost/", - ] { - assert!( - validate_destination(&gate, url, &[], "GET", &[], None) - .await - .is_err(), - "{url}" - ); - } - let allowed = vec!["allowed.test".to_string()]; - assert!( - validate_destination(&gate, "https://other.test/", &allowed, "GET", &[], None) - .await - .is_err() - ); - assert!( - gate.seen.lock().unwrap().is_empty(), - "the gate must not see a URL the tool already refused" - ); -} - -#[tokio::test] -async fn a_gate_cannot_widen_the_tool_to_private_or_empty_answers() { - for bad in [ - vec!["127.0.0.1:443".parse().unwrap()], - vec!["10.0.0.5:443".parse().unwrap()], - vec![public(), "169.254.169.254:443".parse().unwrap()], - vec![], - ] { - let gate = Authorizing::new(Some(Ok(bad))); - assert!( - validate_destination(&gate, "https://example.test/", &[], "GET", &[], None) - .await - .is_err() - ); - } -} diff --git a/crates/tinytools-std/src/network/http_request.rs b/crates/tinytools-std/src/network/http_request.rs deleted file mode 100644 index c6edb274..00000000 --- a/crates/tinytools-std/src/network/http_request.rs +++ /dev/null @@ -1,485 +0,0 @@ -use super::gate::{HttpLimits, NetGate, host_of, read_body_capped}; -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::{ValidatedUrl, normalize_allowed_domains}; - -use super::gate::validate_destination; - -/// 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, - method: &str, - headers: &[(String, String)], - body: Option<&str>, - ) -> anyhow::Result { - validate_destination( - &*self.gate, - raw_url, - &self.allowed_domains, - method, - headers, - body, - ) - .await - } - - 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() - } - - fn is_safe_response_header(name: &str) -> bool { - matches!( - name.to_ascii_lowercase().as_str(), - "content-type" - | "content-length" - | "retry-after" - | "x-ratelimit-limit" - | "x-ratelimit-remaining" - | "x-ratelimit-reset" - ) - } - - async fn execute_request( - &self, - target: &ValidatedUrl, - 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)) - .user_agent(super::gate::USER_AGENT) - .redirect(reqwest::redirect::Policy::none()); - let builder = self.gate.prepare_client("tool.http_request", builder); - // Connect only to the vetted addresses; the URL keeps its host name so - // `Host`, SNI and certificate checks are unchanged. - let builder = builder.resolve_to_addrs(&target.host, &target.addrs); - let client = builder.build()?; - - let mut request = client.request(method, &target.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, - target: &ValidatedUrl, - 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 {}", - target.url - ); - - let attempt = hook.pay(&target.url, initial_response.headers()).await?; - - let mut retry_headers = headers; - retry_headers.extend(attempt.headers); - - let response = self - .execute_request(target, 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(); - - // Name *and* value. This printed the name twice — `{}: {:?}` over - // `(k.as_str(), k.as_str())` — so every line read - // `x-ratelimit-remaining: "x-ratelimit-remaining"` and the header block - // carried no information at all. The redaction beside it only makes - // sense if values were meant to be shown, which is the reading taken - // here: rate-limit and retry-after headers are exactly what a caller - // needs on the failures this block renders. - let headers_text = response - .headers() - .iter() - .map(|(name, value)| { - // Response headers are untrusted and service-specific headers - // can carry credentials under arbitrary names. Only expose - // values from this small set of useful diagnostic headers. - if Self::is_safe_response_header(name.as_str()) { - format!( - "{}: {}", - name.as_str(), - value.to_str().unwrap_or("") - ) - } else { - format!("{}: ***REDACTED***", name.as_str()) - } - }) - .collect::>() - .join(", "); - - let response_text = match read_body_capped(response, self.max_response_size).await { - Ok(body) => self.truncate_response(&String::from_utf8_lossy(&body)), - 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 { - // The same `output` on both arms. This returned the bare string - // `HTTP 403` — twenty characters — having already built the status - // line, headers and body and then dropped them, so the one part of - // the response that said *why* never reached the caller. A real - // case: GitHub's 403 names the missing `User-Agent` header outright, - // with a documentation link, and all of it was discarded. - Ok(ToolResult::error(output)) - } - } - - fn truncate_response(&self, text: &str) -> String { - if text.len() > self.max_response_size { - // The cap is in bytes: cut at the last char boundary at or below it. - let mut end = self.max_response_size; - while !text.is_char_boundary(end) { - end -= 1; - } - let mut truncated = text[..end].to_string(); - 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 request_headers = self.parse_headers(&headers_val); - let target = match self - .validate_url(url, method_str, &request_headers, body) - .await - { - Ok(v) => v, - Err(e) => return Ok(ToolResult::error(e.to_string())), - }; - let url = target.url.clone(); - - // 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 response = match self - .execute_request(&target, 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, &target, 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_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/network/http_request_tests.rs b/crates/tinytools-std/src/network/http_request_tests.rs deleted file mode 100644 index a9458dca..00000000 --- a/crates/tinytools-std/src/network/http_request_tests.rs +++ /dev/null @@ -1,738 +0,0 @@ -use super::*; -use crate::network::test_support::{AutonomyLevel, DEFAULT_LIMITS, TestNetGate, pinned_to_local}; - -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", "GET", &[], None) - .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 only_explicitly_safe_response_header_values_are_displayed() { - for safe in [ - "content-type", - "content-length", - "retry-after", - "x-ratelimit-remaining", - ] { - assert!(HttpRequestTool::is_safe_response_header(safe), "{safe}"); - assert!(HttpRequestTool::is_safe_response_header( - &safe.to_ascii_uppercase() - )); - } - - for sensitive in [ - "set-cookie", - "www-authenticate", - "proxy-authenticate", - "authentication-info", - "x-api-key", - "x-service-token", - ] { - assert!( - !HttpRequestTool::is_safe_response_header(sensitive), - "{sensitive} must be redacted" - ); - } -} - -#[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 target = pinned_to_local(&url); - let first = tool - .execute_request( - &target, - 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, - &target, - 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 target = pinned_to_local(&url); - let first = tool - .execute_request(&target, reqwest::Method::GET, vec![], None) - .await - .unwrap(); - let err = tool - .handle_payment_required(&hook, first, &target, 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\nWWW-Authenticate: Bearer secret-challenge\r\nX-Api-Key: api-secret\r\nX-Service-Token: service-secret\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( - &pinned_to_local(&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}"); - for secret in ["secret-challenge", "api-secret", "service-secret"] { - assert!(!text.contains(secret), "response leaked {secret}: {text}"); - } - for header in ["www-authenticate", "x-api-key", "x-service-token"] { - assert!( - text.to_ascii_lowercase() - .contains(&format!("{header}: ***redacted***")), - "{text}" - ); - } - assert!(seen.lock().unwrap()[0].ends_with("payload")); -} - -#[tokio::test] -async fn a_non_success_response_is_reported_as_an_error() { - // The failure now carries the whole response rather than just its code. - // - // It used to answer `HTTP 500` —, and in a measured case `HTTP 403`, - // twenty characters, after building the status line, headers and body and - // then dropping them. The discarded body was the only thing that said what - // was wrong: GitHub's 403 names the missing `User-Agent` header outright. - let body = "Request forbidden by administrative rules. \ - Please make sure your request has a User-Agent header"; - let (addr, _) = serve(vec![format!( - "HTTP/1.1 403 Forbidden\r\nContent-Length: {}\r\nX-RateLimit-Remaining: 59\r\nConnection: close\r\n\r\n{}", - body.len(), - body - )]) - .await; - let tool = test_tool(vec![]); - let response = tool - .execute_request( - &pinned_to_local(&format!("http://{addr}/")), - reqwest::Method::GET, - vec![], - None, - ) - .await - .unwrap(); - let result = tool.format_response(response).await.unwrap(); - assert!(result.is_error, "a 403 is still a failure"); - let text = result.text(); - // Anchored: a caller classifying the failure reads the status from the head - // of the text, and an unreadable one gets guessed at. - assert!(text.starts_with("Status: 403"), "{text}"); - // The reason the server gave. - assert!(text.contains("User-Agent header"), "{text}"); - // Header *values*, not the name printed twice. `x-ratelimit-remaining` is - // exactly what a caller wants on the failures this block renders. - let lower = text.to_ascii_lowercase(); - assert!(lower.contains("x-ratelimit-remaining: 59"), "{text}"); -} - -#[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( - &pinned_to_local(&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); -} - -/// Both network tools must name themselves on the wire. -/// -/// Not cosmetic: GitHub's REST API answers 403 to a request with no -/// `User-Agent`, so every `api.github.com` call through these tools failed -/// until this was set. `serve` records the raw request, which is the only way -/// to assert an outgoing header actually left the process. -#[tokio::test] -async fn an_outgoing_request_carries_a_user_agent() -> anyhow::Result<()> { - let (addr, seen) = serve(vec![ - "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_string(), - ]) - .await; - let tool = test_tool(vec![]); - let _ = tool - .execute_request( - &pinned_to_local(&format!("http://{addr}/")), - reqwest::Method::GET, - vec![], - None, - ) - .await?; - - let request = seen - .lock() - .map_err(|error| anyhow::anyhow!("request log mutex poisoned: {error}"))?[0] - .clone(); - let lower = request.to_ascii_lowercase(); - assert!( - lower.contains("user-agent:"), - "no User-Agent was sent:\n{request}" - ); - assert!( - lower.contains("user-agent: tinytools/"), - "the header must identify this crate:\n{request}" - ); - Ok(()) -} - -/// The connection goes to the vetted address, not to whatever the host name -/// resolves to a second time: the name below does not resolve at all, and the -/// request still reaches the pinned socket with the name in `Host`. -#[tokio::test] -async fn the_request_connects_to_the_pinned_address_and_keeps_the_host_name() { - let ok = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; - let (addr, seen) = serve(vec![ok.to_string()]).await; - let tool = test_tool(vec![]); - let target = crate::url_guard::ValidatedUrl { - url: format!("http://pinned.invalid:{}/x", addr.port()), - host: "pinned.invalid".to_string(), - addrs: vec![addr], - }; - let response = tool - .execute_request(&target, reqwest::Method::GET, vec![], None) - .await - .unwrap(); - assert_eq!(response.status(), reqwest::StatusCode::OK); - let request = seen.lock().unwrap().join(""); - assert!( - request - .to_ascii_lowercase() - .contains(&format!("host: pinned.invalid:{}", addr.port())), - "{request}" - ); -} - -// With a trailing-dot host the client must still connect only to the pin. -#[tokio::test] -async fn a_trailing_dot_host_still_connects_only_to_the_pinned_address() { - let ok = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; - let (addr, _seen) = serve(vec![ok.to_string()]).await; - let tool = test_tool(vec![]); - let url = format!("http://pinned.invalid.:{}/x", addr.port()); - let target = crate::url_guard::ValidatedUrl { - host: crate::url_guard::connect_host(&url).unwrap(), - url, - addrs: vec![addr], - }; - let r = tool - .execute_request(&target, reqwest::Method::GET, vec![], None) - .await; - assert!( - r.is_ok(), - "client re-resolved instead of using the pin: {r:?}" - ); -} - -// The response cap is in bytes, so multibyte text must not exceed it. -#[test] -fn truncate_response_caps_bytes_not_chars() { - let tool = HttpRequestTool::new( - TestNetGate::supervised(), - vec!["example.com".into()], - 10, - 30, - DEFAULT_LIMITS, - ); - let out = tool.truncate_response(&"€".repeat(10)); - let marker = "\n\n... [Response truncated due to size limit] ..."; - assert!(out.len() <= 10 + marker.len(), "{} bytes", out.len()); -} - -// A body larger than the cap is read only up to the cap, not to its end: -// this server never stops sending, so an unbounded read never returns. -#[tokio::test] -async fn an_endless_body_is_read_only_up_to_the_cap() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let _ = socket.read(&mut [0u8; 8192]).await; - let _ = socket - .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n") - .await; - let chunk = vec![b'x'; 65536]; - while socket.write_all(&chunk).await.is_ok() {} - }); - let tool = HttpRequestTool::new(TestNetGate::supervised(), vec![], 1000, 30, DEFAULT_LIMITS); - let target = pinned_to_local(&format!("http://{addr}/big")); - let output = tokio::time::timeout(std::time::Duration::from_secs(5), async { - let response = tool - .execute_request(&target, reqwest::Method::GET, vec![], None) - .await - .unwrap(); - tool.format_response(response).await.unwrap().output() - }) - .await; - assert!(output.is_ok(), "the body read did not stop at the cap"); - let output = output.unwrap(); - assert!(output.contains("[Response truncated due to size limit]")); - assert!(!output.contains(&"x".repeat(1001))); -} diff --git a/crates/tinytools-std/src/network/mod.rs b/crates/tinytools-std/src/network/mod.rs deleted file mode 100644 index e9fae3c6..00000000 --- a/crates/tinytools-std/src/network/mod.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! 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; -pub mod web_fetch; - -#[cfg(test)] -#[path = "contract_tests.rs"] -mod contract_test; -#[cfg(test)] -mod test_support; - -pub use curl::CurlTool; -pub use gate::{Destination, DestinationFuture, HttpLimits, NetGate}; -pub use http_request::{HttpRequestTool, PaymentAttempt, PaymentHook, PaymentOutcome}; -pub use pushover::PushoverTool; -pub use web_fetch::{AsyncHtmlExtractor, HtmlExtractor, WebFetchTool}; diff --git a/crates/tinytools-std/src/network/pushover.rs b/crates/tinytools-std/src/network/pushover.rs deleted file mode 100644 index defd9bf2..00000000 --- a/crates/tinytools-std/src/network/pushover.rs +++ /dev/null @@ -1,217 +0,0 @@ -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_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/network/pushover_tests.rs b/crates/tinytools-std/src/network/pushover_tests.rs deleted file mode 100644 index 66c4e6b1..00000000 --- a/crates/tinytools-std/src/network/pushover_tests.rs +++ /dev/null @@ -1,209 +0,0 @@ -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_ne!(tool.description().len(), 0); -} - -#[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 deleted file mode 100644 index 8425aaa4..00000000 --- a/crates/tinytools-std/src/network/test_support.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! 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>, - prepared: 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()), - prepared: 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()), - prepared: 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() - } - - /// The service names `prepare_client` was called with, in order. - pub fn prepared(&self) -> Vec { - self.prepared.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 { - self.prepared.lock().unwrap().push(service.to_string()); - 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 - } -} - -/// A [`ValidatedUrl`] for a loopback test server: the host is the IP literal in -/// `url`, pinned to that one socket address, as the SSRF guard would return it -/// for a public literal. -pub fn pinned_to_local(url: &str) -> crate::url_guard::ValidatedUrl { - let parsed = reqwest::Url::parse(url).expect("test URL parses"); - let host = parsed.host_str().expect("test URL has a host").to_string(); - let ip: std::net::IpAddr = host.parse().expect("test URL host is an IP literal"); - let port = parsed.port_or_known_default().expect("test URL has a port"); - crate::url_guard::ValidatedUrl { - url: url.to_string(), - host, - addrs: vec![std::net::SocketAddr::new(ip, port)], - } -} diff --git a/crates/tinytools-std/src/network/web_fetch.rs b/crates/tinytools-std/src/network/web_fetch.rs deleted file mode 100644 index b55a3cf6..00000000 --- a/crates/tinytools-std/src/network/web_fetch.rs +++ /dev/null @@ -1,680 +0,0 @@ -//! `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). -//! -//! A 4xx/5xx response is a failed fetch: it comes back as an error result -//! (`is_error`) naming the status and a short body excerpt, so a host that -//! budgets or retries on tool errors sees blocked and rate-limited pages for -//! what they are. 3xx responses are not followed and stay successful reports. - -use super::gate::{HttpLimits, NetGate, USER_AGENT, host_of}; -use crate::url_guard::{ValidatedUrl, normalize_allowed_domains}; - -use super::gate::validate_destination; -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; -} - -/// An asynchronous, fallible HTML transform supplied by the host. -/// -/// Use this interface when extraction runs in a separate service or module. -/// Failures propagate to the fetch caller; the tool does not retry locally. -#[async_trait] -pub trait AsyncHtmlExtractor: std::fmt::Debug + Send + Sync { - /// Whether a response without a usable content type contains HTML. - /// - /// # Errors - /// Returns the host's detection failure. - async fn looks_like_html(&self, body: &str) -> anyhow::Result; - - /// Convert HTML to Markdown, preserving links and dropping scripts. - /// - /// # Errors - /// Returns the host's extraction failure. - async fn to_markdown(&self, html: &str) -> anyhow::Result; -} - -#[derive(Debug)] -enum HtmlProvider { - Local(Arc), - Async { - extractor: Arc, - timeout: Duration, - }, -} - -impl HtmlProvider { - async fn looks_like_html(&self, body: &str) -> anyhow::Result { - match self { - Self::Local(html) => Ok(html.looks_like_html(body)), - Self::Async { extractor, timeout } => { - tokio::time::timeout(*timeout, extractor.looks_like_html(body)) - .await - .map_err(|_| anyhow::anyhow!("HTML detection timed out"))? - } - } - } - - async fn to_markdown(&self, body: &str) -> anyhow::Result { - match self { - Self::Local(html) => Ok(html.to_markdown(body)), - Self::Async { extractor, timeout } => { - tokio::time::timeout(*timeout, extractor.to_markdown(body)) - .await - .map_err(|_| anyhow::anyhow!("HTML extraction timed out"))? - } - } - } -} - -/// 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: HtmlProvider, - 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 { - Self::with_provider( - gate, - allowed_domains, - max_bytes, - timeout_secs, - defaults, - HtmlProvider::Local(html), - ) - } - - /// A `web_fetch` tool with asynchronous HTML detection and extraction. - /// - /// Limits and raw-response handling match [`Self::new`]. Extraction errors - /// propagate without invoking another provider. Both asynchronous detection - /// and conversion are bounded by the configured request timeout. - #[must_use] - pub fn new_async( - gate: Arc, - allowed_domains: Vec, - max_bytes: Option, - timeout_secs: Option, - defaults: HttpLimits, - html: Arc, - ) -> Self { - let resolved_timeout = 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(seconds) => seconds, - None => defaults.timeout_secs, - }; - Self::with_provider( - gate, - allowed_domains, - max_bytes, - Some(resolved_timeout), - defaults, - HtmlProvider::Async { - extractor: html, - timeout: Duration::from_secs(resolved_timeout), - }, - ) - } - - fn with_provider( - gate: Arc, - allowed_domains: Vec, - max_bytes: Option, - timeout_secs: Option, - defaults: HttpLimits, - html: HtmlProvider, - ) -> 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": "Cap the returned text at this many bytes \ - (default 1_000_000). Bounds the OUTPUT — the extracted \ - markdown, or the raw body with raw:true — never the markup \ - the extractor reads, so lowering it cannot cost you content \ - the page actually had.", - "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 target = match validate_destination( - &*self.gate, - raw_url, - &self.allowed_domains, - "GET", - &[], - None, - ) - .await - { - Ok(u) => u, - Err(e) => return Ok(ToolResult::error(format!("URL rejected: {e}"))), - }; - let url = target.url.clone(); - - // Egress spine (privacy epic S2, #4436): disclose the fetch destination - // before contacting the host. - self.gate.disclose(&host_of(&url), false, false); - - self.fetch_validated(&target, max_bytes, raw_requested) - .await - } -} - -impl WebFetchTool { - /// Issue the GET for a URL that already passed the gate and the SSRF - /// guard, and render the response for the model. - async fn fetch_validated( - &self, - target: &ValidatedUrl, - max_bytes: usize, - raw_requested: bool, - ) -> anyhow::Result { - // 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 builder = reqwest::Client::builder() - .timeout(Duration::from_secs(self.timeout_secs)) - .user_agent(USER_AGENT) - .redirect(reqwest::redirect::Policy::none()); - let builder = self.gate.prepare_client("tool.web_fetch", builder); - // Connect only to the vetted addresses; the URL keeps its host name. - let client = match builder - .resolve_to_addrs(&target.host, &target.addrs) - .build() - { - Ok(c) => c, - Err(e) => return Ok(ToolResult::error(format!("Failed to build client: {e}"))), - }; - - let url = target.url.as_str(); - 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 retry_after = resp - .headers() - .get(reqwest::header::RETRY_AFTER) - .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 - ))); - } - - // A 4xx/5xx is a failed fetch, not a page: returning it as a success - // let a blocked or rate-limited site count as research done. (3xx is - // handled above and stays a successful "not followed" report.) - if status.is_client_error() || status.is_server_error() { - let host = host_of(&final_url); - log::debug!( - "[tool.web_fetch] http error status={} host={host} retry_after_present={}", - status.as_u16(), - retry_after.is_some() - ); - let excerpt = - error_body_excerpt(&self.html, &body, content_type.as_deref(), raw_requested) - .await?; - return Ok(ToolResult::error(http_error_message( - status, - &host, - retry_after.as_deref(), - &excerpt, - ))); - } - - let downloaded = body.len(); - - // 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, &body, content_type.as_deref()).await?; - let rendered = render_body(&self.html, body, converted, max_bytes).await?; - - let extracted = rendered.extracted; - let mut header = format!("status={} url={final_url}", status.as_u16()); - if converted { - header.push_str(" content=markdown"); - } - // `output_capped_at`, not the old `download_capped_at`: nothing here - // ever capped a download — `resp.text()` above materialises the whole - // body regardless — and naming it that sent a reader looking in the - // wrong place for the content that went missing. - if rendered.output_capped { - header.push_str(&format!(" output_capped_at={max_bytes}B")); - } - append_markup_truncation_header(&mut header, &rendered); - if converted && extracted < downloaded { - header.push_str(&format!(" extracted={extracted}B_of_{downloaded}B")); - } - header.push('\n'); - let content = rendered.content; - - // 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}"))) - } -} - -/// How much of an error response's body is quoted back to the model. -const ERROR_EXCERPT_CHARS: usize = 300; - -/// The model-facing text for a 4xx/5xx response: what happened, why it -/// matters, and what to do next, plus a short excerpt of the body. -fn http_error_message( - status: reqwest::StatusCode, - host: &str, - retry_after: Option<&str>, - excerpt: &str, -) -> String { - let code = status.as_u16(); - let reason = status.canonical_reason().unwrap_or("Unknown Status"); - let mut msg = format!("HTTP {code} {reason} from {host}; "); - match code { - 429 => { - msg.push_str("the site is rate limiting requests."); - if let Some(wait) = retry_after.map(str::trim).filter(|w| !w.is_empty()) { - msg.push_str(&format!(" Retry-After: {wait}.")); - } - msg.push_str(" Try another source, or retry later."); - } - 401 | 403 => msg.push_str("the site refused the request. Try another source."), - 404 | 410 => { - msg.push_str( - "the page does not exist at this URL. Check the URL or try another source.", - ); - } - 500..=599 => { - msg.push_str( - "the server failed to handle the request. Retry later or try another source.", - ); - } - _ => msg.push_str("the server rejected the request. Try another source."), - } - if !excerpt.is_empty() { - msg.push_str("\nResponse excerpt: "); - msg.push_str(excerpt); - } - msg -} - -/// A short, single-line, text-only excerpt of an error response body. -/// -/// HTML goes through the host extractor (unless the caller asked for `raw`) -/// so the model reads the page's words rather than its markup. -async fn error_body_excerpt( - extractor: &HtmlProvider, - body: &str, - content_type: Option<&str>, - raw_requested: bool, -) -> anyhow::Result { - let (bounded_body, _) = cap_extractor_input(body); - let text = if !raw_requested && is_html(extractor, bounded_body, content_type).await? { - extractor.to_markdown(bounded_body).await? - } else { - body.to_string() - }; - let collapsed = text.split_whitespace().collect::>().join(" "); - Ok(match collapsed.char_indices().nth(ERROR_EXCERPT_CHARS) { - Some((cut, _)) => format!("{}...", &collapsed[..cut]), - None => collapsed, - }) -} - -/// Raw markup handed to the HTML extractor, at most. -/// -/// Not a byte budget — the caller's `max_bytes` owns that, applied to the -/// output. This exists only so a pathological document cannot cost unbounded -/// CPU in `to_markdown`, a cost the old pre-truncation ordering hid by -/// accident. 8 MiB is ~19x the largest real page measured here (a 434 KB -/// client-rendered spreadsheet) and ~8x the default `max_response_size`, so no -/// realistic page reaches it. -const EXTRACTOR_INPUT_CEILING: usize = 8 * 1024 * 1024; - -/// A body turned into what the caller reads. -struct RenderedBody { - /// The text to return, bounded by the caller's `max_bytes`. - content: String, - /// Length *before* that bound, so the header's ratio describes the - /// extraction rather than the truncation. - extracted: usize, - /// Whether `content` was cut to fit `max_bytes`. - output_capped: bool, - /// Whether the markup was cut before the extractor saw it, which only - /// happens past [`EXTRACTOR_INPUT_CEILING`]. - markup_truncated: bool, -} - -/// Add the extractor input ceiling to a fetch header when markup was cut. -fn append_markup_truncation_header(header: &mut String, rendered: &RenderedBody) { - if rendered.markup_truncated { - header.push_str(&format!(" markup_truncated_at={EXTRACTOR_INPUT_CEILING}B")); - } -} - -/// Convert, **then** bound. -/// -/// The order is the whole of this function. It used to be the other way round, -/// and the cap was therefore destroying the thing it was meant to measure: a -/// 432,864-byte client-rendered page fetched with `max_bytes: 50000` was cut at -/// byte 50,000 — mid-tag, mid-DOM — and the readability pass, handed that -/// wreckage, recovered only the ``. The result was -/// `extracted=48B_of_432864B`, reported as `status=200`. The same URL with no -/// `max_bytes` yields `extracted=37243B_of_433638B`: the real document. -/// Identical tool, identical extractor, 776x the content, one parameter. -/// -/// So a caller setting a sensible cost bound silently lost the page, and the -/// knob it would then reach for — raising the cap — was the right knob turned -/// too timidly, which is the worst case for learning anything from the failure. -/// -/// Bounding the output is also what the schema promises, and it costs no -/// memory: the caller has already materialised the whole body. Only the -/// extractor's input needs a ceiling, and that is for CPU. -async fn render_body( - html: &HtmlProvider, - body: String, - converted: bool, - max_bytes: usize, -) -> anyhow::Result<RenderedBody> { - let (markup_truncated, body) = if converted { - let (body, truncated) = cap_extractor_input(&body); - (truncated, body.to_string()) - } else { - (false, body) - }; - let full = if converted { - html.to_markdown(&body).await? - } else { - body - }; - let extracted = full.len(); - let (content, output_capped) = if extracted > max_bytes { - let cut = floor_char_boundary(&full, max_bytes); - (full[..cut].to_string(), true) - } else { - (full, false) - }; - Ok(RenderedBody { - content, - extracted, - output_capped, - markup_truncated, - }) -} - -/// Bound every body before it crosses into a host supplied extractor. -fn cap_extractor_input(body: &str) -> (&str, bool) { - if body.len() > EXTRACTOR_INPUT_CEILING { - let cut = floor_char_boundary(body, EXTRACTOR_INPUT_CEILING); - (&body[..cut], true) - } else { - (body, false) - } -} - -/// 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. -async fn is_html( - extractor: &HtmlProvider, - body: &str, - content_type: Option<&str>, -) -> anyhow::Result<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 Ok(false); - } - if !mime.is_empty() { - return Ok(true); - } - } - let (body, _) = cap_extractor_input(body); - extractor.looks_like_html(body).await -} - -/// The HTTP status of a `web_fetch` error result. This remains available from -/// the producer module, while the single parser lives in `tinytools::status` -/// so lightweight hosts can consume the result contract without depending on -/// `tinytools-std`. -pub use tinytools::status::web_fetch_status as status_of; - -#[cfg(test)] -#[path = "web_fetch_tests.rs"] -mod tests; - -#[cfg(test)] -#[path = "web_fetch_proxy_tests.rs"] -mod proxy_tests; diff --git a/crates/tinytools-std/src/network/web_fetch_proxy_tests.rs b/crates/tinytools-std/src/network/web_fetch_proxy_tests.rs deleted file mode 100644 index a0a2eea8..00000000 --- a/crates/tinytools-std/src/network/web_fetch_proxy_tests.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! web_fetch builds its client through the host's `prepare_client`, so the -//! host's proxy settings apply to it as they do to http_request and curl. - -#![allow(clippy::unwrap_used)] - -use super::*; -use crate::network::test_support::{DEFAULT_LIMITS, TestHtml, TestNetGate, pinned_to_local}; - -#[tokio::test] -async fn web_fetch_builds_its_client_through_prepare_client() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let _ = socket.read(&mut [0u8; 8192]).await; - let _ = socket - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") - .await; - }); - let gate = TestNetGate::supervised(); - let tool = WebFetchTool::new( - gate.clone(), - vec![], - None, - None, - DEFAULT_LIMITS, - Arc::new(TestHtml), - ); - let result = tool - .fetch_validated(&pinned_to_local(&format!("http://{addr}/p")), 1_000, false) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert_eq!(gate.prepared(), vec!["tool.web_fetch".to_string()]); -} diff --git a/crates/tinytools-std/src/network/web_fetch_tests.rs b/crates/tinytools-std/src/network/web_fetch_tests.rs deleted file mode 100644 index 685dc1e2..00000000 --- a/crates/tinytools-std/src/network/web_fetch_tests.rs +++ /dev/null @@ -1,816 +0,0 @@ -#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] - -use super::*; -use crate::network::test_support::{DEFAULT_LIMITS, TestHtml, TestNetGate, pinned_to_local}; -use std::sync::Mutex; - -fn test_security() -> Arc<TestNetGate> { - TestNetGate::supervised() -} - -fn fetch( - gate: Arc<TestNetGate>, - allowed: Vec<String>, - max: Option<usize>, - timeout: Option<u64>, -) -> 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() - ); -} - -#[tokio::test] -async 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 ---------------------------------------------------- - -async fn html(body: &str, content_type: Option<&str>) -> bool { - is_html(&HtmlProvider::Local(Arc::new(TestHtml)), body, content_type) - .await - .unwrap() -} - -#[tokio::test] -async fn an_explicit_html_content_type_selects_markdown_conversion() { - assert!(html("<p>hi</p>", Some("text/html; charset=utf-8")).await); - assert!(html("<p>hi</p>", Some("application/xhtml+xml")).await); -} - -#[tokio::test] -async 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": "<div><p>one</p><span>two</span><a href="/x">two</a></div>"}"#; - assert!(!html(body, Some("application/json")).await); - assert!(!html("<p>x</p>", Some("text/plain")).await); -} - -#[tokio::test] -async fn a_missing_content_type_falls_back_to_content_detection() { - assert!(html("<!DOCTYPE html><html><body><p>hi</p></body></html>", None).await); - assert!(!html("# Just a README\n\nSome prose.\n", None).await); -} - -#[tokio::test] -async fn an_empty_content_type_does_not_veto_detection() { - assert!(html("<!DOCTYPE html><html><body>x</body></html>", Some("")).await); -} - -#[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}" - ); -} - -#[tokio::test] -async fn html_is_converted_through_the_host_extractor_only_when_it_is_html() { - let body = "<!DOCTYPE html><html><body><p>hi</p></body></html>"; - assert!(html(body, None).await); - 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")); -} - -// --- HTTP error statuses --------------------------------------------------- -// -// Loopback is refused by the SSRF guard, so these drive `fetch_validated` -// (everything after validation) against a one-shot local server. - -/// Serves one canned response and returns the base URL. -async fn serve_once(response: &str) -> String { - 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 response = response.to_string(); - tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut buf = vec![0u8; 8192]; - let _ = socket.read(&mut buf).await.unwrap(); - socket.write_all(response.as_bytes()).await.unwrap(); - let _ = socket.shutdown().await; - }); - format!("http://{addr}/page") -} - -#[tokio::test] -async fn an_outgoing_request_carries_a_user_agent() { - 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(String::new())); - let request_log = Arc::clone(&seen); - let server = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut request = Vec::new(); - let mut buf = [0u8; 1024]; - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - let n = socket.read(&mut buf).await.unwrap(); - assert!( - n != 0, - "peer closed before sending the complete HTTP headers" - ); - request.extend_from_slice(&buf[..n]); - } - *request_log.lock().unwrap() = String::from_utf8_lossy(&request).to_string(); - socket - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") - .await - .unwrap(); - }); - - let tool = fetch(test_security(), vec![], None, None); - let result = tool - .fetch_validated( - &pinned_to_local(&format!("http://{addr}/")), - 1_000_000, - false, - ) - .await - .unwrap(); - assert!(!result.is_error, "got: {}", result.output()); - server.await.unwrap(); - - let request = seen.lock().unwrap().to_ascii_lowercase(); - let user_agent = request - .lines() - .find_map(|line| line.strip_prefix("user-agent: ")); - assert_eq!( - user_agent, - Some(concat!("tinytools/", env!("CARGO_PKG_VERSION"))) - ); -} - -fn http_response(status_line: &str, headers: &str, body: &str) -> String { - format!( - "HTTP/1.1 {status_line}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) -} - -async fn fetch_canned(response: String) -> ToolResult { - let url = serve_once(&response).await; - fetch(test_security(), vec![], None, None) - .fetch_validated(&pinned_to_local(&url), 1_000_000, false) - .await - .unwrap() -} - -#[tokio::test] -async fn a_200_is_a_successful_result_with_the_status_header() { - let result = fetch_canned(http_response( - "200 OK", - "Content-Type: text/plain\r\n", - "hello", - )) - .await; - assert!(!result.is_error, "got: {}", result.output()); - assert!(result.output().starts_with("status=200 url=")); - assert!(result.output().ends_with("hello")); -} - -#[tokio::test] -async fn a_403_is_an_error_naming_the_status_and_suggesting_another_source() { - let result = fetch_canned(http_response( - "403 Forbidden", - "Content-Type: text/plain\r\n", - "Access denied by bot protection", - )) - .await; - assert!(result.is_error, "got: {}", result.output()); - let out = result.output(); - assert!( - out.contains("HTTP 403 Forbidden from 127.0.0.1"), - "got: {out}" - ); - assert!(out.contains("refused the request"), "got: {out}"); - assert!(out.contains("another source"), "got: {out}"); - assert!( - out.contains("Access denied by bot protection"), - "got: {out}" - ); -} - -#[tokio::test] -async fn a_429_is_an_error_that_reports_rate_limiting_and_retry_after() { - let result = fetch_canned(http_response( - "429 Too Many Requests", - "Retry-After: 120\r\n", - "", - )) - .await; - assert!(result.is_error, "got: {}", result.output()); - let out = result.output(); - assert!(out.contains("HTTP 429 Too Many Requests"), "got: {out}"); - assert!(out.contains("rate limit"), "got: {out}"); - assert!(out.contains("Retry-After: 120"), "got: {out}"); -} - -#[tokio::test] -async fn a_429_without_retry_after_still_reports_rate_limiting() { - let result = fetch_canned(http_response("429 Too Many Requests", "", "")).await; - assert!(result.is_error); - assert!(result.output().contains("rate limit")); - assert!(!result.output().contains("Retry-After")); -} - -#[tokio::test] -async fn a_404_is_an_error() { - let result = fetch_canned(http_response( - "404 Not Found", - "Content-Type: text/html\r\n", - "<!DOCTYPE html><html><body><p>No such page</p></body></html>", - )) - .await; - assert!(result.is_error, "got: {}", result.output()); - let out = result.output(); - assert!(out.contains("HTTP 404 Not Found"), "got: {out}"); - // The excerpt is the page's text, not its markup. - assert!(out.contains("No such page"), "got: {out}"); - assert!(!out.contains("<p>"), "got: {out}"); -} - -#[tokio::test] -async fn a_5xx_is_an_error() { - let result = fetch_canned(http_response("503 Service Unavailable", "", "")).await; - assert!(result.is_error); - assert!(result.output().contains("HTTP 503 Service Unavailable")); -} - -#[tokio::test] -async fn an_error_body_excerpt_is_short() { - let long = "x".repeat(5_000); - let result = fetch_canned(http_response( - "500 Internal Server Error", - "Content-Type: text/plain\r\n", - &long, - )) - .await; - assert!(result.is_error); - assert!( - result.output().len() < 1_000, - "excerpt must be bounded, got {} bytes", - result.output().len() - ); -} - -#[tokio::test] -async fn a_redirect_is_still_reported_as_a_successful_result() { - let result = fetch_canned(http_response( - "301 Moved Permanently", - "Location: https://example.com/new\r\n", - "", - )) - .await; - assert!(!result.is_error, "got: {}", result.output()); - assert!(result.output().contains("status=301")); - assert!(result.output().contains("location=https://example.com/new")); -} - -// --- The cap bounds the output, not the extractor's input ------------------ -// -// `TestHtml::to_markdown` strips tags, so a document whose prose sits past the -// cap offset is the shape that discriminates: cutting the markup first loses -// the prose outright, cutting the rendered text keeps it. - -/// Markup whose readable text sits behind `filler` bytes of attribute. -fn page_with_prose_after(filler: usize) -> String { - format!( - "<!DOCTYPE html><html><head><title>T\ -

the prose that matters

", - "f".repeat(filler) - ) -} - -#[tokio::test] -async fn the_cap_applies_to_the_extracted_text_not_the_markup() { - // The regression this function exists for. A 432,864-byte page fetched at - // `max_bytes: 50000` used to come back as `extracted=48B_of_432864B` — the - // whole document reduced to its `` — because the markup was cut - // mid-DOM before the extractor ever saw it. - let body = page_with_prose_after(4_000); - assert!(body.len() > 1_000, "the prose must sit past the cap"); - - let rendered = render_body(&HtmlProvider::Local(Arc::new(TestHtml)), body, true, 1_000) - .await - .unwrap(); - assert!( - rendered.content.contains("the prose that matters"), - "cutting the markup first would have lost this: {:?}", - rendered.content - ); -} - -#[test] -fn cutting_the_markup_first_really_does_destroy_the_extraction() { - // The counterfactual, so the test above cannot pass vacuously: the old - // ordering, performed by hand, loses the prose from the same document. - let body = page_with_prose_after(4_000); - let truncated = &body[..1_000]; - assert!( - !TestHtml - .to_markdown(truncated) - .contains("the prose that matters"), - "a pre-extraction cut is what destroyed the content" - ); -} - -#[tokio::test] -async fn the_reported_length_is_the_extraction_not_the_truncation() { - // `extracted` is pre-cap on purpose: the header's `extracted=XB_of_YB` - // ratio describes how much of the document the extractor found, which is - // the number that reveals a collapse. Measuring post-cap would report the - // cap back to the caller as if it were the page. - let body = page_with_prose_after(4_000); - let full = TestHtml.to_markdown(&body); - let rendered = render_body(&HtmlProvider::Local(Arc::new(TestHtml)), body, true, 4) - .await - .unwrap(); - assert_eq!(rendered.extracted, full.len()); - assert!(rendered.output_capped); - assert!(rendered.content.len() <= 4); -} - -#[tokio::test] -async fn an_output_within_the_cap_is_returned_whole_and_unflagged() { - let body = page_with_prose_after(16); - let rendered = render_body( - &HtmlProvider::Local(Arc::new(TestHtml)), - body, - true, - 1_000_000, - ) - .await - .unwrap(); - assert!(!rendered.output_capped); - assert!(!rendered.markup_truncated); - assert!(rendered.content.contains("the prose that matters")); -} - -#[tokio::test] -async fn markup_truncated_input_is_reported_in_the_fetch_header() { - // Drive the body across the extractor's independent input ceiling. The - // large attribute keeps extracted text small, while proving that the - // extractor input itself was bounded and reported to the caller. - let body = format!( - "<!DOCTYPE html><html><body><div data-pad=\"{}\"></div><p>visible</p></body></html>", - "x".repeat(EXTRACTOR_INPUT_CEILING) - ); - assert!(body.len() > EXTRACTOR_INPUT_CEILING); - let rendered = render_body(&HtmlProvider::Local(Arc::new(TestHtml)), body, true, 1_000) - .await - .unwrap(); - assert!(rendered.markup_truncated); - let mut output = "status=200 url=https://example.com content=markdown".to_string(); - append_markup_truncation_header(&mut output, &rendered); - assert!( - output.contains(&format!("markup_truncated_at={EXTRACTOR_INPUT_CEILING}B")), - "header should disclose the extractor input ceiling: {output}" - ); -} - -#[tokio::test] -async fn raw_output_is_bounded_by_the_same_cap() { - // With `raw: true` there is no extraction, so the cap applies to the body - // itself — the one case where cutting the input and cutting the output are - // the same act. - let rendered = render_body( - &HtmlProvider::Local(Arc::new(TestHtml)), - "abcdefghij".to_string(), - false, - 4, - ) - .await - .unwrap(); - assert_eq!(rendered.content, "abcd"); - assert!(rendered.output_capped); - assert_eq!(rendered.extracted, 10); -} - -#[test] -fn the_markup_ceiling_is_far_above_any_real_page() { - // Sized against the measured worst case, not picked round. A ceiling near - // the old default would reintroduce the bug for ordinary documents. - const { - assert!(EXTRACTOR_INPUT_CEILING >= 8 * 1024 * 1024); - assert!(EXTRACTOR_INPUT_CEILING > 433_638 * 10); - } -} - -#[test] -fn the_schema_says_which_side_of_the_extractor_it_bounds() { - // The description is a caller's only account of what the knob does, and - // the old wording ("Truncate body at this many bytes") is what made - // lowering it look free. - let tool = fetch(test_security(), vec![], None, None); - let schema = tool.parameters_schema(); - let desc = schema["properties"]["max_bytes"]["description"] - .as_str() - .expect("max_bytes documents itself"); - assert!(desc.contains("OUTPUT"), "{desc}"); - assert!(desc.contains("never the markup"), "{desc}"); -} - -#[derive(Debug)] -struct RemoteHtml { - fail_detection: bool, - fail_extraction: bool, -} - -#[async_trait] -impl AsyncHtmlExtractor for RemoteHtml { - async fn looks_like_html(&self, body: &str) -> anyhow::Result<bool> { - tokio::task::yield_now().await; - anyhow::ensure!(!self.fail_detection, "remote detection unavailable"); - Ok(TestHtml.looks_like_html(body)) - } - - async fn to_markdown(&self, body: &str) -> anyhow::Result<String> { - tokio::task::yield_now().await; - anyhow::ensure!(!self.fail_extraction, "remote extraction unavailable"); - Ok(TestHtml.to_markdown(body)) - } -} - -fn remote_fetch(fail_detection: bool, fail_extraction: bool) -> WebFetchTool { - WebFetchTool::new_async( - test_security(), - vec![], - None, - None, - DEFAULT_LIMITS, - Arc::new(RemoteHtml { - fail_detection, - fail_extraction, - }), - ) -} - -#[tokio::test] -async fn remote_extraction_reads_before_applying_the_output_cap() { - let tool = remote_fetch(false, false); - let body = page_with_prose_after(4_000); - let url = serve_once(&http_response("200 OK", "", &body)).await; - let result = tool - .fetch_validated(&pinned_to_local(&url), 1_000, false) - .await - .unwrap(); - assert!(result.output().contains("the prose that matters")); - assert!(result.output().contains("content=markdown")); -} - -#[tokio::test] -async fn remote_detection_failure_propagates_without_returning_markup() { - let tool = remote_fetch(true, false); - let url = serve_once(&http_response("200 OK", "", "<html>private</html>")).await; - let error = tool - .fetch_validated(&pinned_to_local(&url), 1_000, false) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "remote detection unavailable"); -} - -#[tokio::test] -async fn remote_extraction_failure_propagates_for_success_and_error_responses() { - let tool = remote_fetch(false, true); - for status in ["200 OK", "403 Forbidden"] { - let url = serve_once(&http_response( - status, - "Content-Type: text/html\r\n", - "<p>private</p>", - )) - .await; - let error = tool - .fetch_validated(&pinned_to_local(&url), 1_000, false) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "remote extraction unavailable"); - } -} - -#[tokio::test] -async fn raw_and_explicit_non_html_responses_do_not_call_the_remote_provider() { - let tool = remote_fetch(true, true); - for (headers, raw) in [("Content-Type: application/json\r\n", false), ("", true)] { - let url = serve_once(&http_response("200 OK", headers, "<p>raw</p>")).await; - let result = tool - .fetch_validated(&pinned_to_local(&url), 1_000, raw) - .await - .unwrap(); - assert!(result.output().ends_with("<p>raw</p>")); - assert!(!result.output().contains("content=markdown")); - } -} - -#[derive(Debug)] -struct PendingHtml; - -#[async_trait] -impl AsyncHtmlExtractor for PendingHtml { - async fn looks_like_html(&self, _body: &str) -> anyhow::Result<bool> { - std::future::pending().await - } - - async fn to_markdown(&self, _body: &str) -> anyhow::Result<String> { - std::future::pending().await - } -} - -#[tokio::test(start_paused = true)] -async fn pending_remote_detection_and_extraction_are_bounded() { - let tool = WebFetchTool::new_async( - test_security(), - vec![], - None, - Some(2), - DEFAULT_LIMITS, - Arc::new(PendingHtml), - ); - let error = is_html(&tool.html, "<html>body</html>", None) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "HTML detection timed out"); - let error = render_body(&tool.html, "<p>body</p>".into(), true, 1_000) - .await - .err() - .unwrap(); - assert_eq!(error.to_string(), "HTML extraction timed out"); - // Error-response excerpts are governed by the same provider deadline. - let error = error_body_excerpt(&tool.html, "<p>body</p>", Some("text/html"), false) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "HTML extraction timed out"); -} - -#[derive(Debug)] -struct InputLengthHtml(Arc<Mutex<Vec<usize>>>); - -#[async_trait] -impl AsyncHtmlExtractor for InputLengthHtml { - async fn looks_like_html(&self, body: &str) -> anyhow::Result<bool> { - self.0.lock().unwrap().push(body.len()); - Ok(true) - } - - async fn to_markdown(&self, body: &str) -> anyhow::Result<String> { - self.0.lock().unwrap().push(body.len()); - Ok(String::new()) - } -} - -#[tokio::test] -async fn every_remote_extractor_call_receives_at_most_the_input_ceiling() { - let lengths = Arc::new(Mutex::new(Vec::new())); - let extractor = HtmlProvider::Async { - extractor: Arc::new(InputLengthHtml(lengths.clone())), - timeout: Duration::from_secs(1), - }; - let body = "x".repeat(EXTRACTOR_INPUT_CEILING + 1); - - // Missing Content-Type takes the remote detection path. - assert!(is_html(&extractor, &body, None).await.unwrap()); - // Error bodies can call detection and extraction directly. - error_body_excerpt(&extractor, &body, None, false) - .await - .unwrap(); - // An explicit HTML error response skips detection but still caps extraction. - error_body_excerpt(&extractor, &body, Some("text/html"), false) - .await - .unwrap(); - - assert_eq!( - *lengths.lock().unwrap(), - vec![ - EXTRACTOR_INPUT_CEILING, - EXTRACTOR_INPUT_CEILING, - EXTRACTOR_INPUT_CEILING, - EXTRACTOR_INPUT_CEILING, - ] - ); -} - -#[test] -fn remote_provider_timeout_uses_the_same_zero_and_none_defaults_as_http() { - for configured in [Some(0), None, Some(7)] { - let tool = WebFetchTool::new_async( - test_security(), - vec![], - None, - configured, - DEFAULT_LIMITS, - Arc::new(PendingHtml), - ); - let HtmlProvider::Async { timeout, .. } = tool.html else { - panic!("expected remote provider") - }; - assert_eq!(timeout, Duration::from_secs(tool.timeout_secs)); - } -} - -#[test] -fn status_of_reads_the_leading_web_fetch_error_shape() { - assert_eq!( - status_of("HTTP 403 Forbidden from example.com; try another source\nbody"), - Some(403) - ); - assert_eq!( - status_of(" HTTP 503 Service Unavailable from a.b; retry later"), - Some(503) - ); -} - -#[test] -fn status_of_rejects_quoted_bare_or_non_error_statuses() { - assert_eq!(status_of("403 Forbidden"), None); - assert_eq!(status_of("HTTP 403"), None); - assert_eq!(status_of("HTTP 200 OK from example.com; fine"), None); - assert_eq!(status_of("HTTP 99 x from h; g"), None); - assert_eq!(status_of("HTTP 404 Not Found from two words; g"), None); - assert_eq!( - status_of("error\nHTTP 403 Forbidden from example.com; quoted"), - None - ); -} - -/// As for `http_request`: the fetch connects to the vetted address and keeps -/// the host name, rather than resolving the name again. -#[tokio::test] -async fn the_fetch_connects_to_the_pinned_address() { - let url = serve_once(&http_response( - "200 OK", - "Content-Type: text/plain\r\n", - "pinned", - )) - .await; - let port = reqwest::Url::parse(&url).unwrap().port().unwrap(); - let target = ValidatedUrl { - url: format!("http://pinned.invalid:{port}/page"), - host: "pinned.invalid".to_string(), - addrs: vec![format!("127.0.0.1:{port}").parse().unwrap()], - }; - let result = fetch(test_security(), vec![], None, None) - .fetch_validated(&target, 1_000_000, false) - .await - .unwrap(); - assert!(!result.is_error, "{result:?}"); -} diff --git a/crates/tinytools-std/src/timeout/command_environment.rs b/crates/tinytools-std/src/timeout/command_environment.rs deleted file mode 100644 index 92bcb262..00000000 --- a/crates/tinytools-std/src/timeout/command_environment.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Child-process environment carried by one dispatch task. - -use std::{collections::BTreeMap, future::Future, sync::Arc}; - -tokio::task_local! { static ACTIVE: CommandEnvironment; } - -/// A complete, turn-owned environment for builtin tool subprocesses. -/// This never mutates the host process environment. Host-spawned tasks must -/// explicitly inherit its scope. Interpreter pools cannot accept scoped work. -#[derive(Clone, Debug)] -pub struct CommandEnvironment(Arc<BTreeMap<String, String>>); - -impl CommandEnvironment { - /// Own the exact variables permitted in child processes. - #[must_use] - pub fn new(env: impl IntoIterator<Item = (String, String)>) -> Self { - Self(Arc::new(env.into_iter().collect())) - } - /// Whether this task requires its own child environment. - #[must_use] - pub fn is_active() -> bool { - ACTIVE.try_with(|_| ()).is_ok() - } - /// Run a future using this environment for builtin subprocesses. - pub async fn scope<F: Future>(&self, future: F) -> F::Output { - ACTIVE.scope(self.clone(), future).await - } - /// Read a functional child variable from the scoped map, falling back to - /// the process only when no map was supplied. - #[must_use] - pub fn var_os(name: &str) -> Option<std::ffi::OsString> { - ACTIVE - .try_with(|environment| environment.0.get(name).map(Into::into)) - .unwrap_or_else(|_| std::env::var_os(name)) - } - /// UTF-8 counterpart used by managed interpreter command builders. - pub fn var(name: &str) -> Result<String, std::env::VarError> { - ACTIVE - .try_with(|environment| { - environment - .0 - .get(name) - .cloned() - .ok_or(std::env::VarError::NotPresent) - }) - .unwrap_or_else(|_| std::env::var(name)) - } - pub(super) fn apply(command: &mut tokio::process::Command) { - let _ = ACTIVE.try_with(|environment| { - // Explicit runtime/security additions (managed PATH, scratch TMP, - // Git policy) remain authoritative over the base environment. - let explicit: Vec<_> = command - .as_std() - .get_envs() - .map(|(key, value)| (key.to_owned(), value.map(ToOwned::to_owned))) - .collect(); - command.env_clear().envs(environment.0.iter()); - for (key, value) in explicit { - match value { - Some(value) => { - command.env(key, value); - } - None => { - command.env_remove(key); - } - } - } - }); - } -} diff --git a/crates/tinytools-std/src/timeout/group_exit.rs b/crates/tinytools-std/src/timeout/group_exit.rs deleted file mode 100644 index a30cc7e1..00000000 --- a/crates/tinytools-std/src/timeout/group_exit.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Wait for signalled Linux process-group members to stop executing. -//! -//! The caller keeps the group leader unreaped during this check, reserving -//! its PID and preventing a new process group from reusing the identifier. - -/// Wait until the reserved group contains no executing Linux processes. -#[cfg(target_os = "linux")] -pub(super) async fn wait(pid: u32) -> std::io::Result<()> { - loop { - let active = tokio::task::spawn_blocking(move || active_members(pid)) - .await - .map_err(std::io::Error::other)??; - if !active { - return Ok(()); - } - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - } -} - -/// Other platforms retain their existing direct-child acknowledgement. -#[cfg(not(target_os = "linux"))] -pub(super) async fn wait(_: u32) -> std::io::Result<()> { - Ok(()) -} - -/// Inspect kernel process states without reaping the reserved group leader. -#[cfg(target_os = "linux")] -fn active_members(group: u32) -> std::io::Result<bool> { - for entry in std::fs::read_dir("/proc")? { - let entry = entry?; - if entry.file_name().to_string_lossy().parse::<u32>().is_err() { - continue; - } - // Process entries can disappear or become unreadable between - // enumeration and reading. - if let Ok(stat) = std::fs::read_to_string(entry.path().join("stat")) { - if is_active_member(&stat, group) { - return Ok(true); - } - } - } - Ok(false) -} - -/// Zombies and dead tasks cannot execute further side effects. -#[cfg(target_os = "linux")] -pub(super) fn is_active_member(stat: &str, group: u32) -> bool { - let Some((_, fields)) = stat.rsplit_once(") ") else { - return false; - }; - let mut fields = fields.split_whitespace(); - let state = fields.next(); - let process_group = fields.nth(1).and_then(|value| value.parse::<u32>().ok()); - process_group == Some(group) && !matches!(state, Some("Z" | "X" | "x")) -} - -#[cfg(all(test, target_os = "linux"))] -#[path = "group_exit_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/timeout/group_exit_tests.rs b/crates/tinytools-std/src/timeout/group_exit_tests.rs deleted file mode 100644 index 9b20c5bf..00000000 --- a/crates/tinytools-std/src/timeout/group_exit_tests.rs +++ /dev/null @@ -1,44 +0,0 @@ -#![allow(clippy::unwrap_used, clippy::expect_used)] -use super::*; - -#[test] -fn exited_members_do_not_block_cleanup_but_running_members_do() { - for state in ["R", "S", "D", "T", "I"] { - assert!(is_active_member( - &format!("12 (name with ) spaces) {state} 1 42 42"), - 42 - )); - } - for state in ["Z", "X", "x"] { - assert!(!is_active_member( - &format!("12 (sleep) {state} 1 42 42"), - 42 - )); - } - assert!(!is_active_member("12 (sleep) R 1 43 43", 42)); - assert!(!is_active_member("malformed", 42)); -} - -#[tokio::test] -async fn wait_returns_immediately_for_a_group_with_no_members() { - // PID 0x7fff_fff0 is above any pid_max, so no process group matches. - wait(0x7fff_fff0).await.unwrap(); - assert!(!active_members(0x7fff_fff0).unwrap()); -} - -#[tokio::test] -async fn wait_polls_until_the_group_has_stopped() { - let mut cmd = tokio::process::Command::new("sh"); - cmd.arg("-c").arg("sleep 0.3"); - cmd.process_group(0); - let mut child = cmd.spawn().unwrap(); - let pid = child.id().unwrap(); - assert!(active_members(pid).unwrap()); - // The group leader stays unreaped, so `wait` only returns once the - // member has exited (zombie) after polling at least once. - tokio::time::timeout(std::time::Duration::from_secs(10), wait(pid)) - .await - .expect("group stops") - .unwrap(); - child.wait().await.unwrap(); -} diff --git a/crates/tinytools-std/src/timeout/mod.rs b/crates/tinytools-std/src/timeout/mod.rs deleted file mode 100644 index c44dab49..00000000 --- a/crates/tinytools-std/src/timeout/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Tool-execution timeouts and child-process management. -//! -//! - [`settings`]: bounds, resolution and the process-global inherited timeout. -//! The host reads its config and env, then calls [`configure`]. -//! - [`output_or_kill`] / [`output_unbounded`] / [`output_with_input`]: capture a -//! command's output, killing its whole process group on a deadline or drop. -//! - [`ProcessCleanup`]: lets an embedder await reaping after cancelling a turn; -//! [`CleanupHold`] extends that wait to work a host tool hands off to its own task. -//! - [`CommandEnvironment`]: a turn-owned child environment, never the host's. - -#![allow( - clippy::missing_errors_doc, - clippy::ignored_unit_patterns, - clippy::struct_field_names, - clippy::collapsible_if -)] - -mod command_environment; -mod group_exit; -mod output; -mod process_cleanup; -mod settings; - -pub use command_environment::CommandEnvironment; -pub use output::{ - hide_console_window, kill_process_group, output_or_kill, output_unbounded, output_with_input, - own_process_group, set_task_wrapper, -}; -pub use process_cleanup::{CleanupHold, ProcessCleanup}; -pub use settings::{ - DEFAULT_TIMEOUT_SECS, MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS, ResolvedTimeout, - SANDBOX_UNBOUNDED_CAP_SECS, TOOL_TIMEOUT_GRACE_SECS, TimeoutSettings, configure, - explicit_call_timeout_duration, explicit_call_timeout_secs, override_from, - parse_tool_timeout_secs, resolve_effective, settings, -}; - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/timeout/mod_tests.rs b/crates/tinytools-std/src/timeout/mod_tests.rs deleted file mode 100644 index 83996460..00000000 --- a/crates/tinytools-std/src/timeout/mod_tests.rs +++ /dev/null @@ -1,441 +0,0 @@ -#![allow( - clippy::expect_used, - clippy::panic, - clippy::unwrap_used, - clippy::cast_sign_loss -)] -use super::*; -use std::time::Duration; -use tinytools::ToolTimeout; - -fn sh(script: &str) -> tokio::process::Command { - let mut cmd = tokio::process::Command::new("sh"); - cmd.arg("-c").arg(script); - cmd -} - -#[cfg(unix)] -fn process_exists(pid: i32) -> bool { - rustix::process::Pid::from_raw(pid) - .is_some_and(|pid| rustix::process::test_kill_process(pid).is_ok()) -} - -#[test] -fn configure_pushes_the_inherited_timeout_into_the_global() { - let global = configure(TimeoutSettings::with_inherited_secs(77)); - assert_eq!(global.inherited_timeout(), Duration::from_secs(77)); - configure(TimeoutSettings::with_inherited_secs(88)); - assert_eq!(settings().inherited_timeout(), Duration::from_secs(88)); - assert_eq!( - settings().resolve(ToolTimeout::Inherit).deadline, - Some(Duration::from_secs(88)) - ); - assert_eq!(settings().resolve(ToolTimeout::Unbounded).deadline, None); -} - -#[test] -fn default_when_env_missing() { - assert_eq!(parse_tool_timeout_secs(None), DEFAULT_TIMEOUT_SECS); -} - -#[test] -fn default_when_value_not_numeric() { - assert_eq!( - parse_tool_timeout_secs(Some("not-a-number")), - DEFAULT_TIMEOUT_SECS - ); - assert_eq!(parse_tool_timeout_secs(Some("")), DEFAULT_TIMEOUT_SECS); - assert_eq!(parse_tool_timeout_secs(Some("12x")), DEFAULT_TIMEOUT_SECS); -} - -#[test] -fn default_when_value_zero() { - // 0 seconds would disable the timeout — reject and fall back. - assert_eq!(parse_tool_timeout_secs(Some("0")), DEFAULT_TIMEOUT_SECS); -} - -#[test] -fn default_when_value_above_max() { - assert_eq!(parse_tool_timeout_secs(Some("3601")), DEFAULT_TIMEOUT_SECS); - assert_eq!( - parse_tool_timeout_secs(Some("99999999999")), - DEFAULT_TIMEOUT_SECS - ); -} - -#[test] -fn default_when_value_negative_or_signed() { - // Negative values fail u64 parse and fall back to default. - assert_eq!(parse_tool_timeout_secs(Some("-5")), DEFAULT_TIMEOUT_SECS); -} - -#[test] -fn accepts_valid_values_at_boundaries() { - assert_eq!(parse_tool_timeout_secs(Some("1")), MIN_TIMEOUT_SECS); - assert_eq!(parse_tool_timeout_secs(Some("3600")), MAX_TIMEOUT_SECS); -} - -#[test] -fn accepts_valid_midrange_value() { - assert_eq!(parse_tool_timeout_secs(Some("300")), 300); -} - -#[test] -fn env_override_takes_precedence_over_config() { - // When the env var holds a valid value it wins over the config value. - assert_eq!(resolve_effective(300, Some("600")), 600); -} - -#[test] -fn config_value_used_when_env_absent_or_invalid() { - // No env → config drives the effective value (bounded). - assert_eq!(resolve_effective(300, None), 300); - // Present-but-invalid env (non-numeric / 0 / out of range) is ignored, - // so the config value still applies. - assert_eq!(resolve_effective(300, Some("nonsense")), 300); - assert_eq!(resolve_effective(300, Some("0")), 300); - assert_eq!(resolve_effective(300, Some("4000")), 300); -} - -#[test] -fn config_value_is_bounded() { - // An out-of-range config value falls back to the default rather than - // being applied verbatim. - assert_eq!(resolve_effective(0, None), DEFAULT_TIMEOUT_SECS); - assert_eq!(resolve_effective(99_999, None), DEFAULT_TIMEOUT_SECS); -} - -#[test] -fn explicit_call_timeout_unbounded_when_absent_or_disabled() { - // No request, or an explicit 0, means "run unbounded" (None). - assert_eq!(explicit_call_timeout_secs(None, MAX_TIMEOUT_SECS), None); - assert_eq!(explicit_call_timeout_secs(Some(0), MAX_TIMEOUT_SECS), None); -} - -#[test] -fn explicit_call_timeout_enforces_and_clamps_request() { - // An in-range request is enforced verbatim. - assert_eq!( - explicit_call_timeout_secs(Some(900), MAX_TIMEOUT_SECS), - Some(900) - ); - // Below the floor clamps up to MIN. - assert_eq!( - explicit_call_timeout_secs(Some(0), MAX_TIMEOUT_SECS), - None, - "0 disables rather than clamping to MIN" - ); - // Above the cap clamps down to the cap. - assert_eq!( - explicit_call_timeout_secs(Some(99_999), MAX_TIMEOUT_SECS), - Some(MAX_TIMEOUT_SECS) - ); - // A tool with a tighter own ceiling (node/npm at 1800) clamps to it. - assert_eq!(explicit_call_timeout_secs(Some(99_999), 1800), Some(1800)); - assert_eq!(explicit_call_timeout_secs(Some(600), 1800), Some(600)); -} - -#[test] -fn explicit_call_timeout_duration_matches_secs() { - assert_eq!( - explicit_call_timeout_duration(Some(900), MAX_TIMEOUT_SECS), - Some(Duration::from_secs(900)) - ); - assert_eq!(explicit_call_timeout_duration(None, MAX_TIMEOUT_SECS), None); -} - -#[test] -fn override_from_rejects_invalid() { - assert_eq!(override_from(None), None); - assert_eq!(override_from(Some("")), None); - assert_eq!(override_from(Some("0")), None); - assert_eq!(override_from(Some("abc")), None); - assert_eq!(override_from(Some("3601")), None); - assert_eq!(override_from(Some("120")), Some(120)); -} - -/// Table of `(policy, inherited config secs, expected (deadline_ms, budget_secs))`. -/// Pins the deadline resolution. -#[test] -fn resolve_tool_deadline_table() { - use tinytools::ToolTimeout; - let ms = |m: u64| Some(Duration::from_millis(m)); - let cases: Vec<(ToolTimeout, u64, (Option<Duration>, u64))> = vec![ - // Inherit: the global config value, no grace. - (ToolTimeout::Inherit, 120, (ms(120_000), 120)), - (ToolTimeout::Inherit, 1, (ms(1_000), 1)), - (ToolTimeout::Inherit, 3600, (ms(3_600_000), 3600)), - // Unbounded: no deadline, zero budget. - (ToolTimeout::Unbounded, 120, (None, 0)), - // Millis: rounded UP to whole seconds, clamped, +5s grace on deadline. - (ToolTimeout::Millis(0), 120, (ms(6_000), 1)), - (ToolTimeout::Millis(1), 120, (ms(6_000), 1)), - (ToolTimeout::Millis(1_000), 120, (ms(6_000), 1)), - (ToolTimeout::Millis(1_001), 120, (ms(7_000), 2)), - (ToolTimeout::Millis(1_500), 120, (ms(7_000), 2)), - (ToolTimeout::Millis(30_000), 120, (ms(35_000), 30)), - (ToolTimeout::Millis(3_600_000), 120, (ms(3_605_000), 3600)), - (ToolTimeout::Millis(3_600_001), 120, (ms(3_605_000), 3600)), - (ToolTimeout::Millis(u64::MAX), 120, (ms(3_605_000), 3600)), - ]; - for (policy, inherited_secs, expected) in cases { - // Resolve against the pure helper so the process-global stays untouched. - let r = TimeoutSettings::with_inherited_secs(inherited_secs).resolve(policy); - let got = (r.deadline, r.budget_ms / 1000); - assert_eq!( - got, expected, - "policy {policy:?} inherited {inherited_secs}s" - ); - } -} - -/// Env / config precedence table for the effective inherited timeout. -#[test] -fn resolve_effective_table() { - let cases: &[(u64, Option<&str>, u64)] = &[ - (300, None, 300), - (300, Some("600"), 600), - (300, Some("1"), 1), - (300, Some("3600"), 3600), - (300, Some("3601"), 300), - (300, Some("0"), 300), - (300, Some("-1"), 300), - (300, Some(""), 300), - (300, Some("abc"), 300), - (0, None, DEFAULT_TIMEOUT_SECS), - (0, Some("45"), 45), - (3601, None, DEFAULT_TIMEOUT_SECS), - (u64::MAX, None, DEFAULT_TIMEOUT_SECS), - (1, None, 1), - (3600, None, 3600), - ]; - for &(config, env, expected) in cases { - assert_eq!( - resolve_effective(config, env), - expected, - "config {config} env {env:?}" - ); - } -} - -/// The deadline must take the command's whole process group with it. A shell -/// pipeline is grandchildren of the tool's child, and a `grep -rl … /` the tool -/// had reported as killed ran on for half an hour at a full core. -#[cfg(unix)] -#[tokio::test] -async fn a_timed_out_command_takes_its_whole_process_group_with_it() { - let dir = tempfile::tempdir() - .unwrap() - .keep() - .join(format!("oh-pgkill-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let pidfile = dir.join("grandchild.pid"); - // `sleep 30 &` is a grandchild in the shell's own process group; `wait` - // keeps the shell alive past the deadline. - let mut cmd = sh(&format!("sleep 30 & echo $! > {}; wait", pidfile.display())); - let result = output_or_kill(&mut cmd, Duration::from_millis(700)).await; - assert!(result.is_err(), "the deadline must fire on a 30s sleep"); - - let grandchild: i32 = loop { - if let Ok(text) = std::fs::read_to_string(&pidfile) { - if let Ok(pid) = text.trim().parse() { - break pid; - } - } - tokio::time::sleep(Duration::from_millis(20)).await; - }; - let mut gone = false; - for _ in 0..100 { - // kill(pid, 0) succeeds while the process (or its zombie) exists. - if !process_exists(grandchild) { - gone = true; - break; - } - // A container's PID 1 may leave a killed orphan unreaped. It cannot - // execute side effects; use the same kernel-state rule as cleanup. - #[cfg(target_os = "linux")] - if let Ok(stat) = std::fs::read_to_string(format!("/proc/{grandchild}/stat")) { - let group = rustix::process::Pid::from_raw(grandchild) - .and_then(|pid| rustix::process::getpgid(Some(pid)).ok()); - if let Some(group) = group.map(|g| g.as_raw_nonzero().get() as u32) { - if !group_exit::is_active_member(&stat, group) { - gone = true; - break; - } - } - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - let _ = std::fs::remove_dir_all(&dir); - assert!(gone, "grandchild {grandchild} survived the deadline"); -} - -/// Without a deadline the child still dies with the handle: a cancelled tool -/// future must not leave the command running either. -#[cfg(unix)] -#[tokio::test] -async fn a_shell_family_child_dies_with_a_dropped_future() { - let dir = tempfile::tempdir() - .unwrap() - .keep() - .join(format!("oh-dropkill-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let pidfile = dir.join("child.pid"); - let mut cmd = sh(&format!("echo $$ > {}; sleep 30", pidfile.display())); - // `timeout` takes the future by value and drops it when the deadline - // passes -- exactly the abandoned-future path a cancelled tool call takes. - let _ = tokio::time::timeout(Duration::from_millis(300), output_unbounded(&mut cmd)).await; - let shell: i32 = loop { - if let Ok(text) = std::fs::read_to_string(&pidfile) { - if let Ok(pid) = text.trim().parse() { - break pid; - } - } - tokio::time::sleep(Duration::from_millis(20)).await; - }; - let mut gone = false; - for _ in 0..100 { - if !process_exists(shell) { - gone = true; - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - let _ = std::fs::remove_dir_all(&dir); - assert!(gone, "shell {shell} survived its dropped future"); -} - -/// A reaped scope: after the turn future is dropped, `ProcessCleanup::wait` -/// returns only once the sleeping child group is gone. -#[cfg(target_os = "linux")] -#[tokio::test] -async fn process_cleanup_waits_for_a_cancelled_sleeping_group() { - let cleanup = ProcessCleanup::default(); - let turn = { - let cleanup = cleanup.clone(); - tokio::spawn(async move { - cleanup - .scope(async { - let mut cmd = sh("sleep 30 & wait"); - let _ = output_unbounded(&mut cmd).await; - }) - .await; - }) - }; - tokio::time::sleep(Duration::from_millis(300)).await; - turn.abort(); - let _ = turn.await; - tokio::time::timeout(Duration::from_secs(10), cleanup.wait()) - .await - .expect("cleanup completes once the group is reaped"); -} - -#[tokio::test] -async fn command_environment_replaces_the_child_environment() { - let env = CommandEnvironment::new([ - ("PATH".to_string(), "/usr/bin:/bin".to_string()), - ("ONLY_THIS".to_string(), "1".to_string()), - ]); - let out = env - .scope(async { - let mut cmd = sh("echo ${ONLY_THIS:-missing}-${HOME:-nohome}"); - output_unbounded(&mut cmd).await.unwrap() - }) - .await; - assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "1-nohome"); -} - -#[tokio::test] -async fn command_environment_scopes_variable_lookup() { - assert!(!CommandEnvironment::is_active()); - let env = CommandEnvironment::new([("SCOPED_ONLY".to_string(), "yes".to_string())]); - env.scope(async { - assert!(CommandEnvironment::is_active()); - assert_eq!( - CommandEnvironment::var_os("SCOPED_ONLY").unwrap(), - std::ffi::OsString::from("yes") - ); - assert_eq!(CommandEnvironment::var("SCOPED_ONLY").unwrap(), "yes"); - assert!(CommandEnvironment::var_os("PATH").is_none()); - assert!(CommandEnvironment::var("PATH").is_err()); - }) - .await; - assert!(CommandEnvironment::var_os("PATH").is_some()); - assert!(CommandEnvironment::var("PATH").is_ok()); -} - -#[tokio::test] -async fn explicit_command_env_overrides_and_removals_survive_the_scoped_base() { - let env = CommandEnvironment::new([ - ("PATH".to_string(), "/usr/bin:/bin".to_string()), - ("BASE".to_string(), "base".to_string()), - ("DROP".to_string(), "x".to_string()), - ]); - let out = env - .scope(async { - let mut cmd = sh("echo $BASE-$EXTRA-${DROP:-dropped}"); - cmd.env("BASE", "override") - .env("EXTRA", "e") - .env_remove("DROP"); - output_unbounded(&mut cmd).await.unwrap() - }) - .await; - assert_eq!( - String::from_utf8_lossy(&out.stdout).trim(), - "override-e-dropped" - ); -} - -#[tokio::test] -async fn process_cleanup_reports_active_only_inside_its_scope() { - assert!(!ProcessCleanup::is_active()); - let cleanup = ProcessCleanup::default(); - cleanup - .scope(async { - assert!(ProcessCleanup::is_active()); - }) - .await; - cleanup.wait().await; -} - -/// A hold taken inside a turn's scope keeps `wait` pending until dropped, so -/// detached host work can finish its own cleanup before cancellation is -/// acknowledged. A hold taken outside any scope registers nothing. -#[tokio::test] -async fn a_cleanup_hold_keeps_wait_pending_until_dropped() { - let cleanup = ProcessCleanup::default(); - let hold = cleanup.scope(async { ProcessCleanup::hold() }).await; - assert!( - tokio::time::timeout(Duration::from_millis(50), cleanup.wait()) - .await - .is_err(), - "wait must not return while the hold is alive" - ); - drop(hold); - tokio::time::timeout(Duration::from_secs(1), cleanup.wait()) - .await - .expect("wait returns once the hold is dropped"); - - let unscoped = ProcessCleanup::default(); - let _outside = ProcessCleanup::hold(); - tokio::time::timeout(Duration::from_secs(1), unscoped.wait()) - .await - .expect("a hold outside any scope registers nothing"); -} - -/// A child marked with `hide_console_window` still runs and reports its exit -/// status. On Windows this goes through the `CREATE_NO_WINDOW` branch. -#[tokio::test] -async fn hide_console_window_child_still_runs() { - #[cfg(windows)] - let mut cmd = { - let mut cmd = tokio::process::Command::new("cmd"); - cmd.args(["/C", "exit 3"]); - cmd - }; - #[cfg(not(windows))] - let mut cmd = sh("exit 3"); - hide_console_window(cmd.as_std_mut()); - let status = cmd.status().await.expect("spawn"); - assert_eq!(status.code(), Some(3)); -} diff --git a/crates/tinytools-std/src/timeout/output.rs b/crates/tinytools-std/src/timeout/output.rs deleted file mode 100644 index 7748fd1e..00000000 --- a/crates/tinytools-std/src/timeout/output.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! Child-process management: bounded capture, process-group kill, reaping. - -use std::future::Future; -use std::pin::Pin; -use std::sync::OnceLock; -use std::time::Duration; - -use super::command_environment::CommandEnvironment; -use super::group_exit; -use super::process_cleanup::Reaped; - -type BoxTask = Pin<Box<dyn Future<Output = std::io::Result<std::process::Output>> + Send>>; - -static TASK_WRAPPER: OnceLock<fn(BoxTask) -> BoxTask> = OnceLock::new(); - -/// Register a wrapper applied to every internal waiter task before it is -/// spawned, so a host can carry its ambient task-locals (request context, -/// identity) into it. First registration wins; returns whether it took effect. -pub fn set_task_wrapper(wrapper: fn(BoxTask) -> BoxTask) -> bool { - TASK_WRAPPER.set(wrapper).is_ok() -} - -fn spawn_wrapped( - fut: impl Future<Output = std::io::Result<std::process::Output>> + Send + 'static, -) -> tokio::task::JoinHandle<std::io::Result<std::process::Output>> { - let fut: BoxTask = Box::pin(fut); - tokio::spawn(match TASK_WRAPPER.get() { - Some(wrap) => wrap(fut), - None => fut, - }) -} - -/// Run `cmd` to completion with its output captured, or kill it -- and every -/// process it started -- when `deadline` passes. -/// -/// `tokio::time::timeout(deadline, cmd.output())` only abandons the future. -/// Without `kill_on_drop` the child is not even signalled, and a shell's -/// pipeline (`grep -r … | head`) is a set of grandchildren that no kill of the -/// direct child reaches anyway. One `grep -rl … /` the shell tool had reported -/// as "timed out after 600s and was killed" ran on for half an hour at a full -/// core inside a two-CPU container, starving the agent that had started it. -/// -/// So the child is spawned as the leader of its own process group and the -/// whole group is signalled when the deadline fires. The result has the same -/// shape as `timeout(deadline, cmd.output())`, so a caller's match arms do not -/// change. Stdio is set the way `output()` sets it: stdin closed, both output -/// streams captured. -pub async fn output_or_kill( - cmd: &mut tokio::process::Command, - deadline: Duration, -) -> Result<std::io::Result<std::process::Output>, tokio::time::error::Elapsed> { - tokio::time::timeout(deadline, output_unbounded(cmd)).await -} - -/// Capture a command with no deadline, killing its process group if the -/// future is dropped. The child is reaped by an owned waiter even when the -/// caller cancels; a scoped [`super::ProcessCleanup`] can await that waiter. -pub async fn output_unbounded( - cmd: &mut tokio::process::Command, -) -> std::io::Result<std::process::Output> { - output_with_input(cmd, None).await -} - -/// Capture a command, optionally supplying stdin, in the current cleanup scope. -pub async fn output_with_input( - cmd: &mut tokio::process::Command, - input: Option<Vec<u8>>, -) -> std::io::Result<std::process::Output> { - use std::process::Stdio; - cmd.stdin(if input.is_some() { - Stdio::piped() - } else { - Stdio::null() - }) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - CommandEnvironment::apply(cmd); - own_process_group(cmd.as_std_mut()); - let mut child = cmd.spawn()?; - let stdin = child.stdin.take(); - let reaped = Reaped::register(); - let (cancel, cancellation) = tokio::sync::watch::channel(false); - let waiter = spawn_wrapped(async move { - let _reaped = reaped; - let write_input = async move { - if let (Some(mut stdin), Some(input)) = (stdin, input) { - use tokio::io::AsyncWriteExt; - // An early child exit closes stdin. Its exit status/output is - // authoritative; a broken pipe must not hide it. - let _ = stdin.write_all(&input).await; - } - }; - let (_, output) = tokio::join!(write_input, collect_command_output(child, cancellation)); - output - }); - let _cancel_on_drop = CancelOnDrop(cancel); - waiter.await.map_err(std::io::Error::other)? -} - -// The caller signals only the owned waiter. It never retains a PID after -// that waiter reaps the child, so late future drops cannot kill a reused PID. -struct CancelOnDrop(tokio::sync::watch::Sender<bool>); - -impl Drop for CancelOnDrop { - fn drop(&mut self) { - self.0.send_replace(true); - } -} - -struct CommandGroup(Option<u32>); - -impl CommandGroup { - fn kill(&self) { - if let Some(pid) = self.0 { - kill_process_group(pid); - } - } -} - -impl Drop for CommandGroup { - fn drop(&mut self) { - self.kill(); - } -} - -async fn collect_command_output( - mut child: tokio::process::Child, - mut cancellation: tokio::sync::watch::Receiver<bool>, -) -> std::io::Result<std::process::Output> { - use tokio::io::AsyncReadExt; - - let mut group = CommandGroup(child.id()); - let mut stdout = child - .stdout - .take() - .ok_or_else(|| std::io::Error::other("command stdout is not piped"))?; - let mut stderr = child - .stderr - .take() - .ok_or_else(|| std::io::Error::other("command stderr is not piped"))?; - let mut stdout_bytes = Vec::new(); - let mut stderr_bytes = Vec::new(); - { - let drain = async { - tokio::try_join!( - stdout.read_to_end(&mut stdout_bytes), - stderr.read_to_end(&mut stderr_bytes), - ) - }; - tokio::pin!(drain); - tokio::select! { - biased; - _ = async { let _ = cancellation.wait_for(|cancelled| *cancelled).await; } => { - // The leader has not been reaped, even if it already exited. - // Its PID cannot be reused while signalling this group. - group.kill(); - child.start_kill()?; - if let Ok(result) = tokio::time::timeout(Duration::from_secs(2), drain).await { - result?; - } - } - result = &mut drain => { result?; } - } - } - let status = tokio::select! { - biased; - _ = async { let _ = cancellation.wait_for(|cancelled| *cancelled).await; } => { - group.kill(); - child.start_kill()?; - // Pipe EOF and the shell's exit can precede a descendant's exit. - // Keep the leader unreaped while checking the reserved group. - let stopped = match group.0 { - Some(pid) => group_exit::wait(pid).await, - None => Ok(()), - }; - let status = child.wait().await?; - // Disarm before propagating a check error after reaping: its PID - // could now be reused, including while unwinding this function. - group.0 = None; - stopped?; - status - } - result = child.wait() => result?, - }; - // No await between reaping and disarming. Only this waiter owns the PID. - group.0 = None; - Ok(std::process::Output { - status, - stdout: stdout_bytes, - stderr: stderr_bytes, - }) -} - -/// Make `cmd` the leader of a new process group when it is spawned, so that -/// [`kill_process_group`] can reach everything it starts. A no-op off Unix. -pub fn own_process_group(cmd: &mut std::process::Command) { - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } - #[cfg(not(unix))] - { - let _ = cmd; - } -} - -/// Spawn `cmd` without a console window on Windows (`CREATE_NO_WINDOW`), so a -/// GUI host's child does not flash a visible console. A no-op off Windows. -pub fn hide_console_window(cmd: &mut std::process::Command) { - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - cmd.creation_flags(0x0800_0000); - } - #[cfg(not(windows))] - { - let _ = cmd; - } -} - -/// Send SIGKILL to the process group led by `pid` -- a child spawned via -/// [`own_process_group`] and everything it started. Off Unix the direct child -/// is what `kill_on_drop` reaches and no group exists to signal. -pub fn kill_process_group(pid: u32) { - #[cfg(unix)] - { - use rustix::process::{Pid, Signal}; - let Some(pid) = i32::try_from(pid).ok().and_then(Pid::from_raw) else { - return; - }; - // A signal to a process group this process created; the kernel - // validates the target. A failure (group already gone) is expected. - let _ = rustix::process::kill_process_group(pid, Signal::KILL); - } - #[cfg(not(unix))] - { - let _ = pid; - } -} diff --git a/crates/tinytools-std/src/timeout/process_cleanup.rs b/crates/tinytools-std/src/timeout/process_cleanup.rs deleted file mode 100644 index 845f11c9..00000000 --- a/crates/tinytools-std/src/timeout/process_cleanup.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Track command reaping for an embedder that awaits turn cancellation. - -use std::future::Future; -use std::sync::{Arc, Mutex}; - -use tokio::sync::watch; - -tokio::task_local! { - static ACTIVE: Vec<ProcessCleanup>; -} - -/// Command waiters belonging to one turn. Clone before scoping the turn; -/// after dropping the turn future, [`wait`](Self::wait) awaits their reaping. -/// Tasks spawned by a host tool must explicitly inherit this scope. -#[derive(Clone, Default, Debug)] -pub struct ProcessCleanup(Arc<Mutex<Vec<watch::Receiver<bool>>>>); - -impl ProcessCleanup { - /// Whether the current task must acknowledge owned subprocess cleanup. - /// Interpreter pools have no per-job cancellation acknowledgement and - /// therefore cannot accept work from this scope. - #[must_use] - pub fn is_active() -> bool { - ACTIVE.try_with(|_| ()).is_ok() - } - - /// Run a future with command waiters registered to this turn. - pub async fn scope<T>(&self, future: impl Future<Output = T>) -> T { - let mut scopes = ACTIVE.try_with(Clone::clone).unwrap_or_default(); - scopes.push(self.clone()); - ACTIVE.scope(scopes, future).await - } - - /// Register work that the current turn's [`wait`](Self::wait) must outlast - /// until the returned hold is dropped: the explicit inheritance a host tool - /// needs when it hands a command to a task or service of its own. Take it - /// inside the scope (before spawning), move it into the detached work, and - /// drop it once that work has stopped. Outside any scope it holds nothing. - pub fn hold() -> CleanupHold { - CleanupHold { - _reaped: Reaped::register(), - } - } - - /// Wait for every registered command to exit and its output pipes to close. - /// Call only after the scoped future has finished or been dropped, so no - /// further commands can be registered by it. - pub async fn wait(&self) { - let waiters = self - .0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone(); - for mut waiter in waiters { - let _ = waiter.wait_for(|done| *done).await; - } - } -} - -/// Keeps the turn's cleanup waiting while alive. See [`ProcessCleanup::hold`]. -#[must_use = "the cleanup stops waiting as soon as the hold is dropped"] -pub struct CleanupHold { - _reaped: Reaped, -} - -impl std::fmt::Debug for CleanupHold { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CleanupHold").finish_non_exhaustive() - } -} - -pub(super) struct Reaped(watch::Sender<bool>); - -impl Reaped { - pub(super) fn register() -> Self { - let (done, waiter) = watch::channel(false); - let _ = ACTIVE.try_with(|scopes| { - for cleanup in scopes { - cleanup - .0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(waiter.clone()); - } - }); - Self(done) - } -} - -impl Drop for Reaped { - fn drop(&mut self) { - self.0.send_replace(true); - } -} diff --git a/crates/tinytools-std/src/timeout/settings.rs b/crates/tinytools-std/src/timeout/settings.rs deleted file mode 100644 index f5df16b4..00000000 --- a/crates/tinytools-std/src/timeout/settings.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! Wall-clock bounds for tool execution: pure resolution, no host policy. -//! -//! The host decides where the number comes from (config, an operator env var) -//! and pushes it in through [`configure`]; this module only bounds, resolves -//! and stores it. The inherited value lives in an atomic so a later push -//! reaches every reader on its next call. - -use std::sync::OnceLock; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; - -use tinytools::ToolTimeout; - -/// Default tool-execution timeout in seconds when nothing else is configured. -pub const DEFAULT_TIMEOUT_SECS: u64 = 120; -/// Smallest accepted timeout. `0` would disable the timeout entirely, so it is -/// rejected and falls back to the default. -pub const MIN_TIMEOUT_SECS: u64 = 1; -/// Largest accepted timeout (1 hour). -pub const MAX_TIMEOUT_SECS: u64 = 3600; -/// Effective-unbounded cap (24h) for sandbox backends, which require a finite -/// deadline. -pub const SANDBOX_UNBOUNDED_CAP_SECS: u64 = 86_400; -/// Slack added on top of an explicit per-call budget before the hard deadline -/// fires, so a tool finishing right at its budget is not killed by jitter. -pub const TOOL_TIMEOUT_GRACE_SECS: u64 = 5; - -/// A resolved per-call deadline. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ResolvedTimeout { - /// Hard deadline, or `None` to run unbounded. - pub deadline: Option<Duration>, - /// The user-facing budget in milliseconds (`0` when unbounded). - pub budget_ms: u64, -} - -/// Bounds plus the inherited timeout, with the inherited value atomic. -#[derive(Debug)] -pub struct TimeoutSettings { - inherited_ms: AtomicU64, - min_ms: u64, - max_ms: u64, - grace_ms: u64, -} - -impl TimeoutSettings { - /// Settings with explicit bounds, all in milliseconds. - #[must_use] - pub fn new(inherited_ms: u64, min_ms: u64, max_ms: u64, grace_ms: u64) -> Self { - Self { - inherited_ms: AtomicU64::new(inherited_ms), - min_ms, - max_ms, - grace_ms, - } - } - - /// Settings with the crate's default bounds, inheriting `inherited_secs`. - #[must_use] - pub fn with_inherited_secs(inherited_secs: u64) -> Self { - Self::new( - inherited_secs.saturating_mul(1000), - MIN_TIMEOUT_SECS * 1000, - MAX_TIMEOUT_SECS * 1000, - TOOL_TIMEOUT_GRACE_SECS * 1000, - ) - } - - /// The timeout `Inherit` tools get. - #[must_use] - pub fn inherited_timeout(&self) -> Duration { - Duration::from_millis(self.inherited_ms.load(Ordering::Relaxed)) - } - - /// Replace the inherited timeout; readers see it on their next call. - pub fn set_inherited_timeout_ms(&self, ms: u64) { - self.inherited_ms.store(ms, Ordering::Relaxed); - } - - /// Resolve a tool's policy. An explicit millisecond request is rounded up - /// to whole seconds, clamped to the bounds, and padded with the grace. - #[must_use] - pub fn resolve(&self, policy: ToolTimeout) -> ResolvedTimeout { - match policy { - ToolTimeout::Inherit => { - let ms = self.inherited_ms.load(Ordering::Relaxed); - ResolvedTimeout { - deadline: Some(Duration::from_millis(ms)), - budget_ms: ms, - } - } - ToolTimeout::Unbounded => ResolvedTimeout { - deadline: None, - budget_ms: 0, - }, - ToolTimeout::Millis(req) => { - let rounded = req.saturating_add(999) / 1000 * 1000; - let budget = rounded.clamp(self.min_ms, self.max_ms); - ResolvedTimeout { - deadline: Some(Duration::from_millis(budget.saturating_add(self.grace_ms))), - budget_ms: budget, - } - } - } - } -} - -static SETTINGS: OnceLock<TimeoutSettings> = OnceLock::new(); - -/// Install the process-global settings. The first call installs `settings`; -/// later calls only push its inherited timeout into the installed one, so -/// handles obtained earlier stay live. Returns the global. -pub fn configure(settings: TimeoutSettings) -> &'static TimeoutSettings { - let ms = settings.inherited_ms.load(Ordering::Relaxed); - let global = SETTINGS.get_or_init(|| settings); - global.set_inherited_timeout_ms(ms); - log::debug!("[tool_timeout] configured inherited timeout {ms}ms"); - global -} - -/// The process-global settings, seeded with [`DEFAULT_TIMEOUT_SECS`] when the -/// host never called [`configure`]. -#[must_use] -pub fn settings() -> &'static TimeoutSettings { - SETTINGS.get_or_init(|| TimeoutSettings::with_inherited_secs(DEFAULT_TIMEOUT_SECS)) -} - -/// Parse a raw value into a bounded timeout; anything missing, non-numeric or -/// outside `MIN_TIMEOUT_SECS..=MAX_TIMEOUT_SECS` yields [`DEFAULT_TIMEOUT_SECS`]. -#[must_use] -pub fn parse_tool_timeout_secs(raw: Option<&str>) -> u64 { - override_from(raw).unwrap_or(DEFAULT_TIMEOUT_SECS) -} - -/// A raw operator override, if it is a valid in-range number of seconds. -#[must_use] -pub fn override_from(raw: Option<&str>) -> Option<u64> { - raw.and_then(|s| s.parse::<u64>().ok()) - .filter(|&n| (MIN_TIMEOUT_SECS..=MAX_TIMEOUT_SECS).contains(&n)) -} - -/// Env override wins when valid; otherwise the (bounded) config value applies. -#[must_use] -pub fn resolve_effective(config_secs: u64, env_raw: Option<&str>) -> u64 { - override_from(env_raw) - .unwrap_or_else(|| parse_tool_timeout_secs(Some(&config_secs.to_string()))) -} - -/// Resolve an explicit per-call request for an otherwise unbounded tool. -/// `None`/`Some(0)` run unbounded; otherwise clamp to `MIN..=cap`. -#[must_use] -pub fn explicit_call_timeout_secs(requested: Option<u64>, cap: u64) -> Option<u64> { - let cap = cap.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS); - match requested { - None | Some(0) => None, - Some(n) => Some(n.clamp(MIN_TIMEOUT_SECS, cap)), - } -} - -/// [`explicit_call_timeout_secs`] as a [`Duration`]. -#[must_use] -pub fn explicit_call_timeout_duration(requested: Option<u64>, cap: u64) -> Option<Duration> { - explicit_call_timeout_secs(requested, cap).map(Duration::from_secs) -} diff --git a/crates/tinytools-std/src/url_guard/README.md b/crates/tinytools-std/src/url_guard/README.md deleted file mode 100644 index 81c41b8a..00000000 --- a/crates/tinytools-std/src/url_guard/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# URL guard - -This module provides the URL policy shared by outbound network tools. It -accepts HTTP and HTTPS URLs, applies an optional domain allowlist, rejects -local and non-global IP destinations, and checks DNS answers for rebinding. - -## Public surface - -- `validate_url` checks syntax, host policy, and local-address rules. -- `validate_url_with_dns_check` also resolves the host and returns a - `ValidatedUrl` containing the vetted socket addresses. -- `normalize_allowed_domains`, `normalize_domain`, and - `host_matches_allowlist` support host configuration. -- `is_private_or_local_host`, `is_non_global_v4`, and `is_non_global_v6` - expose the address classification used by the guard. - -## Security and operational constraints - -Callers making outbound requests should use `validate_url_with_dns_check` and -pin the connection to every address in `ValidatedUrl::addrs`. Resolving the -hostname again in an HTTP client reopens the DNS-rebinding gap. Keep the URL's -hostname for TLS SNI and the Host header. Validate every redirect destination -before following it. - -The guard rejects loopback, private, link-local, multicast, documentation, -shared-address, local names, IPv4-mapped and IPv4-compatible IPv6, private -IPv4 destinations embedded in NAT64 and 6to4 addresses, private Teredo client -addresses, and NAT64 translation prefixes. A Teredo server address is not the -IPv4 destination represented by the endpoint. -Its lexical checks reject userinfo, backslashes, percent-encoded hosts, and -IPv6 URL literals because downstream URL parsers can interpret those forms -differently. This crate supplies no HTTP transport, so connection pinning and -redirect policy remain the caller's responsibility. diff --git a/crates/tinytools-std/src/url_guard/mod.rs b/crates/tinytools-std/src/url_guard/mod.rs deleted file mode 100644 index 18b5ecd7..00000000 --- a/crates/tinytools-std/src/url_guard/mod.rs +++ /dev/null @@ -1,509 +0,0 @@ -//! Shared URL validation + SSRF guards for outbound network tools. -//! -//! Used by `http_request`, `curl`, and any future tool that takes a -//! user-supplied URL. Two allowlist modes: -//! -//! - **Open allowlist** (`allowed_domains` is empty): any public non-private -//! host is permitted. All SSRF guards still apply (loopback / RFC1918 / -//! link-local / multicast / documentation / shared-address / -//! IPv4-mapped and transition IPv6, `localhost` / `*.localhost` / `*.local`). -//! - **Strict allowlist** (`allowed_domains` is non-empty): only the listed -//! domains and their subdomains are permitted. -//! -//! Both modes enforce: http(s) only, no whitespace, no userinfo, no IPv6 hosts, -//! no backslash anywhere, and no percent-encoding in the host — the last two -//! because a WHATWG parser would read them as a different host than the one -//! checked here. -//! -//! **Alternate IP notations** (octal, hex, decimal): Rust's `IpAddr::parse` -//! rejects them so they are treated as plain hostnames. In strict-allowlist -//! mode they are rejected by the domain check. In open-allowlist mode they -//! pass `validate_url` but are caught by `validate_url_with_dns_check` -//! because they fail real-world DNS resolution. -//! -//! ## DNS Rebinding -//! -//! Hostname validation alone is insufficient: an attacker can register a -//! domain that alternates DNS responses between a public IP (passing the -//! allowlist) and a private IP (e.g. 127.0.0.1). -//! [`validate_url_with_dns_check`] resolves the hostname, vets every -//! resolved IP, and returns them in a [`ValidatedUrl`]. That closes the gap -//! **only if the caller connects to [`ValidatedUrl::addrs`]** — for example -//! via `reqwest::ClientBuilder::resolve_to_addrs` — rather than letting its -//! HTTP client resolve the hostname a second time. This crate carries no -//! HTTP client, so the pinning is the caller's responsibility. The network -//! tools in [`crate::network`] do pin: they connect only to the vetted -//! addresses, which are either the ones this module resolved or the ones the -//! host's [`NetGate::authorize_destination`](crate::network::NetGate::authorize_destination) -//! authorized. - -use std::future::Future; -use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; - -/// Validate a URL against the allowlist + SSRF rules. Returns the -/// original URL on success. -/// -/// # Errors -/// -/// Fails when the URL is empty, contains whitespace or a backslash, is not -/// `http(s)`, has percent-encoding in its host, names a local/private host, -/// or (in strict mode) is outside the allowlist. -pub fn validate_url(raw_url: &str, allowed_domains: &[String]) -> anyhow::Result<String> { - let url = raw_url.trim(); - - if url.is_empty() { - anyhow::bail!("URL cannot be empty"); - } - - if url.chars().any(char::is_whitespace) { - anyhow::bail!("URL cannot contain whitespace"); - } - - if !url.starts_with("http://") && !url.starts_with("https://") { - anyhow::bail!("Only http:// and https:// URLs are allowed"); - } - - reject_backslash(url)?; - - let host = extract_host(url)?; - - if is_private_or_local_host(&host) { - log::debug!( - "[url_guard] ssrf block: host={host} mode={}", - if allowed_domains.is_empty() { - "open" - } else { - "strict" - } - ); - anyhow::bail!("Blocked local/private host: {host}"); - } - - // Empty allowed_domains = open mode: any public non-private host is - // permitted (same as ["*"]). This ensures the http_request tool works - // out of the box regardless of whether the user configured an explicit - // domain list, and keeps web-fetch consistent across routing paths. - // A non-empty list = strict mode: only listed domains pass. (#2700) - if !allowed_domains.is_empty() && !host_matches_allowlist(&host, allowed_domains) { - log::debug!( - "[url_guard] strict-allowlist rejection: host={host} allowed={allowed_domains:?}" - ); - anyhow::bail!( - "I'm not allowed to open '{host}' — it isn't in your allowed websites. \ - Add it (or turn on \"Allow all sites\") under \ - Settings → Advanced → Search engine → Allowed websites, then ask me again." - ); - } - - log::debug!( - "[url_guard] validate_url ok: host={host} mode={}", - if allowed_domains.is_empty() { - "open" - } else { - "strict" - } - ); - - Ok(url.to_string()) -} - -/// A URL that passed [`validate_url_with_dns_check`], together with the -/// exact socket addresses that were vetted. -/// -/// The addresses are the point: DNS can answer differently the next time it -/// is asked, so a client that re-resolves `host` may connect somewhere that -/// was never checked. Pin the connection to [`addrs`](Self::addrs) instead — -/// for example with `reqwest::ClientBuilder::resolve_to_addrs(&host, &addrs)` -/// — and keep `url` unchanged so TLS SNI and the `Host` header still name -/// `host`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ValidatedUrl { - /// The validated URL, trimmed, otherwise exactly as supplied. - pub url: String, - /// The host exactly as the HTTP client will look it up — the key to pin - /// [`addrs`](Self::addrs) under. It differs from the validated host for a - /// trailing-dot name (`example.com.`), which the client resolves as - /// written; pinning the dot-less name would miss and let it re-resolve. - pub host: String, - /// Every address `host` resolved to, each paired with the URL's port; - /// all are public. For an IP-literal host this is that single address. - pub addrs: Vec<SocketAddr>, -} - -impl ValidatedUrl { - /// The validated URL, retaining its hostname as the request authority. - #[must_use] - pub fn url(&self) -> &str { - &self.url - } - - /// Vetted connection destinations to pin the HTTP client to. - #[must_use] - pub fn addresses(&self) -> &[SocketAddr] { - &self.addrs - } -} - -/// Like [`validate_url`] but also resolves the hostname via DNS and -/// verifies that none of the resolved IPs are private/local. -/// -/// This only defends against DNS rebinding — an attacker's domain answering -/// with a public IP here and 127.0.0.1 at request time — when the caller -/// connects to the returned [`ValidatedUrl::addrs`] rather than resolving -/// the hostname again. Callers should use this function instead of -/// `validate_url` in all paths that make outbound HTTP requests, and pin -/// the connection as described on [`ValidatedUrl`]. -/// -/// # Errors -/// -/// Everything [`validate_url`] rejects, plus DNS failure, an empty answer, or -/// any resolved address that is private/local. -pub async fn validate_url_with_dns_check( - raw_url: &str, - allowed_domains: &[String], -) -> anyhow::Result<ValidatedUrl> { - validate_url_with_dns_check_with_resolver(raw_url, allowed_domains, resolve_host_ips).await -} - -async fn validate_url_with_dns_check_with_resolver<F, Fut>( - raw_url: &str, - allowed_domains: &[String], - resolver: F, -) -> anyhow::Result<ValidatedUrl> -where - F: FnOnce(String, u16) -> Fut, - Fut: Future<Output = anyhow::Result<Vec<IpAddr>>>, -{ - let url = validate_url(raw_url, allowed_domains)?; - - let host = extract_host(&url)?; - - let port = extract_port(&url)?; - - // If the host is already a valid IP literal, `is_private_or_local_host` - // has already checked it above. We only need DNS resolution for hostnames. - if let Ok(ip) = host.parse::<IpAddr>() { - return Ok(ValidatedUrl { - host: connect_host(&url)?, - url, - addrs: vec![SocketAddr::new(ip, port)], - }); - } - - log::debug!("[url_guard] resolving DNS for host={host} port={port}"); - let addrs = resolver(host.clone(), port).await?; - - if addrs.is_empty() { - anyhow::bail!("DNS resolution returned no addresses for '{host}'"); - } - - log::debug!("[url_guard] DNS resolved host={host} addrs={}", addrs.len()); - - for addr in &addrs { - let ip_str = addr.to_string(); - if is_private_or_local_host(&ip_str) { - log::debug!("[url_guard] DNS rebinding blocked host={host} resolved_ip={ip_str}"); - anyhow::bail!( - "DNS rebinding blocked: '{host}' resolved to private/local address {ip_str}" - ); - } - } - - Ok(ValidatedUrl { - host: connect_host(&url)?, - url, - addrs: addrs - .into_iter() - .map(|ip| SocketAddr::new(ip, port)) - .collect(), - }) -} - -/// The host `url` names as the HTTP client sees it: lowercased, punycoded, -/// and with any trailing dot kept. This is the name the client resolves, so -/// it is the name a pinned address must be registered under. -pub(crate) fn connect_host(url: &str) -> anyhow::Result<String> { - reqwest::Url::parse(url)? - .host_str() - .map(str::to_string) - .ok_or_else(|| anyhow::anyhow!("URL must include a host")) -} - -async fn resolve_host_ips(host: String, port: u16) -> anyhow::Result<Vec<IpAddr>> { - let log_host = host.clone(); - tokio::task::spawn_blocking(move || { - (host.as_str(), port) - .to_socket_addrs() - .map_err(|e| { - log::debug!("[url_guard] DNS resolution failed host={host} port={port} error={e}"); - anyhow::anyhow!("DNS resolution failed for '{host}': {e}") - }) - .map(|iter| iter.map(|addr| addr.ip()).collect()) - }) - .await - .map_err(|e| { - log::debug!("[url_guard] DNS resolution task failed host={log_host} port={port} error={e}"); - anyhow::anyhow!("DNS resolution task failed for '{log_host}': {e}") - })? -} - -#[must_use] -/// Normalise an allowlist: strip scheme/path, lowercase, drop invalid entries and -/// duplicates. An empty result means open mode. -pub fn normalize_allowed_domains(domains: Vec<String>) -> Vec<String> { - if domains.is_empty() { - return Vec::new(); - } - let mut normalized = domains - .into_iter() - .filter_map(|d| normalize_domain(&d)) - .collect::<Vec<_>>(); - normalized.sort_unstable(); - normalized.dedup(); - if normalized.is_empty() { - // All entries were malformed (whitespace-only, scheme-only, etc.) and - // filtered out. Returning empty would silently enter open mode; instead - // return a sentinel that keeps the tool in strict mode and rejects every - // URL — fail-closed on misconfiguration. (#2738) - log::warn!( - "[url_guard] all configured allowed_domains entries are invalid — \ - treating as misconfigured allowlist (fail-closed)" - ); - return vec!["<misconfigured-allowlist>".to_string()]; - } - normalized -} - -/// Normalise one allowlist entry to a bare lowercase host, or `None` if invalid. -pub fn normalize_domain(raw: &str) -> Option<String> { - let mut d = raw.trim().to_lowercase(); - if d.is_empty() { - return None; - } - - if let Some(stripped) = d.strip_prefix("https://") { - d = stripped.to_string(); - } else if let Some(stripped) = d.strip_prefix("http://") { - d = stripped.to_string(); - } - - if let Some((host, _)) = d.split_once('/') { - d = host.to_string(); - } - - d = d.trim_start_matches('.').trim_end_matches('.').to_string(); - - if let Some((host, _)) = d.split_once(':') { - d = host.to_string(); - } - - if d.is_empty() || d.chars().any(char::is_whitespace) { - return None; - } - - Some(d) -} - -/// Refuse a URL containing `\` anywhere. WHATWG URL parsers (browsers, -/// `reqwest`'s `url` crate) treat `\` as a path separator in `http(s)` URLs, -/// so `http://127.0.0.1\.example.com/` names `127.0.0.1` to a real client -/// while a naive split on `/` would see a subdomain of `example.com`. -fn reject_backslash(url: &str) -> anyhow::Result<()> { - if url.contains('\\') { - anyhow::bail!("URL cannot contain a backslash"); - } - Ok(()) -} - -/// Split an `http(s)` URL into whether it is plain `http` and its authority -/// (`host[:port]`), refusing inputs a WHATWG parser would read differently. -fn split_authority(url: &str) -> anyhow::Result<(bool, &str)> { - let (is_http, rest) = if let Some(rest) = url.strip_prefix("http://") { - (true, rest) - } else if let Some(rest) = url.strip_prefix("https://") { - (false, rest) - } else { - anyhow::bail!("Only http:// and https:// URLs are allowed"); - }; - - reject_backslash(url)?; - - let authority = rest - .split(['/', '?', '#']) - .next() - .ok_or_else(|| anyhow::anyhow!("Invalid URL"))?; - - // WHATWG percent-decodes the host, so `%31%32%37.0.0.1` is 127.0.0.1 on - // the wire while the literal text matches neither the SSRF checks nor - // the allowlist. - if authority.contains('%') { - anyhow::bail!("URL host cannot contain percent-encoded characters"); - } - - if authority.starts_with('[') { - anyhow::bail!("IPv6 hosts are not supported in http_request"); - } - - Ok((is_http, authority)) -} - -/// Extract the host part of an `http(s)` URL. -/// -/// # Errors -/// -/// Fails on a missing/empty host, userinfo, an IPv6 literal, a backslash -/// anywhere in the URL, or percent-encoding in the authority. -pub fn extract_host(url: &str) -> anyhow::Result<String> { - let (_, authority) = split_authority(url)?; - - if authority.is_empty() { - anyhow::bail!("URL must include a host"); - } - - if authority.contains('@') { - anyhow::bail!("URL userinfo is not allowed"); - } - - let host = authority - .split(':') - .next() - .unwrap_or_default() - .trim() - .trim_end_matches('.') - .to_lowercase(); - - if host.is_empty() { - anyhow::bail!("URL must include a valid host"); - } - - Ok(host) -} - -/// Extract the explicit or scheme-default port of an `http(s)` URL. -/// -/// # Errors -/// -/// Fails when the URL has no valid port, is an IPv6 literal, contains a -/// backslash, or has percent-encoding in the authority. -pub fn extract_port(url: &str) -> anyhow::Result<u16> { - let (is_http, authority) = split_authority(url)?; - - if let Some((_, port)) = authority.rsplit_once(':') { - if port.is_empty() || !port.chars().all(|ch| ch.is_ascii_digit()) { - anyhow::bail!("URL port must be numeric"); - } - return port - .parse::<u16>() - .map_err(|_| anyhow::anyhow!("URL port is out of range")); - } - - Ok(if is_http { 80 } else { 443 }) -} - -#[must_use] -/// Whether `host` equals, or is a subdomain of, an allowlist entry. -pub fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool { - allowed_domains.iter().any(|domain| { - // `"*"` is the explicit allow-all wildcard (the "Allow all sites" - // toggle), mirroring the browser tool. Local/private hosts are still - // rejected upstream by `is_private_or_local_host`, so a wildcard only - // opens *public* hosts, never the loopback/RFC1918 SSRF surface. - domain == "*" - || host == domain - || host - .strip_suffix(domain) - .is_some_and(|prefix| prefix.ends_with('.')) - }) -} - -#[must_use] -/// Whether `host` is a local name or resolves lexically to a non-global address. -pub fn is_private_or_local_host(host: &str) -> bool { - let unbracketed = host - .strip_prefix('[') - .and_then(|h| h.strip_suffix(']')) - .unwrap_or(host); - let bare = unbracketed.strip_suffix('.').unwrap_or(unbracketed); - - let lower = bare.to_ascii_lowercase(); - - let has_local_tld = lower - .rsplit('.') - .next() - .is_some_and(|label| label == "local"); - - if lower == "localhost" || lower.ends_with(".localhost") || has_local_tld { - return true; - } - - if let Ok(ip) = bare.parse::<std::net::IpAddr>() { - return match ip { - std::net::IpAddr::V4(v4) => is_non_global_v4(v4), - std::net::IpAddr::V6(v6) => is_non_global_v6(v6), - }; - } - - false -} - -#[must_use] -/// Whether an IPv4 address is non-global (loopback, private, link-local, ...). -pub fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { - let [a, b, c, _] = v4.octets(); - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_unspecified() - || v4.is_broadcast() - || v4.is_multicast() - || (a == 100 && (64..=127).contains(&b)) - || a >= 240 - || (a == 192 && b == 0 && c == 0) - || (a == 192 && b == 88 && c == 99) - || (a == 198 && b == 51 && c == 100) - || (a == 203 && b == 0 && c == 113) - || (a == 198 && (18..=19).contains(&b)) - // 0.0.0.0/8 — "this network" (RFC 1122 §3.2.1.3). `is_unspecified()` only - // covers 0.0.0.0 itself, but the whole /8 routes to the local host on - // Linux. Carried over from the `ops_install` copy this replaces. - || a == 0 -} - -/// Whether an IPv6 address is non-global, including private IPv4 destinations -/// embedded in transition addresses. -pub fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { - let segs = v6.segments(); - let embedded_v4 = |hi: u16, lo: u16| { - let [first, second] = hi.to_be_bytes(); - let [third, fourth] = lo.to_be_bytes(); - is_non_global_v4(std::net::Ipv4Addr::new(first, second, third, fourth)) - }; - v6.is_loopback() - || v6.is_unspecified() - || v6.is_multicast() - || (segs[0] & 0xfe00) == 0xfc00 - || (segs[0] & 0xffc0) == 0xfe80 - || (segs[0] == 0x2001 && segs[1] == 0x0db8) - || (segs[0] == 0x0100 && segs[1] == 0 && segs[2] == 0 && segs[3] <= 1) - || (segs[0] == 0x2001 && segs[1] == 0x0002 && segs[2] == 0) - // Local-use translation (RFC 8215) and the well-known NAT64 prefix - // can embed addresses that translate to private IPv4 destinations. - || (segs[0] == 0x0064 && segs[1] == 0xff9b && segs[2] == 1) - || (segs[0] == 0x0064 - && segs[1] == 0xff9b - && segs[2..6] == [0; 4] - && embedded_v4(segs[6], segs[7])) - // 6to4 carries the destination IPv4 address immediately after 2002::/16. - || (segs[0] == 0x2002 && embedded_v4(segs[1], segs[2])) - // Teredo carries a server IPv4 address, but the destination is the - // client's XOR-obfuscated IPv4 address in the last two segments. - || (segs[..2] == [0x2001, 0] && embedded_v4(!segs[6], !segs[7])) - || (segs[0] & 0xfff0) == 0x3ff0 - || segs[0] == 0x5f00 - // `to_ipv4` covers both mapped and deprecated compatible addresses. - || v6.to_ipv4().is_some_and(is_non_global_v4) -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/url_guard/mod_tests.rs b/crates/tinytools-std/src/url_guard/mod_tests.rs deleted file mode 100644 index ecf14fce..00000000 --- a/crates/tinytools-std/src/url_guard/mod_tests.rs +++ /dev/null @@ -1,790 +0,0 @@ -//! Unit tests for URL validation, SSRF host classification, and the DNS -//! check's vetted addresses. - -use super::*; - -/// Turns an expected rejection into its message, and an unexpected success -/// into a test failure, without `unwrap_err`. -trait Rejection { - fn rejection(self) -> anyhow::Result<String>; -} - -impl<T: std::fmt::Debug> Rejection for anyhow::Result<T> { - fn rejection(self) -> anyhow::Result<String> { - match self { - Ok(value) => anyhow::bail!("expected a rejection, got {value:?}"), - Err(err) => Ok(err.to_string()), - } - } -} - -#[test] -fn normalize_domain_strips_scheme_path_and_case() { - let got = normalize_domain(" HTTPS://Docs.Example.com/path "); - assert_eq!(got.as_deref(), Some("docs.example.com")); -} - -#[test] -fn normalizes_http_domains_and_rejects_empty_hosts() { - assert_eq!( - normalize_domain("http://Example.com:8080/path"), - Some("example.com".into()) - ); - assert_eq!(normalize_domain("https://"), None); - assert!(extract_host("http:///path").is_err()); - assert!(extract_host("http://:80/path").is_err()); -} - -#[test] -fn rejects_malformed_ports() { - assert!(extract_port("http://example.com:abc").is_err()); - assert!(extract_port("http://example.com:65536").is_err()); -} - -#[tokio::test] -async fn system_dns_resolves_numeric_loopback_without_external_network() -> anyhow::Result<()> { - let resolved = super::resolve_host_ips("127.0.0.1".to_string(), 80).await?; - assert_eq!(resolved, vec!["127.0.0.1".parse::<std::net::IpAddr>()?]); - assert!(super::resolve_host_ips(String::new(), 80).await.is_err()); - Ok(()) -} - -#[test] -fn normalize_allowed_domains_deduplicates() { - let got = normalize_allowed_domains(vec![ - "example.com".into(), - "EXAMPLE.COM".into(), - "https://example.com/".into(), - ]); - assert_eq!(got, vec!["example.com".to_string()]); -} - -#[test] -fn validate_accepts_exact_domain() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let got = validate_url("https://example.com/docs", &allow)?; - assert_eq!(got, "https://example.com/docs"); - Ok(()) -} - -#[test] -fn validate_accepts_http() { - let allow = vec!["example.com".to_string()]; - assert!(validate_url("http://example.com", &allow).is_ok()); -} - -#[test] -fn validate_accepts_subdomain() { - let allow = vec!["example.com".to_string()]; - assert!(validate_url("https://api.example.com/v1", &allow).is_ok()); -} - -#[test] -fn validate_rejects_allowlist_miss() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("https://google.com", &allow).rejection()?; - assert!(err.contains("allowed websites")); - Ok(()) -} - -#[test] -fn validate_wildcard_allows_any_public_host() { - let allow = vec!["*".to_string()]; - assert!(validate_url("https://example.com/docs", &allow).is_ok()); - assert!(validate_url("https://www.cnbc.com/markets", &allow).is_ok()); - assert!(validate_url("https://sub.deep.example.org", &allow).is_ok()); -} - -#[test] -fn validate_wildcard_still_blocks_local_and_private() -> anyhow::Result<()> { - // "Allow all sites" must NOT defeat the SSRF guard. - let allow = vec!["*".to_string()]; - assert!( - validate_url("https://localhost:8080", &allow) - .rejection()? - .contains("local/private") - ); - assert!( - validate_url("https://192.168.1.5", &allow) - .rejection()? - .contains("local/private") - ); - Ok(()) -} - -#[test] -fn validate_rejects_localhost() -> anyhow::Result<()> { - let allow = vec!["localhost".to_string()]; - let err = validate_url("https://localhost:8080", &allow).rejection()?; - assert!(err.contains("local/private")); - Ok(()) -} - -#[test] -fn validate_rejects_private_ipv4() -> anyhow::Result<()> { - let allow = vec!["192.168.1.5".to_string()]; - let err = validate_url("https://192.168.1.5", &allow).rejection()?; - assert!(err.contains("local/private")); - Ok(()) -} - -#[test] -fn validate_rejects_whitespace() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("https://example.com/hello world", &allow).rejection()?; - assert!(err.contains("whitespace")); - Ok(()) -} - -#[test] -fn validate_rejects_userinfo() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("https://user@example.com", &allow).rejection()?; - assert!(err.contains("userinfo")); - Ok(()) -} - -// Empty allowed_domains = open mode: any public host is permitted. -// This keeps web-fetch working when no domain list is configured and -// makes behaviour consistent between default and external-LLM routing. -// (#2700) -#[test] -fn validate_empty_allowlist_allows_public_host() { - assert!(validate_url("https://example.com", &[]).is_ok()); - assert!(validate_url("https://www.cnbc.com/markets", &[]).is_ok()); -} - -#[test] -fn validate_empty_allowlist_still_blocks_private_hosts() -> anyhow::Result<()> { - let err = validate_url("https://192.168.1.5", &[]).rejection()?; - assert!(err.contains("local/private")); - - let err = validate_url("https://localhost", &[]).rejection()?; - assert!(err.contains("local/private")); - Ok(()) -} - -// ── normalize_allowed_domains: fail-closed on malformed-only input ── - -#[test] -fn normalize_all_invalid_entries_stays_fail_closed() { - // A non-empty list that fully normalizes to nothing must NOT produce - // an empty slice (which would silently enter open mode). (#2738) - let got = normalize_allowed_domains(vec![" ".into(), "https://".into()]); - assert!( - !got.is_empty(), - "normalized result must be non-empty to stay in strict mode" - ); - // The sentinel must not match any real public host. - assert!( - !host_matches_allowlist("example.com", &got), - "sentinel must not grant access to real hosts" - ); - assert!( - !host_matches_allowlist("api.example.com", &got), - "sentinel must not grant access to subdomains" - ); -} - -#[test] -fn normalize_empty_input_stays_empty_for_open_mode() { - // Explicitly empty input should return empty (open mode is intentional). - assert_eq!(normalize_allowed_domains(vec![]).len(), 0); -} - -#[tokio::test] -async fn dns_check_with_empty_allowlist_allows_public_resolved_host() -> anyhow::Result<()> { - // Open mode (empty allowlist) must still pass DNS check for public IPs. - let got = validate_url_with_dns_check_with_resolver( - "https://example.com", - &[], - |host, port| async move { - assert_eq!(host, "example.com"); - assert_eq!(port, 443); - Ok(vec!["93.184.216.34".parse()?]) - }, - ) - .await?; - assert_eq!(got.url, "https://example.com"); - Ok(()) -} - -#[tokio::test] -async fn dns_check_with_empty_allowlist_blocks_private_resolved_ip() -> anyhow::Result<()> { - // Even in open mode, DNS rebinding to a private IP must be blocked. - let err = validate_url_with_dns_check_with_resolver("https://example.com", &[], |_, _| async { - Ok(vec!["10.0.0.1".parse()?]) - }) - .await - .rejection()?; - assert!(err.contains("DNS rebinding blocked")); - Ok(()) -} - -#[tokio::test] -async fn dns_check_resolver_failure_is_a_refusal_not_a_pass_through() -> anyhow::Result<()> { - // A resolver error (NXDOMAIN, network down, timeout) must refuse the - // fetch, not fall back to treating the host as unresolved-and-therefore- - // allowed. - let err = validate_url_with_dns_check_with_resolver( - "https://this-host-does-not-exist.invalid", - &[], - |host, _port| async move { anyhow::bail!("DNS resolution failed for '{host}': NXDOMAIN") }, - ) - .await - .rejection()?; - assert!(err.contains("DNS resolution failed")); - Ok(()) -} - -#[tokio::test] -async fn dns_check_resolver_returning_no_addresses_is_a_refusal() -> anyhow::Result<()> { - // A resolver that answers with zero addresses (some stub resolvers do - // this instead of erroring) must not be treated as "no IPs to check, - // therefore allowed". - let err = validate_url_with_dns_check_with_resolver("https://example.com", &[], |_, _| async { - Ok(Vec::new()) - }) - .await - .rejection()?; - assert!(err.contains("DNS resolution returned no addresses")); - Ok(()) -} - -#[test] -fn validate_rejects_ftp_scheme() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("ftp://example.com", &allow).rejection()?; - assert!(err.contains("http://") || err.contains("https://")); - Ok(()) -} - -#[test] -fn validate_rejects_empty_url() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("", &allow).rejection()?; - assert!(err.contains("empty")); - Ok(()) -} - -#[test] -fn validate_rejects_ipv6_host() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("http://[::1]:8080/path", &allow).rejection()?; - assert!(err.contains("IPv6")); - Ok(()) -} - -#[test] -fn blocks_multicast_ipv4() { - assert!(is_private_or_local_host("224.0.0.1")); - assert!(is_private_or_local_host("239.255.255.255")); -} - -#[test] -fn blocks_broadcast() { - assert!(is_private_or_local_host("255.255.255.255")); -} - -#[test] -fn blocks_reserved_ipv4() { - assert!(is_private_or_local_host("240.0.0.1")); - assert!(is_private_or_local_host("250.1.2.3")); -} - -#[test] -fn blocks_documentation_ranges() { - // TEST-NET-1 is globally routable in this policy; only TEST-NET-2 and - // TEST-NET-3 are classified as non-global here. - assert!(!is_private_or_local_host("192.0.2.1")); - assert!(is_private_or_local_host("198.51.100.1")); - assert!(is_private_or_local_host("203.0.113.1")); -} - -#[test] -fn blocks_benchmarking_range() { - assert!(is_private_or_local_host("198.18.0.1")); - assert!(is_private_or_local_host("198.19.255.255")); -} - -#[test] -fn blocks_ipv6_localhost() { - assert!(is_private_or_local_host("::1")); - assert!(is_private_or_local_host("[::1]")); -} - -#[test] -fn blocks_ipv6_multicast() { - assert!(is_private_or_local_host("ff02::1")); -} - -#[test] -fn blocks_ipv6_link_local() { - assert!(is_private_or_local_host("fe80::1")); -} - -#[test] -fn blocks_ipv6_unique_local() { - assert!(is_private_or_local_host("fd00::1")); -} - -#[test] -fn blocks_ipv4_mapped_ipv6() { - assert!(is_private_or_local_host("::ffff:127.0.0.1")); - assert!(is_private_or_local_host("::ffff:192.168.1.1")); - assert!(is_private_or_local_host("::ffff:10.0.0.1")); -} - -#[test] -fn allows_public_ipv4() { - assert!(!is_private_or_local_host("8.8.8.8")); - assert!(!is_private_or_local_host("1.1.1.1")); - assert!(!is_private_or_local_host("93.184.216.34")); -} - -#[test] -fn blocks_ipv6_documentation_range() { - assert!(is_private_or_local_host("2001:db8::1")); -} - -#[test] -fn blocks_nat64_translation_prefixes() { - assert!(is_private_or_local_host("64:ff9b:1::7f00:1")); - assert!(is_private_or_local_host("64:ff9b::7f00:1")); - assert!(!is_private_or_local_host("64:ff9b::808:808")); - assert!(!is_private_or_local_host("2001:4860:4860::8888")); -} - -#[test] -fn classifies_well_known_nat64_embedded_addresses() { - assert!(is_private_or_local_host("64:ff9b::a00:1")); - assert!(is_private_or_local_host("64:ff9b::7f00:1")); - assert!(is_private_or_local_host("64:ff9b::c633:6401")); - assert!(!is_private_or_local_host("64:ff9b::808:808")); - // This is outside the exact /96 translation prefix. - assert!(!is_private_or_local_host("64:ff9b:0:0:0:1:a00:1")); -} - -#[test] -fn classifies_6to4_embedded_destinations() { - assert!(is_private_or_local_host("2002:a00:1::")); - assert!(is_private_or_local_host("2002:7f00:1::")); - assert!(!is_private_or_local_host("2002:808:808::")); -} - -#[test] -fn classifies_teredo_client_address_without_rejecting_server_address() { - // The last two segments are the client's IPv4 address with every bit inverted. - assert!(is_private_or_local_host("2001:0:808:808:0:0:f5ff:fffe")); - // The server is not the IPv4 destination represented by this endpoint. - assert!(!is_private_or_local_host("2001:0:a00:1:0:0:f7f7:f7f7")); - assert!(!is_private_or_local_host("2001:0:808:808:0:0:fefe:fefe")); -} - -#[test] -fn classifies_mapped_and_compatible_ipv4_addresses() { - assert!(is_private_or_local_host("::ffff:10.0.0.1")); - assert!(!is_private_or_local_host("::ffff:8.8.8.8")); - assert!(is_private_or_local_host("::10.0.0.1")); - assert!(!is_private_or_local_host("::8.8.8.8")); -} - -#[tokio::test] -async fn dns_check_rejects_private_ipv4_inside_transition_address() -> anyhow::Result<()> { - let err = validate_url_with_dns_check_with_resolver("https://example.com", &[], |_, _| async { - Ok(vec!["2002:a00:1::".parse()?]) - }) - .await - .rejection()?; - assert!(err.contains("DNS rebinding blocked")); - - let allowed = - validate_url_with_dns_check_with_resolver("https://example.com", &[], |_, _| async { - Ok(vec!["2002:808:808::".parse()?]) - }) - .await?; - assert_eq!(allowed.addrs[0].ip().to_string(), "2002:808:808::"); - Ok(()) -} - -#[test] -fn allows_public_ipv6() { - assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); -} - -#[test] -fn blocks_shared_address_space() { - assert!(is_private_or_local_host("100.64.0.1")); - assert!(is_private_or_local_host("100.127.255.255")); - assert!(!is_private_or_local_host("100.63.0.1")); - assert!(!is_private_or_local_host("100.128.0.1")); -} - -#[test] -fn ssrf_blocks_loopback_127_range() { - assert!(is_private_or_local_host("127.0.0.1")); - assert!(is_private_or_local_host("127.0.0.2")); - assert!(is_private_or_local_host("127.255.255.255")); -} - -#[test] -fn ssrf_blocks_rfc1918_10_range() { - assert!(is_private_or_local_host("10.0.0.1")); - assert!(is_private_or_local_host("10.255.255.255")); -} - -#[test] -fn ssrf_blocks_rfc1918_172_range() { - assert!(is_private_or_local_host("172.16.0.1")); - assert!(is_private_or_local_host("172.31.255.255")); -} - -#[test] -fn ssrf_blocks_unspecified_address() { - assert!(is_private_or_local_host("0.0.0.0")); -} - -#[test] -fn ssrf_blocks_dot_localhost_subdomain() { - assert!(is_private_or_local_host("evil.localhost")); - assert!(is_private_or_local_host("a.b.localhost")); -} - -#[test] -fn ssrf_blocks_dot_local_tld() { - assert!(is_private_or_local_host("service.local")); -} - -#[test] -fn ssrf_ipv6_unspecified() { - assert!(is_private_or_local_host("::")); -} - -// ── Defense-in-depth: alternate IP notations rejected by allowlist -// -// Rust's IpAddr::parse() rejects octal, hex, decimal, and -// zero-padded notations. They fall through as hostnames and get -// rejected by the allowlist instead. These tests pin that -// behaviour so a parser change can't silently re-open SSRF. - -#[test] -fn ssrf_octal_loopback_not_parsed_as_ip() { - assert!(!is_private_or_local_host("0177.0.0.1")); -} - -#[test] -fn ssrf_hex_loopback_not_parsed_as_ip() { - assert!(!is_private_or_local_host("0x7f000001")); -} - -#[test] -fn ssrf_decimal_loopback_not_parsed_as_ip() { - assert!(!is_private_or_local_host("2130706433")); -} - -#[test] -fn ssrf_zero_padded_loopback_not_parsed_as_ip() { - assert!(!is_private_or_local_host("127.000.000.001")); -} - -#[test] -fn ssrf_alternate_notations_rejected_by_validate_url() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - for notation in [ - "http://0177.0.0.1", - "http://0x7f000001", - "http://2130706433", - "http://127.000.000.001", - ] { - let err = validate_url(notation, &allow).rejection()?; - assert!( - err.contains("allowed websites"), - "Expected allowlist rejection for {notation}, got: {err}" - ); - } - Ok(()) -} - -// ── DNS rebinding protection ───────────────────────────────── - -#[tokio::test] -async fn dns_check_blocks_localhost_resolution() -> anyhow::Result<()> { - // "localhost" resolves to 127.0.0.1 on most systems. Even if - // someone adds it to the allowlist, the DNS check should block it. - let allow = vec!["localhost".to_string()]; - // validate_url itself already blocks "localhost" via the hostname check, - // but validate_url_with_dns_check should also catch it. - let err = validate_url_with_dns_check("https://localhost", &allow) - .await - .rejection()?; - assert!( - err.contains("local/private") || err.contains("rebinding"), - "Expected SSRF block for localhost, got: {err}" - ); - Ok(()) -} - -#[tokio::test] -async fn dns_check_passes_for_public_resolved_ip() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let got = validate_url_with_dns_check_with_resolver( - "https://example.com", - &allow, - |host, port| async move { - assert_eq!(host, "example.com"); - assert_eq!(port, 443); - Ok(vec!["93.184.216.34".parse()?]) - }, - ) - .await?; - assert_eq!(got.url, "https://example.com"); - Ok(()) -} - -#[tokio::test] -async fn dns_check_blocks_private_resolved_ip() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = - validate_url_with_dns_check_with_resolver("https://example.com", &allow, |_, _| async { - Ok(vec!["127.0.0.1".parse()?]) - }) - .await - .rejection()?; - assert!(err.contains("DNS rebinding blocked")); - Ok(()) -} - -#[tokio::test] -async fn dns_check_uses_explicit_port_for_resolution() -> anyhow::Result<()> { - let allow = vec!["api.example.com".to_string()]; - let got = validate_url_with_dns_check_with_resolver( - "http://api.example.com:8080/status", - &allow, - |host, port| async move { - assert_eq!(host, "api.example.com"); - assert_eq!(port, 8080); - Ok(vec!["93.184.216.34".parse()?]) - }, - ) - .await?; - assert_eq!(got.url, "http://api.example.com:8080/status"); - Ok(()) -} - -#[tokio::test] -async fn dns_check_returns_resolver_failure() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url_with_dns_check_with_resolver( - "https://example.com", - &allow, - |host, _| async move { - anyhow::bail!("DNS resolution failed for '{host}': resolver unavailable") - }, - ) - .await - .rejection()?; - assert!(err.contains("DNS resolution failed")); - Ok(()) -} - -#[tokio::test] -async fn dns_check_rejects_ip_literal_private() -> anyhow::Result<()> { - let allow = vec!["10.0.0.1".to_string()]; - let err = validate_url_with_dns_check("https://10.0.0.1", &allow) - .await - .rejection()?; - assert!(err.contains("local/private")); - Ok(()) -} - -#[test] -fn wildcard_allows_any_host() { - let any = vec!["*".to_string()]; - assert!(host_matches_allowlist("docs.rs", &any)); - assert!(host_matches_allowlist("api.github.com", &any)); - assert!(host_matches_allowlist("whatever.example.org", &any)); -} - -#[tokio::test] -async fn wildcard_still_blocks_private_hosts() -> anyhow::Result<()> { - // `*` opens public hosts only — SSRF block on private/local hosts stays. - let any = vec!["*".to_string()]; - let err = validate_url_with_dns_check("https://127.0.0.1", &any) - .await - .rejection()?; - assert!(err.contains("local/private"), "got: {err}"); - Ok(()) -} - -#[test] -fn exported_ssrf_predicates_classify_non_global_ips_accurately() -> anyhow::Result<()> { - use std::net::{Ipv4Addr, Ipv6Addr}; - - // IPv4 Non-global checks - assert!(is_non_global_v4(Ipv4Addr::LOCALHOST)); - assert!(is_non_global_v4(Ipv4Addr::new(10, 0, 0, 1))); - assert!(is_non_global_v4(Ipv4Addr::new(172, 16, 0, 1))); - assert!(is_non_global_v4(Ipv4Addr::new(192, 168, 1, 1))); - assert!(is_non_global_v4(Ipv4Addr::new(169, 254, 1, 1))); - assert!(is_non_global_v4(Ipv4Addr::new(100, 64, 0, 1))); // CGNAT - assert!(is_non_global_v4(Ipv4Addr::new(240, 0, 0, 1))); // Class E - assert!(!is_non_global_v4(Ipv4Addr::new(192, 0, 2, 1))); // TEST-NET-1 is globally routable in this policy - assert!(is_non_global_v4(Ipv4Addr::new(198, 51, 100, 1))); // TEST-NET-2 - assert!(is_non_global_v4(Ipv4Addr::new(203, 0, 113, 1))); // TEST-NET-3 - assert!(is_non_global_v4(Ipv4Addr::new(192, 88, 99, 1))); // 6to4 anycast - assert!(is_non_global_v4(Ipv4Addr::UNSPECIFIED)); // 0.0.0.0/8 - assert!(is_non_global_v4(Ipv4Addr::new(0, 1, 2, 3))); // 0.0.0.0/8 - - // IPv4 Global public IPs - assert!(!is_non_global_v4(Ipv4Addr::new(8, 8, 8, 8))); - assert!(!is_non_global_v4(Ipv4Addr::new(1, 1, 1, 1))); - assert!(!is_non_global_v4(Ipv4Addr::new(140, 82, 121, 4))); - // Non-TEST-NET IPs in adjacent /24 blocks should not be classified as non-global - assert!(!is_non_global_v4(Ipv4Addr::new(198, 51, 1, 1))); - assert!(!is_non_global_v4(Ipv4Addr::new(203, 0, 1, 1))); - assert!(!is_non_global_v4(Ipv4Addr::new(192, 88, 98, 1))); - - // IPv6 Non-global checks - assert!(is_non_global_v6(Ipv6Addr::LOCALHOST)); - assert!(is_non_global_v6(Ipv6Addr::UNSPECIFIED)); - assert!(is_non_global_v6("fc00::1".parse()?)); - assert!(is_non_global_v6("fe80::1".parse()?)); - assert!(is_non_global_v6("2001:db8::1".parse()?)); - assert!(is_non_global_v6("100::1".parse()?)); - assert!(is_non_global_v6("100:0:0:1::1".parse()?)); - assert!(is_non_global_v6("2001:2::1".parse()?)); - assert!(is_non_global_v6("3fff::1".parse()?)); - assert!(is_non_global_v6("5f00::1".parse()?)); - - // IPv6 Global public IPs - assert!(!is_non_global_v6("2606:4700:4700::1111".parse()?)); - assert!(!is_non_global_v6("101::1".parse()?)); - assert!(!is_non_global_v6("100:0:0:2::1".parse()?)); - assert!(!is_non_global_v6("2001:3::1".parse()?)); - assert!(!is_non_global_v6("4000::1".parse()?)); - assert!(!is_non_global_v6("5f01::1".parse()?)); - - // Host helper checks (including ASCII case-insensitivity and trailing dot) - assert!(is_private_or_local_host("localhost")); - assert!(is_private_or_local_host("LOCALHOST")); - assert!(is_private_or_local_host("localhost.")); - assert!(is_private_or_local_host("my-service.localhost")); - assert!(is_private_or_local_host("MY-SERVICE.LOCALHOST")); - assert!(is_private_or_local_host("device.local")); - assert!(is_private_or_local_host("DEVICE.LOCAL")); - assert!(is_private_or_local_host("device.local.")); - assert!(is_private_or_local_host("127.0.0.1")); - assert!(is_private_or_local_host("[::1]")); - assert!(!is_private_or_local_host("github.com")); - assert!(!is_private_or_local_host("api.openai.com")); - Ok(()) -} - -// ── WHATWG parser differentials ───────────────────────────── - -#[test] -fn validate_rejects_backslash_authority_smuggling() -> anyhow::Result<()> { - // A WHATWG parser treats `\` as `/` for http(s), so a real client - // connects to 127.0.0.1 while a naive split sees `*.example.com`. - let allow = vec!["example.com".to_string()]; - let smuggled = "http://127.0.0.1\\.example.com/"; - let err = validate_url(smuggled, &allow).rejection()?; - assert!(err.contains("backslash"), "got: {err}"); - let err = validate_url(smuggled, &[]).rejection()?; - assert!(err.contains("backslash"), "got: {err}"); - Ok(()) -} - -#[test] -fn validate_rejects_backslash_anywhere() -> anyhow::Result<()> { - let err = validate_url("https://example.com/a\\b", &[]).rejection()?; - assert!(err.contains("backslash"), "got: {err}"); - Ok(()) -} - -#[test] -fn extract_host_and_port_reject_backslash() { - let smuggled = "http://127.0.0.1\\.example.com:8080/"; - assert!(extract_host(smuggled).is_err()); - assert!(extract_port(smuggled).is_err()); -} - -#[test] -fn validate_rejects_percent_encoded_host() -> anyhow::Result<()> { - // WHATWG percent-decodes the host, so this is 127.0.0.1 on the wire. - let err = validate_url("http://%31%32%37.0.0.1/", &[]).rejection()?; - assert!(err.contains("percent-encoded"), "got: {err}"); - let allow = vec!["example.com".to_string()]; - let err = validate_url("http://evil%2eexample.com/", &allow).rejection()?; - assert!(err.contains("percent-encoded"), "got: {err}"); - Ok(()) -} - -#[test] -fn extract_host_and_port_reject_percent_encoded_authority() { - assert!(extract_host("http://%31%32%37.0.0.1/").is_err()); - assert!(extract_port("http://example.com:%38%30/").is_err()); -} - -#[test] -fn validate_allows_percent_encoding_outside_the_authority() -> anyhow::Result<()> { - let got = validate_url("https://example.com/search?q=a%20b#x%2F", &[])?; - assert_eq!(got, "https://example.com/search?q=a%20b#x%2F"); - Ok(()) -} - -#[tokio::test] -async fn dns_check_returns_exactly_the_vetted_addresses() -> anyhow::Result<()> { - let got = validate_url_with_dns_check_with_resolver( - "https://API.example.com:8443/v1", - &[], - |_, _| async { - Ok(vec![ - "93.184.216.34".parse()?, - "2606:4700:4700::1111".parse()?, - ]) - }, - ) - .await?; - assert_eq!( - got, - ValidatedUrl { - url: "https://API.example.com:8443/v1".to_string(), - host: "api.example.com".to_string(), - addrs: vec![ - "93.184.216.34:8443".parse()?, - "[2606:4700:4700::1111]:8443".parse()?, - ], - } - ); - Ok(()) -} - -#[tokio::test] -async fn dns_check_pins_an_ip_literal_host_to_itself() -> anyhow::Result<()> { - // IP literals skip DNS entirely, so this stays network-free. - let got = validate_url_with_dns_check("http://93.184.216.34/page", &[]).await?; - assert_eq!(got.host, "93.184.216.34"); - assert_eq!(got.addrs, vec!["93.184.216.34:80".parse()?]); - Ok(()) -} - -// The pinned host must be the name the HTTP client will resolve. -#[tokio::test] -async fn pinned_host_matches_the_url_host_for_a_trailing_dot() -> anyhow::Result<()> { - let got = validate_url_with_dns_check_with_resolver( - "https://rebind.example./x", - &[], - |_, _| async move { Ok(vec!["93.184.216.34".parse()?]) }, - ) - .await?; - let url_host = reqwest::Url::parse(&got.url)? - .host_str() - .map(str::to_string); - assert_eq!( - url_host.as_deref(), - Some(got.host.as_str()), - "pin key differs from the URL host" - ); - Ok(()) -} diff --git a/crates/tinytools/src/status/web_fetch.rs b/crates/tinytools/src/status/web_fetch.rs index d20c9e9d..b2ff3c11 100644 --- a/crates/tinytools/src/status/web_fetch.rs +++ b/crates/tinytools/src/status/web_fetch.rs @@ -1,8 +1,8 @@ //! The status shape emitted by the standard `web_fetch` tool. //! -//! This parser and its disposition live in the lightweight `tinytools` crate so -//! a host can use the producer's exact result contract without depending on -//! `tinytools-std` (which depends on `tinytools`). +//! This parser and its disposition live in `tinytools` so a host can use the +//! producer's exact result contract without a separate tool-implementation +//! crate. /// The safe recovery meaning of a structured HTTP failure from a fetched site. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/docs/host-adoption/72-host-adoption-map.md b/docs/host-adoption/72-host-adoption-map.md index 518ad118..cab1552c 100644 --- a/docs/host-adoption/72-host-adoption-map.md +++ b/docs/host-adoption/72-host-adoption-map.md @@ -23,6 +23,5 @@ fetched-site disposition policy with the `tinytools::status` API: host's separate host-per-URL recovery scope; that remains a small URL projection (`web_fetch` + `url` argument → parsed URL host). -The status parser is `tinytools::status::web_fetch_status`. The standard tool -continues to expose `tinytools_std::network::web_fetch::status_of` as an alias -to that same parser, so the producer and host share one accepted shape. +The status parser is `tinytools::status::web_fetch_status`, which the producer +and host share as one accepted shape. diff --git a/docs/plans/collapsed-tools-and-standard-helpers.md b/docs/plans/collapsed-tools-and-standard-helpers.md index 23259aa1..a553bf69 100644 --- a/docs/plans/collapsed-tools-and-standard-helpers.md +++ b/docs/plans/collapsed-tools-and-standard-helpers.md @@ -4,6 +4,11 @@ Specification: [Collapsed tools and standard helpers](../specs/collapsed-tools-a **Status:** Complete +**Historical plan:** The `tinytools-std` work in this plan was removed in +tinytools#99. Its former helper implementations are now owned by +`tinyagents-harness-tools`. Do not follow the deleted `crates/tinytools-std` +paths; this plan records the completed historical work. + ## Goal and assumptions Implement the public contracts in the linked specification across `tinytools` diff --git a/docs/plans/web-fetch-async-html-extraction.md b/docs/plans/web-fetch-async-html-extraction.md index 7b5e00b3..c4dab8c0 100644 --- a/docs/plans/web-fetch-async-html-extraction.md +++ b/docs/plans/web-fetch-async-html-extraction.md @@ -1,5 +1,9 @@ # Implement asynchronous HTML extraction in `web_fetch` +**Historical plan:** The implementation moved to `tinyagents-harness-tools` +when `tinytools-std` was removed in tinytools#99. This plan records completed +work and does not describe current ownership in `tinytools`. + Spec: [Asynchronous HTML extraction in `web_fetch`](../specs/web-fetch-async-html-extraction.md). ## Steps diff --git a/docs/specs/collapsed-tools-and-standard-helpers.md b/docs/specs/collapsed-tools-and-standard-helpers.md index ee8b5c5b..db7168f4 100644 --- a/docs/specs/collapsed-tools-and-standard-helpers.md +++ b/docs/specs/collapsed-tools-and-standard-helpers.md @@ -3,6 +3,11 @@ **Status:** Implemented **Owner:** tinytools maintainers +**Scope note:** The standard-helper portion of this specification was +superseded when `tinytools-std` was removed in tinytools#99. Its former helper +implementations are owned by `tinyagents-harness-tools`; the collapsed-tool +contracts remain in `tinytools`. + ## Problem Hosts need to expose related tools as one action-dispatched tool without losing diff --git a/docs/specs/fsgate-batch-path-check.md b/docs/specs/fsgate-batch-path-check.md index c8e4a802..dc8caac2 100644 --- a/docs/specs/fsgate-batch-path-check.md +++ b/docs/specs/fsgate-batch-path-check.md @@ -1,8 +1,13 @@ # FsGate batch path check +**Status:** Superseded by the removal of `tinytools-std` in tinytools#99. The +former filesystem helper implementation is now owned by +`tinyagents-harness-tools`; this document is historical and does not describe +an API implemented by `tinytools`. + ## Status and owner -Implemented. Owner: tinytools-std (`filesystem`). +Historical implementation: `tinytools-std` (`filesystem`). ## Problem diff --git a/docs/specs/web-fetch-async-html-extraction.md b/docs/specs/web-fetch-async-html-extraction.md index aa6ac2ed..f1b71077 100644 --- a/docs/specs/web-fetch-async-html-extraction.md +++ b/docs/specs/web-fetch-async-html-extraction.md @@ -1,8 +1,12 @@ # Asynchronous HTML extraction in `web_fetch` +**Status:** Superseded by the removal of `tinytools-std` in tinytools#99. The +current implementation is owned by `tinyagents-harness-tools`; this document +records historical behavior and does not describe a `tinytools` API. + ## Status -Accepted behavior, introduced with `AsyncHtmlExtractor` and +Historical accepted behavior, introduced with `AsyncHtmlExtractor` and `WebFetchTool::new_async`. ## Context