diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9167aef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,172 @@ +# agent-bridle release workflow. +# +# HOOK PARITY: publish sequencing mirrors the `publish-crates` recipe in +# justfile. When editing the crate list or order here, update the justfile +# recipe to match. +# +# Triggers: +# - Tag push `v*` → build MCP binary + publish all crates to crates.io +# - workflow_dispatch → manual build (no upload) +# +# Secrets (Settings → Secrets and variables → Actions): +# - CARGO_REGISTRY_TOKEN — crates.io publish token (account scope) + +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write # gh release create + binary asset upload + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + +jobs: + # ── Build the agent-bridle-mcp binary ───────────────────────────────── + + build-mcp-binary: + name: Build agent-bridle-mcp (${{ matrix.label }}) + strategy: + fail-fast: false + matrix: + include: + - platform: ubuntu-latest + target: x86_64-unknown-linux-gnu + label: linux-x86_64 + - platform: macos-latest + target: aarch64-apple-darwin + label: macos-arm64 + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: release-${{ matrix.target }} + + - name: Build agent-bridle-mcp + run: cargo build --release --locked --target ${{ matrix.target }} -p agent-bridle-mcp + + - name: Stage archive (Unix) + shell: bash + run: | + VERSION="${GITHUB_REF_NAME//\//-}" + STAGE="agent-bridle-mcp-${VERSION}-${{ matrix.label }}" + mkdir -p "$STAGE" + cp target/${{ matrix.target }}/release/agent-bridle-mcp "$STAGE/" + cp LICENSE README.md "$STAGE/" + tar -czf "$STAGE.tar.gz" "$STAGE" + echo "ARCHIVE=$STAGE.tar.gz" >> "$GITHUB_ENV" + + - name: Upload archive artifact + uses: actions/upload-artifact@v4 + with: + name: agent-bridle-mcp-${{ matrix.label }} + path: ${{ env.ARCHIVE }} + if-no-files-found: error + retention-days: 30 + + # ── GitHub release (tag-only) ───────────────────────────────────────── + + github-release: + name: GitHub release (${{ github.ref_name }}) + if: startsWith(github.ref, 'refs/tags/') + needs: [build-mcp-binary] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Download all binary artifacts + uses: actions/download-artifact@v4 + with: + path: all-artifacts/ + - name: Collect archives + run: | + mkdir -p dist + find all-artifacts -type f -name '*.tar.gz' -exec cp {} dist/ \; + ls -la dist/ + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: "agent-bridle ${{ github.ref_name }}" + draft: true + prerelease: false + generate_release_notes: true + files: dist/* + + # ── Publish to crates.io (tag-only, topological order) ──────────────── + # + # Order is load-bearing: each crate must land before its dependents. + # cargo >= 1.66 blocks until the new version is downloadable, so + # max-parallel: 1 + the ordered matrix ensures correct sequencing. + # + # agent-bridle-py is excluded: PyO3 arm64 linker issues on the GitHub + # Actions macOS runner make it unpublishable here. Publish manually or + # via a maturin wheel job if/when needed. + + publish-crates: + name: Publish ${{ matrix.crate }} → crates.io + if: startsWith(github.ref, 'refs/tags/') + needs: [build-mcp-binary] + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 1 # one at a time — internal deps need topological order + matrix: + crate: + - agent-bridle-core + - agent-bridle-tool-shell + - agent-bridle-tool-web + - agent-bridle # facade — depends on the three above + - agent-bridle-mcp # binary crate — depends on the facade + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: publish-${{ matrix.crate }} + + - name: Confirm CARGO_REGISTRY_TOKEN is mounted + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + if [ -z "${CARGO_REGISTRY_TOKEN}" ]; then + echo "::error::CARGO_REGISTRY_TOKEN not set — add it under Settings → Secrets and variables → Actions" + exit 1 + fi + echo "CARGO_REGISTRY_TOKEN: present (${#CARGO_REGISTRY_TOKEN} chars)" + + - name: cargo publish ${{ matrix.crate }} + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + # Resilient publish: idempotent on already-published versions, + # backs off on 429 rate-limits (new crate names), fails on anything else. + run: | + for attempt in 1 2 3 4 5 6; do + if cargo publish -p ${{ matrix.crate }} --locked --no-verify 2>publish.err; then + cat publish.err >&2 || true + echo "published ${{ matrix.crate }}" + exit 0 + elif grep -qiE 'already (exists|uploaded)|is already uploaded|already published' publish.err; then + cat publish.err >&2 + echo "::notice::${{ matrix.crate }} already published at this version — skipping" + exit 0 + elif grep -qi '429 Too Many Requests' publish.err; then + cat publish.err >&2 + wait=$((attempt * 90)) + echo "::warning::crates.io rate-limited on ${{ matrix.crate }} — waiting ${wait}s, retry ${attempt}/6" + sleep "$wait" + else + cat publish.err >&2 + exit 1 + fi + done + echo "::error::${{ matrix.crate }} still rate-limited after 6 retries" + exit 1 diff --git a/Cargo.lock b/Cargo.lock index cf12a45..00d84a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,9 +62,6 @@ dependencies = [ "agent-bridle-core", "anyhow", "async-trait", - "brush-builtins", - "brush-core", - "brush-coreutils-builtins", "serde", "serde_json", "tokio", @@ -107,18 +104,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -128,86 +113,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "archery" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" -dependencies = [ - "triomphe", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -263,17 +174,6 @@ dependencies = [ "event-listener", ] -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -315,19 +215,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bigdecimal" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" -dependencies = [ - "autocfg", - "libm", - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -372,115 +259,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bon" -version = "3.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" -dependencies = [ - "bon-macros", - "rustversion", -] - -[[package]] -name = "bon-macros" -version = "3.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" -dependencies = [ - "darling 0.23.0", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - -[[package]] -name = "brush-builtins" -version = "0.2.0" -source = "git+https://github.com/hartsock/brush?rev=f0ef7715a02f44c670e7f5d5e59d1c7721ea282c#f0ef7715a02f44c670e7f5d5e59d1c7721ea282c" -dependencies = [ - "brush-core", - "brush-parser", - "cfg-if", - "chrono", - "clap", - "fancy-regex", - "itertools", - "nix", - "procfs", - "rlimit", - "strum", - "thiserror 2.0.18", - "tokio", - "tracing", - "uucore", -] - -[[package]] -name = "brush-core" -version = "0.5.0" -source = "git+https://github.com/hartsock/brush?rev=f0ef7715a02f44c670e7f5d5e59d1c7721ea282c#f0ef7715a02f44c670e7f5d5e59d1c7721ea282c" -dependencies = [ - "async-recursion", - "async-trait", - "bon", - "brush-parser", - "cached", - "cfg-if", - "check_elevation", - "chrono", - "clap", - "color-print", - "command-fds", - "fancy-regex", - "futures", - "getrandom 0.4.2", - "hostname", - "inherent", - "itertools", - "libc", - "nix", - "normalize-path", - "rand 0.10.1", - "rpds", - "strum", - "strum_macros", - "terminfo", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", - "uzers", - "whoami", -] - -[[package]] -name = "brush-coreutils-builtins" -version = "0.1.0" -source = "git+https://github.com/hartsock/brush?rev=f0ef7715a02f44c670e7f5d5e59d1c7721ea282c#f0ef7715a02f44c670e7f5d5e59d1c7721ea282c" -dependencies = [ - "uucore", -] - -[[package]] -name = "brush-parser" -version = "0.4.0" -source = "git+https://github.com/hartsock/brush?rev=f0ef7715a02f44c670e7f5d5e59d1c7721ea282c#f0ef7715a02f44c670e7f5d5e59d1c7721ea282c" -dependencies = [ - "bon", - "cached", - "getrandom 0.4.2", - "indenter", - "peg", - "thiserror 2.0.18", - "tracing", - "utf8-chars", - "uuid", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -496,40 +274,6 @@ dependencies = [ "serde", ] -[[package]] -name = "cached" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b6f5d101f0f6322c8646a45b7c581a673e476329040d97565815c2461dd0c4" -dependencies = [ - "ahash", - "cached_proc_macro", - "cached_proc_macro_types", - "hashbrown 0.16.1", - "once_cell", - "parking_lot", - "thiserror 2.0.18", - "web-time", -] - -[[package]] -name = "cached_proc_macro" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ebcf9c75f17a17d55d11afc98e46167d4790a263f428891b8705ab2f793eca3" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "cached_proc_macro_types" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" - [[package]] name = "cc" version = "1.2.63" @@ -552,39 +296,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "check_elevation" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7fe1fbbd8bd3174109071530f12932ff471eeff30d83e9c485d6ff424ac534b" -dependencies = [ - "windows", -] - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "cipher" version = "0.4.4" @@ -595,84 +306,6 @@ dependencies = [ "inout", ] -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", - "terminal_size", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "color-print" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aa954171903797d5623e047d9ab69d91b493657917bdfb8c2c80ecaf9cdb6f4" -dependencies = [ - "color-print-proc-macro", -] - -[[package]] -name = "color-print-proc-macro" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692186b5ebe54007e45a59aea47ece9eb4108e141326c304cdc91699a7118a22" -dependencies = [ - "nom 7.1.3", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "command-fds" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b60b5124979fccd9addd89d8b97a1d6eebb4950694520c75ddd722535ea443f" -dependencies = [ - "nix", - "thiserror 2.0.18", -] - [[package]] name = "compression-codecs" version = "0.4.38" @@ -711,12 +344,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -781,7 +408,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.13.1", + "phf", "smallvec", ] @@ -822,75 +449,6 @@ dependencies = [ "syn", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn", -] - [[package]] name = "data-encoding" version = "2.11.0" @@ -960,9 +518,9 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash 0.2.0", + "foldhash", "html5ever", - "nom 8.0.0", + "nom", "precomputed-hash", "selectors", "tendril", @@ -976,11 +534,11 @@ checksum = "f21cad308997c8c518aaee15c8b38bb04dc699bbcf30da4176443c9ef40c1bb5" dependencies = [ "dom_query", "flagset", - "foldhash 0.2.0", + "foldhash", "gjson", "html-escape", "once_cell", - "phf 0.13.1", + "phf", "tendril", "thiserror 2.0.18", "unicode-segmentation", @@ -1041,12 +599,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1144,17 +696,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fancy-regex" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fastrand" version = "2.4.1" @@ -1199,63 +740,12 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "fluent" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" -dependencies = [ - "fluent-bundle", - "unic-langid", -] - -[[package]] -name = "fluent-bundle" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" -dependencies = [ - "fluent-langneg", - "fluent-syntax", - "intl-memoizer", - "intl_pluralrules", - "rustc-hash", - "self_cell", - "smallvec", - "unic-langid", -] - -[[package]] -name = "fluent-langneg" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" -dependencies = [ - "unic-langid", -] - -[[package]] -name = "fluent-syntax" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" -dependencies = [ - "memchr", - "thiserror 2.0.18", -] - [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1271,21 +761,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1293,7 +768,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1302,17 +776,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" version = "0.3.32" @@ -1354,13 +817,9 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", - "futures-io", "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -1385,7 +844,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -1398,39 +857,17 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", + "r-efi", "wasip2", "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", - "wasm-bindgen", -] - [[package]] name = "gjson" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43503cc176394dd30a6525f5f36e838339b8b5619be33ed9a7783841580a97b6" -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "group" version = "0.13.0" @@ -1461,26 +898,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -1577,17 +994,6 @@ dependencies = [ "digest", ] -[[package]] -name = "hostname" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" -dependencies = [ - "cfg-if", - "libc", - "windows-link", -] - [[package]] name = "htmd" version = "0.5.4" @@ -1596,7 +1002,7 @@ checksum = "7eee9b00ee2e599b4f86507157e3db786e7a3319fc225f0e9584151dbea2291d" dependencies = [ "html5ever", "markup5ever_rcdom", - "phf 0.13.1", + "phf", ] [[package]] @@ -1758,30 +1164,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -1796,21 +1178,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locale" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_locale_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - [[package]] name = "icu_locale_core" version = "2.2.0" @@ -1819,17 +1186,10 @@ checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", - "serde", "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locale_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + "writeable", + "zerovec", +] [[package]] name = "icu_normalizer" @@ -1879,8 +1239,6 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", - "serde", - "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1888,18 +1246,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1921,12 +1267,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indenter" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" - [[package]] name = "indexmap" version = "2.14.0" @@ -1934,20 +1274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "inherent" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "hashbrown", ] [[package]] @@ -1959,25 +1286,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "intl-memoizer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" -dependencies = [ - "type-map", - "unic-langid", -] - -[[package]] -name = "intl_pluralrules" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" -dependencies = [ - "unic-langid", -] - [[package]] name = "ipconfig" version = "0.3.4" @@ -1997,21 +1305,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -2050,12 +1343,6 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -2068,27 +1355,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "libc", -] - [[package]] name = "linked-hash-map" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" -[[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.2" @@ -2160,12 +1432,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2183,7 +1449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -2193,28 +1459,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nom" version = "8.0.0" @@ -2224,22 +1468,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "normalize-path" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5438dd2b2ff4c6df6e1ce22d825ed2fa93ee2922235cc45186991717f0a892d" - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2286,45 +1514,12 @@ dependencies = [ "libm", ] -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", -] - -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "os_display" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad5fd71b79026fb918650dde6d125000a233764f1c2f1659a1c71118e33ea08f" -dependencies = [ - "unicode-width", -] - [[package]] name = "p256" version = "0.13.2" @@ -2401,33 +1596,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "peg" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773" -dependencies = [ - "peg-macros", - "peg-runtime", -] - -[[package]] -name = "peg-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee" -dependencies = [ - "peg-runtime", - "proc-macro2", - "quote", -] - -[[package]] -name = "peg-runtime" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6" - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2443,15 +1611,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.13.1" @@ -2459,38 +1618,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared 0.13.1", + "phf_shared", "serde", ] -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", + "phf_generator", + "phf_shared", ] [[package]] @@ -2500,7 +1639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.13.1", + "phf_shared", ] [[package]] @@ -2509,22 +1648,13 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn", ] -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "phf_shared" version = "0.13.1" @@ -2573,8 +1703,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "serde_core", - "writeable", "zerovec", ] @@ -2593,16 +1721,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "primeorder" version = "0.13.6" @@ -2621,30 +1739,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "procfs" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" -dependencies = [ - "bitflags", - "chrono", - "flate2", - "procfs-core", - "rustix", -] - -[[package]] -name = "procfs-core" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" -dependencies = [ - "bitflags", - "chrono", - "hex", -] - [[package]] name = "pyo3" version = "0.28.3" @@ -2773,12 +1867,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[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.8.6" @@ -2800,17 +1888,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -2849,12 +1926,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -2963,25 +2034,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rlimit" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" -dependencies = [ - "libc", -] - -[[package]] -name = "rpds" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e025feb26210bc196b908e72deb063b1b4000754304341cbc168a1e72c857ebc" -dependencies = [ - "archery", - "smallvec", -] - [[package]] name = "rsa" version = "0.9.10" @@ -3018,19 +2070,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.23.40" @@ -3109,20 +2148,14 @@ dependencies = [ "derive_more", "log", "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", + "phf", + "phf_codegen", "precomputed-hash", "rustc-hash", "servo_arc", "smallvec", ] -[[package]] -name = "self_cell" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" - [[package]] name = "semver" version = "1.0.28" @@ -3382,7 +2415,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.13.1", + "phf_shared", "precomputed-hash", "serde", ] @@ -3393,8 +2426,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -3405,30 +2438,6 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "subtle" version = "2.6.1" @@ -3482,35 +2491,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] -name = "tendril" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" -dependencies = [ - "new_debug_unreachable", - "utf-8", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminfo" -version = "0.9.0" +name = "tendril" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" dependencies = [ - "fnv", - "nom 7.1.3", - "phf 0.11.3", - "phf_codegen 0.11.3", + "new_debug_unreachable", + "utf-8", ] [[package]] @@ -3560,7 +2547,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", - "serde_core", "zerovec", ] @@ -3752,51 +2738,18 @@ dependencies = [ "once_cell", ] -[[package]] -name = "triomphe" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" - [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "type-map" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" -dependencies = [ - "rustc-hash", -] - [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unic-langid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" -dependencies = [ - "unic-langid-impl", -] - -[[package]] -name = "unic-langid-impl" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" -dependencies = [ - "tinystr", -] - [[package]] name = "unicode-ident" version = "1.0.24" @@ -3815,18 +2768,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - [[package]] name = "untrusted" version = "0.9.0" @@ -3851,15 +2792,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-chars" -version = "3.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92498c67ea3511b37eccff13b573ed871faf0ce9c4056ab032bd01a681f445a0" -dependencies = [ - "arrayvec", -] - [[package]] name = "utf8-width" version = "0.1.8" @@ -3872,67 +2804,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uucore" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07d779636d827cde4100f0e65ff3fd23b0b1f1195055475c6e6813d425f30c8e" -dependencies = [ - "bigdecimal", - "clap", - "fluent", - "fluent-bundle", - "fluent-syntax", - "icu_locale", - "itertools", - "nix", - "num-traits", - "os_display", - "rustc-hash", - "rustix", - "thiserror 2.0.18", - "unic-langid", - "unit-prefix", - "uucore_procs", - "wild", -] - -[[package]] -name = "uucore_procs" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da865e8a648b260dbd89fca76d0801995877ab27a4e259b6dec30ba9743b86ab" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "uuid" -version = "1.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "uzers" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8275fb1afee25b4111d2dc8b5c505dbbc4afd0b990cb96deb2d88bff8be18d" -dependencies = [ - "libc", - "log", -] - [[package]] name = "version_check" version = "0.9.5" @@ -3954,40 +2825,13 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", + "wit-bindgen", ] [[package]] @@ -4045,40 +2889,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" version = "0.3.99" @@ -4105,8 +2915,8 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", + "phf", + "phf_codegen", "string_cache", "string_cache_codegen", ] @@ -4120,88 +2930,12 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "widestring" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" -[[package]] -name = "wild" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3131afc8c575281e1e80f36ed6a092aa502c08b18ed7524e86fbbb12bb410e1" -dependencies = [ - "glob", -] - -[[package]] -name = "windows" -version = "0.51.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9" -dependencies = [ - "windows-core 0.51.1", - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-core" -version = "0.51.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -4264,21 +2998,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -4312,12 +3031,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4330,12 +3043,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4348,12 +3055,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4378,12 +3079,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4396,12 +3091,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4414,12 +3103,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4432,12 +3115,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4459,100 +3136,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -4662,7 +3251,6 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", - "zerovec", ] [[package]] @@ -4671,7 +3259,6 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index d830c51..ec32ec1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,22 +40,26 @@ async-trait = "0.1" pyo3 = { version = "0.28", default-features = false, features = ["macros"] } # Leaf-tool-only deps (never enter core or a host's default build). -# `net` is required for the runtime IO driver (`enable_io`/`enable_all`): the -# confined shell turns IO on so `$(...)` command substitution flows through the -# interceptor funnel and yields a clean denial instead of an "IO is disabled" -# panic (DESIGN §6 / security fix). `rt`+`time` alone do not bring in the IO -# driver. -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "time", "net"] } -# brush — pinned to OUR fork at the commit that adds the `CommandInterceptor` -# exec/open hook (`extensions::CommandInterceptor`, wired at the single -# external-spawn funnel + `Shell::open_file`). All three crates share one -# git+rev so cargo resolves them to a single checkout. This closes the -# path-separator exec bypass in-process, cross-OS (DESIGN §6). NOTE: a git dep -# means `agent-bridle-tool-shell` cannot publish to crates.io until the hook is -# upstreamed into `reubeno/brush`; tracked as out-of-scope until then. -brush-core = { git = "https://github.com/hartsock/brush", rev = "f0ef7715a02f44c670e7f5d5e59d1c7721ea282c" } -brush-builtins = { git = "https://github.com/hartsock/brush", rev = "f0ef7715a02f44c670e7f5d5e59d1c7721ea282c" } -brush-coreutils-builtins = { git = "https://github.com/hartsock/brush", rev = "f0ef7715a02f44c670e7f5d5e59d1c7721ea282c" } +# `net` is NOT included here — the stub shell no longer needs the IO driver. +# Restore `net` when the brush `CommandInterceptor` feature is re-enabled +# (tracked in https://github.com/Gilamonster-Foundation/agent-bridle/issues/20). +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "time"] } +# brush — TEMPORARILY REMOVED pending reubeno/brush#1184. +# +# The shell tool is currently a stub (returns a clear error on invoke). Full +# CommandInterceptor-backed execution is restored once the upstream PR merges +# and brush publishes a crates.io release that includes the hook. git deps are +# forbidden by crates.io, so we cannot carry this as an optional dep in the +# published package. +# +# To restore: uncomment these three lines, update the rev to the post-merge +# commit, re-enable the `shell` feature body in agent-bridle-tool-shell, and +# restore caveat_interceptor.rs + the full shell_tool.rs from git history. +# See: https://github.com/Gilamonster-Foundation/agent-bridle/issues/20 +# +# brush-core = { git = "https://github.com/hartsock/brush", rev = "f0ef7715a02f44c670e7f5d5e59d1c7721ea282c" } +# brush-builtins = { git = "https://github.com/hartsock/brush", rev = "f0ef7715a02f44c670e7f5d5e59d1c7721ea282c" } +# brush-coreutils-builtins = { git = "https://github.com/hartsock/brush", rev = "f0ef7715a02f44c670e7f5d5e59d1c7721ea282c" } # Web-tool-only deps (feature `web` in agent-bridle-tool-web). rustls, NOT # openssl, so the client is portable and builds on Windows with no system diff --git a/agent-bridle-mcp/src/handlers.rs b/agent-bridle-mcp/src/handlers.rs index 0ac78d2..8eb6301 100644 --- a/agent-bridle-mcp/src/handlers.rs +++ b/agent-bridle-mcp/src/handlers.rs @@ -179,9 +179,16 @@ mod tests { assert!(shell["description"].is_string()); } + // NOTE: the three shell-execution tests below are updated for the stub + // release (pending reubeno/brush#1184). All shell invocations now return + // an in-band MCP tool error explaining the temporary degradation. + // Restore the original test bodies from git history when brush support + // is re-enabled. See https://github.com/Gilamonster-Foundation/agent-bridle/issues/20 + #[cfg(feature = "shell")] #[tokio::test] async fn call_in_scope_succeeds() { + // Stub: even in-scope calls return an unavailable error. let reg = agent_bridle::registry(); let v = tools_call( ®, @@ -189,14 +196,21 @@ mod tests { serde_json::json!({ "name": "shell", "arguments": { "program": "echo", "args": ["hi"] } }), ) .await; - assert_eq!(v["isError"], false); + assert_eq!( + v["isError"], true, + "stub must surface as MCP tool error: {v}" + ); let text = v["content"][0]["text"].as_str().unwrap(); - assert!(text.contains("hi"), "tool stdout missing: {text}"); + assert!( + text.contains("reubeno/brush/pull/1184"), + "stub error must link to tracking PR: {text}" + ); } #[cfg(feature = "shell")] #[tokio::test] async fn call_out_of_scope_is_in_band_denial() { + // Stub: returns unavailable error (not a caveats denial) in all cases. let reg = agent_bridle::registry(); let v = tools_call( ®, @@ -204,22 +218,21 @@ mod tests { serde_json::json!({ "name": "shell", "arguments": { "program": "rm", "args": ["-rf", "/"] } }), ) .await; - // The denial is an MCP tool error, NOT a transport error: isError=true - // with the reason carried in the content. - assert_eq!(v["isError"], true); + assert_eq!( + v["isError"], true, + "stub must surface as MCP tool error: {v}" + ); let text = v["content"][0]["text"].as_str().unwrap(); - assert!(text.contains("denied"), "denial reason missing: {text}"); - assert!(text.contains("rm"), "denied program missing: {text}"); + assert!( + text.contains("reubeno/brush/pull/1184"), + "stub error must link to tracking PR: {text}" + ); } #[cfg(feature = "shell")] #[tokio::test] async fn call_freeform_denied_is_in_band_error_from_structured_field() { - // A free-form `cmd` denial returns Ok from dispatch (exit 126) with the - // STRUCTURED `denied: true` in the envelope. The handler must turn that - // into an MCP tool error (isError=true) by reading the structured field, - // NOT by string-matching stderr. This is the regression the fix closes: - // before it, a free-form denial slipped through as isError=false. + // Stub: free-form cmd also returns the unavailable error. let reg = agent_bridle::registry(); let v = tools_call( ®, @@ -229,12 +242,12 @@ mod tests { .await; assert_eq!( v["isError"], true, - "free-form denial must surface as an MCP tool error: {v}" + "stub must surface as MCP tool error: {v}" ); let text = v["content"][0]["text"].as_str().unwrap(); assert!( - text.contains("not within the granted") || text.contains("denied"), - "denial reason missing: {text}" + text.contains("reubeno/brush/pull/1184"), + "stub error must link to tracking PR: {text}" ); } diff --git a/agent-bridle-mcp/tests/mcp_stdio.rs b/agent-bridle-mcp/tests/mcp_stdio.rs index 73d780a..1d93e0d 100644 --- a/agent-bridle-mcp/tests/mcp_stdio.rs +++ b/agent-bridle-mcp/tests/mcp_stdio.rs @@ -1,9 +1,14 @@ //! Through-the-MCP-boundary integration test (the payoff). //! //! Spawns the real `agent-bridle-mcp` binary as a child process and drives it -//! over its stdio JSON-RPC pipe, exactly as an MCP client would. It proves the -//! capability leash holds *across the MCP boundary*, not merely in-process: +//! over its stdio JSON-RPC pipe, exactly as an MCP client would. //! +//! NOTE: this test is updated for the stub release (pending reubeno/brush#1184). +//! Steps 3 and 4 both assert the stub unavailable error, NOT real execution. +//! Restore the original assertions from git history when brush support is +//! re-enabled. See https://github.com/Gilamonster-Foundation/agent-bridle/issues/20 +//! +//! Original behaviour tested: //! 1. `initialize` → the server reports its identity + the `tools` capability. //! 2. `tools/list` → the confined `shell` tool is advertised. //! 3. `tools/call shell` with an **in-scope** program (`echo`) → stdout comes @@ -127,7 +132,9 @@ async fn leash_holds_through_the_mcp_boundary() { "tools/list missing shell: {names:?}" ); - // 3. tools/call shell with an IN-SCOPE program → stdout, isError=false. + // 3. Stub: even an in-scope `echo` call returns the unavailable error. + // Restore to assert isError=false + stdout contains "leashed-hello" once + // reubeno/brush#1184 merges and brush ships a crates.io release. let allowed = mcp .call(&serde_json::json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", @@ -138,18 +145,18 @@ async fn leash_holds_through_the_mcp_boundary() { })) .await; assert_eq!( - allowed["result"]["isError"], false, - "echo should run: {allowed}" + allowed["result"]["isError"], true, + "stub must surface as MCP tool error: {allowed}" ); let allowed_text = allowed["result"]["content"][0]["text"].as_str().unwrap(); assert!( - allowed_text.contains("leashed-hello"), - "expected echo stdout, got: {allowed_text}" + allowed_text.contains("reubeno/brush/pull/1184"), + "stub error must link to tracking PR: {allowed_text}" ); - // 4. tools/call shell with an OUT-OF-SCOPE program → DENIED through MCP: - // an MCP tool error (isError=true) carrying the denial reason, NOT a - // transport error. + // 4. Stub: out-of-scope program also returns unavailable error (not a denial). + // Restore to assert isError=true with denial reason containing "rm" and + // "granted authority" once brush support is re-enabled. let denied = mcp .call(&serde_json::json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call", @@ -161,21 +168,16 @@ async fn leash_holds_through_the_mcp_boundary() { .await; assert!( denied.get("error").is_none(), - "denial must be in-band, not a transport error: {denied}" + "stub result must be in-band, not a transport error: {denied}" ); assert_eq!( denied["result"]["isError"], true, - "out-of-scope exec must be an MCP tool error: {denied}" + "stub must surface as MCP tool error: {denied}" ); let reason = denied["result"]["content"][0]["text"].as_str().unwrap(); assert!( - reason.contains("denied") && reason.contains("rm"), - "denial reason must name the refused program: {reason}" - ); - // The reason explains WHY (out-of-scope authority), so the model can adapt. - assert!( - reason.contains("granted authority"), - "denial should explain the leash: {reason}" + reason.contains("reubeno/brush/pull/1184"), + "stub error must link to tracking PR: {reason}" ); mcp.shutdown().await; diff --git a/agent-bridle-tool-shell/Cargo.toml b/agent-bridle-tool-shell/Cargo.toml index 2b9a76f..a141d9d 100644 --- a/agent-bridle-tool-shell/Cargo.toml +++ b/agent-bridle-tool-shell/Cargo.toml @@ -1,6 +1,10 @@ [package] name = "agent-bridle-tool-shell" -description = "A capability-confined, brush-backed shell tool for agent-bridle (carried coreutils builtins)." +# STUB RELEASE — full brush-backed shell is temporarily disabled pending +# reubeno/brush#1184 (CommandInterceptor upstream PR). The tool registers in +# the registry and documents its interface, but invoke() returns a clear error. +# See https://github.com/Gilamonster-Foundation/agent-bridle/issues/20 +description = "Capability-confined shell tool for agent-bridle (stub — pending reubeno/brush#1184)." version.workspace = true edition.workspace = true license.workspace = true @@ -8,21 +12,12 @@ repository.workspace = true authors.workspace = true [features] -# `shell` pulls in the brush runtime + tokio. Off by default so a host that -# does not want a shell tool pays nothing for it. The crate still compiles with -# the feature off (it then exposes no ShellTool) so the workspace builds under -# `--no-default-features`. +# `shell` is on by default so agent-bridle's registry() includes the tool +# (as a stub). The tool is listed, its schema is advertised, and invoke() +# returns a clear explanation instead of silently absent. default = ["shell"] -shell = [ - "dep:brush-core", - "dep:brush-builtins", - "dep:brush-coreutils-builtins", - "dep:tokio", -] -# Forward to core's real, kernel-enforced Landlock ruleset (Linux). Core's -# LandlockSandbox currently confines the `fs_write` axis (ADR 0001 L3); wiring -# it into this tool's spawn path is the next increment. Off-Linux / without the -# feature, core falls back to the advisory NoopSandbox (SandboxKind::None). +shell = ["dep:tokio"] +# linux-landlock forwarded for API compatibility — no-op in the stub. linux-landlock = ["agent-bridle-core/linux-landlock"] [dependencies] @@ -31,15 +26,15 @@ anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } async-trait = { workspace = true } - -# brush — the carried shell/coreutils runtime ("the hands"). MIT; see NOTICE. -# Pinned to OUR fork (workspace git+rev) for the `CommandInterceptor` exec/open -# hook (DESIGN §6). All three resolve to one fork checkout. A git dep blocks -# crates.io publish until the hook is upstreamed (out-of-scope until then). -brush-core = { workspace = true, optional = true } -brush-builtins = { workspace = true, optional = true } -brush-coreutils-builtins = { workspace = true, optional = true } tokio = { workspace = true, optional = true } +# brush deps — REMOVED pending reubeno/brush#1184. +# Restore by re-adding to workspace Cargo.toml and uncommenting in the shell +# feature above. See https://github.com/Gilamonster-Foundation/agent-bridle/issues/20 +# brush-core = { workspace = true, optional = true } +# brush-builtins = { workspace = true, optional = true } +# brush-coreutils-builtins = { workspace = true, optional = true } + [dev-dependencies] tokio = { workspace = true } +serde_json = { workspace = true } diff --git a/agent-bridle-tool-shell/src/caveat_interceptor.rs b/agent-bridle-tool-shell/src/caveat_interceptor.rs deleted file mode 100644 index 7d0da7b..0000000 --- a/agent-bridle-tool-shell/src/caveat_interceptor.rs +++ /dev/null @@ -1,324 +0,0 @@ -//! [`CaveatInterceptor`] — the in-process capability hook for free-form shell. -//! -//! brush 0.5 cannot be confined in-process by a cleared `PATH` + a builtin -//! allow-list: any command whose name contains a path separator (e.g. -//! `/bin/rm`, `./payload`) bypasses both `PATH` and the builtin table and runs -//! directly (DESIGN §6). Our brush fork closes this by exposing -//! [`brush_core::extensions::CommandInterceptor`], whose `before_exec` / -//! `before_open` hooks fire at the **single external-spawn funnel** and at -//! `Shell::open_file` — so a policy applied here cannot be circumvented by -//! spelling a command (or a redirection target) differently. This makes the -//! confined shell a true superset of an `sh -c` cmd-string shell, cross-OS. -//! -//! [`CaveatInterceptor`] carries one invocation's **effective** caveats (the -//! `ToolContext` minted by the gate) and delegates every decision to the -//! *shared* leash logic on [`ToolContext`] — [`ToolContext::check_exec`], -//! [`ToolContext::check_path_read`], [`ToolContext::check_path_write`]. It does -//! **not** duplicate the canonicalizing path check; the one in -//! `agent-bridle-core` (realpath, reject symlink/`..` escapes) is the single -//! source of truth. - -use std::path::Path; -use std::sync::{Arc, Mutex}; - -use agent_bridle_core::{Denial, DenialKind, ToolContext}; -use brush_core::extensions::{CommandInterceptor, ExecDecision, OpenDecision}; - -/// The shared, per-invocation denial sink. -/// -/// brush clones the [`CaveatInterceptor`] internally (the trait requires -/// `Clone`), so to see *every* denial the shell hit we cannot store the log in -/// the struct by value — each clone would get its own copy. The `Arc>` -/// makes all clones write to one vec. The `ShellTool` creates a *fresh* sink per -/// `invoke`, so two concurrent invocations never share one — that is what keeps -/// denials from cross-contaminating across invocations. -pub(crate) type DenialSink = Arc>>; - -/// A brush [`CommandInterceptor`] that enforces an invocation's effective -/// caveats in-process **and records each denial it makes** into a shared sink. -/// -/// Holds an `Option`: -/// -/// - `Some(cx)` — enforce `cx`'s effective caveats (the normal, constructed -/// case). Built per-invocation via [`CaveatInterceptor::new`]. -/// - `None` — the [`Default`] value. **Conservatively denies everything.** The -/// trait requires `Default`, but a `ToolContext` is un-forgeable (no public -/// constructor by design), so the default cannot carry caveats; denying is the -/// only safe behavior. A default-constructed interceptor must never reach a -/// live shell — but if one ever did, it is fail-closed, not allow-all. -/// -/// Every `Deny` is appended to the [`DenialSink`] so the shell tool can read a -/// **structured** signal after the run instead of string-matching stderr. An -/// `Allow` records nothing — so a permitted command that exits non-zero on its -/// own (e.g. exit 126) is never mistaken for a leash denial. -#[derive(Debug, Clone, Default)] -pub struct CaveatInterceptor { - /// The minted context whose effective caveats gate this shell, or `None` - /// for the fail-closed default. - cx: Option, - /// Shared sink every denial is recorded into. `None` only for the - /// [`Default`] interceptor (which still denies, just records nothing — - /// it never reaches a live shell). - sink: Option, -} - -impl CaveatInterceptor { - /// Build an interceptor that enforces `cx`'s effective caveats and records - /// every denial it makes into `sink`. - #[must_use] - pub(crate) fn new(cx: ToolContext, sink: DenialSink) -> Self { - Self { - cx: Some(cx), - sink: Some(sink), - } - } - - /// Record a denial into the shared sink (a no-op if there is no sink). - fn record(&self, kind: DenialKind, target: impl Into, reason: impl Into) { - if let Some(sink) = &self.sink { - // A poisoned mutex would only happen if a brush callback panicked - // mid-record; recover the inner vec rather than poison-propagate so - // a single bad record cannot lose the rest of the denial log. - let mut guard = sink - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - guard.push(Denial { - kind, - target: target.into(), - reason: reason.into(), - }); - } - } -} - -impl CommandInterceptor for CaveatInterceptor { - /// Deny unless the effective `exec` caveat allows `program`. - /// - /// `program` is what brush is about to spawn: for `PATH`-resolved commands - /// the resolved absolute path, and for path-separator commands the path as - /// written (`/bin/rm`, `./x`). We hand that string to - /// [`ToolContext::check_exec`], which allows it if the `exec` scope contains - /// it verbatim OR contains its basename — so a bare-name grant (`["git"]`) - /// matches the resolved `/usr/bin/git`, while `/bin/rm` is denied whenever - /// neither `rm` nor `/bin/rm` is granted (the path-separator bypass stays - /// closed at the funnel). - fn before_exec(&self, program: &str, _args: &[String]) -> ExecDecision { - match &self.cx { - Some(cx) => match cx.check_exec(program) { - Ok(()) => ExecDecision::Allow, - Err(e) => { - let reason = e.to_string(); - // Record the denial as a structured signal BEFORE returning. - self.record(DenialKind::Exec, program, &reason); - ExecDecision::Deny(reason) - } - }, - // Fail-closed default: no caveats means no authority. - None => { - let reason = "no effective caveats (default interceptor); exec denied".to_string(); - self.record(DenialKind::Exec, program, &reason); - ExecDecision::Deny(reason) - } - } - } - - /// Deny unless the effective `fs_read`/`fs_write` caveat allows `path`. - /// - /// `write` selects the axis. Both checks canonicalize first (realpath) and - /// reject paths that escape the granted scope via `..` or a symlink — that - /// logic is the shared one in `agent-bridle-core`, reused here, not copied. - fn before_open(&self, path: &Path, write: bool) -> OpenDecision { - let Some(cx) = &self.cx else { - let reason = "no effective caveats (default interceptor); open denied".to_string(); - self.record(DenialKind::Open, path.to_string_lossy(), &reason); - return OpenDecision::Deny(reason); - }; - let result = if write { - cx.check_path_write(path) - } else { - cx.check_path_read(path) - }; - match result { - Ok(()) => OpenDecision::Allow, - Err(e) => { - let reason = e.to_string(); - self.record(DenialKind::Open, path.to_string_lossy(), &reason); - OpenDecision::Deny(reason) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use agent_bridle_core::{Caveats, Gate, Scope, Tool, ToolResult}; - - /// Mint a `ToolContext` the only legitimate way — through the gate — using a - /// trivial tool with default (`top`) requirements so `effective == granted`. - fn ctx(granted: Caveats) -> ToolContext { - struct AnyTool; - #[async_trait::async_trait] - impl Tool for AnyTool { - fn name(&self) -> &str { - "any" - } - fn schema(&self) -> serde_json::Value { - serde_json::json!({}) - } - async fn invoke( - &self, - _args: serde_json::Value, - _cx: &ToolContext, - ) -> ToolResult { - Ok(serde_json::Value::Null) - } - } - Gate::new(0) - .authorize(&AnyTool, &granted) - .expect("authorize") - } - - /// Build an interceptor with a fresh sink, returning both so a test can - /// assert on what was recorded. - fn interceptor_with_sink(granted: Caveats) -> (CaveatInterceptor, DenialSink) { - let sink: DenialSink = Arc::new(Mutex::new(Vec::new())); - let interceptor = CaveatInterceptor::new(ctx(granted), Arc::clone(&sink)); - (interceptor, sink) - } - - /// Snapshot the sink's recorded denials. - fn drain(sink: &DenialSink) -> Vec { - sink.lock().unwrap().clone() - } - - #[test] - fn default_is_fail_closed() { - let interceptor = CaveatInterceptor::default(); - assert!(matches!( - interceptor.before_exec("echo", &[]), - ExecDecision::Deny(_) - )); - assert!(matches!( - interceptor.before_open(Path::new("/tmp"), false), - OpenDecision::Deny(_) - )); - } - - #[test] - fn before_exec_allows_in_scope_denies_out_of_scope() { - let (interceptor, sink) = interceptor_with_sink(Caveats { - exec: Scope::only(["echo".to_string()]), - ..Caveats::top() - }); - assert!(matches!( - interceptor.before_exec("echo", &[]), - ExecDecision::Allow - )); - assert!(matches!( - interceptor.before_exec("rm", &["-rf".to_string()]), - ExecDecision::Deny(_) - )); - // An Allow records nothing; only the Deny is in the sink. - let recorded = drain(&sink); - assert_eq!( - recorded.len(), - 1, - "exactly one denial expected: {recorded:?}" - ); - assert_eq!(recorded[0].kind, DenialKind::Exec); - assert_eq!(recorded[0].target, "rm"); - assert!(recorded[0].reason.contains("not within the granted")); - } - - #[test] - fn before_exec_denies_path_separator_spelled_command() { - // The load-bearing case the hook exists for: `/bin/rm` is denied because - // `/bin/rm` is not within `exec: Only{echo}` — the path-separator bypass - // is closed, since the hook fires even for path-separator commands. - let (interceptor, sink) = interceptor_with_sink(Caveats { - exec: Scope::only(["echo".to_string()]), - ..Caveats::top() - }); - assert!(matches!( - interceptor.before_exec("/bin/rm", &["-rf".to_string()]), - ExecDecision::Deny(_) - )); - // The denial records the path-separator program verbatim. - let recorded = drain(&sink); - assert_eq!(recorded.len(), 1); - assert_eq!(recorded[0].kind, DenialKind::Exec); - assert_eq!(recorded[0].target, "/bin/rm"); - } - - #[test] - fn allow_records_nothing_in_sink() { - // A permitted exec must NOT leave a denial — this is what keeps a - // permitted command that exits 126 on its own from being flagged. - let (interceptor, sink) = interceptor_with_sink(Caveats { - exec: Scope::only(["echo".to_string()]), - ..Caveats::top() - }); - assert!(matches!( - interceptor.before_exec("echo", &[]), - ExecDecision::Allow - )); - assert!(drain(&sink).is_empty(), "an Allow must record nothing"); - } - - #[test] - fn before_open_write_uses_fs_write_axis() { - let dir = std::env::temp_dir(); - let (interceptor, _sink) = interceptor_with_sink(Caveats { - fs_write: Scope::only([dir.to_string_lossy().into_owned()]), - ..Caveats::top() - }); - // A new file under the allowed dir: write allowed. - assert!(matches!( - interceptor.before_open(&dir.join("ab-interceptor-ok.txt"), true), - OpenDecision::Allow - )); - // Clearly outside the allowed dir: write denied. - assert!(matches!( - interceptor.before_open(Path::new("/etc/shadow"), true), - OpenDecision::Deny(_) - )); - } - - #[test] - fn before_open_denial_is_recorded_as_open_kind() { - let dir = std::env::temp_dir(); - let (interceptor, sink) = interceptor_with_sink(Caveats { - fs_write: Scope::only([dir.to_string_lossy().into_owned()]), - ..Caveats::top() - }); - let _ = interceptor.before_open(Path::new("/etc/shadow"), true); - let recorded = drain(&sink); - assert_eq!(recorded.len(), 1, "one open denial expected: {recorded:?}"); - assert_eq!(recorded[0].kind, DenialKind::Open); - assert_eq!(recorded[0].target, "/etc/shadow"); - } - - #[test] - fn clones_share_one_sink() { - // brush clones the interceptor; every clone must write to the same sink - // (that is why the sink is Arc-shared). Two denials via two clones - // appear in the one shared log. - let (interceptor, sink) = interceptor_with_sink(Caveats { - exec: Scope::only(["echo".to_string()]), - ..Caveats::top() - }); - let clone = interceptor.clone(); - let _ = interceptor.before_exec("rm", &[]); - let _ = clone.before_exec("curl", &[]); - let recorded = drain(&sink); - assert_eq!( - recorded.len(), - 2, - "both clones' denials must land: {recorded:?}" - ); - let targets: Vec<&str> = recorded.iter().map(|d| d.target.as_str()).collect(); - assert!(targets.contains(&"rm")); - assert!(targets.contains(&"curl")); - } -} diff --git a/agent-bridle-tool-shell/src/lib.rs b/agent-bridle-tool-shell/src/lib.rs index 795e10e..bc240fa 100644 --- a/agent-bridle-tool-shell/src/lib.rs +++ b/agent-bridle-tool-shell/src/lib.rs @@ -1,38 +1,33 @@ -//! `agent-bridle-tool-shell` — a capability-confined, brush-backed shell tool. +//! `agent-bridle-tool-shell` — capability-confined shell tool (stub release). //! -//! brush is the carried, batteries-included shell/coreutils runtime ("the -//! hands"); this crate puts it on the [`agent_bridle_core`] leash. The tool -//! accepts **two input shapes**: +//! The full brush-backed implementation with `CommandInterceptor` exec/open +//! interception is temporarily disabled pending the upstream PR that adds the +//! hook to `reubeno/brush`: //! -//! - **argv form** (`program` + `args`) — a single named command; -//! - **free-form `cmd`** — an `sh -c`-style command string (pipelines, -//! redirections, `&&`, globbing). +//! //! -//! Both are now confined *in-process* by the [`CaveatInterceptor`], which rides -//! the `CommandInterceptor` exec/open hook in our brush fork. brush 0.5 bypasses -//! `PATH` and the builtin table for any command containing a path separator -//! (DESIGN §6) — so `/bin/rm` would otherwise run even with an empty `PATH` and -//! an `exec` allow-list. The hook fires at the single external-spawn funnel -//! (catching that bypass) and at every `Shell::open_file` (redirections and -//! `source`), so free-form scripts are gateable too. This makes the confined -//! shell a **true superset** of an `sh -c` cmd-string shell, cross-OS — the -//! prerequisite for superseding an unconfined `shell_run`. +//! In this stub release, [`ShellTool`] registers in the tool registry and +//! advertises its complete JSON schema, but [`Tool::invoke`] returns a +//! structured error explaining the situation. No functionality is silently +//! missing — the error message links to the tracking issue. //! -//! Landlock remains the authoritative Linux backstop (recorded as -//! `sandbox_kind`); off-Linux the in-process hook is the enforcement. +//! **Restoring full support:** once the upstream PR merges and brush ships a +//! crates.io release containing `CommandInterceptor`, restore from git history: //! -//! The crate compiles with the `shell` feature off — it then exposes nothing — -//! so the workspace builds under `--no-default-features`. +//! ```text +//! git show :agent-bridle-tool-shell/src/shell_tool.rs +//! git show :agent-bridle-tool-shell/src/caveat_interceptor.rs +//! ``` +//! +//! Then add brush back to `Cargo.toml` (see commented lines there) and publish +//! a new agent-bridle version. See the tracking issue for the full checklist: +//! #![forbid(unsafe_code)] #![warn(missing_docs)] -#[cfg(feature = "shell")] -mod caveat_interceptor; #[cfg(feature = "shell")] mod shell_tool; -#[cfg(feature = "shell")] -pub use caveat_interceptor::CaveatInterceptor; #[cfg(feature = "shell")] pub use shell_tool::ShellTool; diff --git a/agent-bridle-tool-shell/src/shell_tool.rs b/agent-bridle-tool-shell/src/shell_tool.rs index 48aeb4f..3f6f4c8 100644 --- a/agent-bridle-tool-shell/src/shell_tool.rs +++ b/agent-bridle-tool-shell/src/shell_tool.rs @@ -1,69 +1,29 @@ -//! The confined [`ShellTool`]. - -use std::collections::HashMap; -use std::io::Read; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use agent_bridle_core::{ - Caveats, Denial, SandboxKind, Tool, ToolContext, ToolEnvelope, ToolError, ToolResult, -}; +//! Stub [`ShellTool`] — full brush implementation pending reubeno/brush#1184. +//! +//! The tool registers in the registry and advertises its full schema so +//! consumers can introspect the interface. `invoke()` returns a structured +//! error rather than silently absent functionality. +//! +//! To restore the full `CommandInterceptor`-backed implementation: +//! 1. Wait for to merge and ship +//! a crates.io release. +//! 2. Add brush deps back to `Cargo.toml` (see commented lines there). +//! 3. Restore `caveat_interceptor.rs` and the full `shell_tool.rs` from git +//! history (`git show HEAD~1:agent-bridle-tool-shell/src/shell_tool.rs`). +//! +//! See . + +use agent_bridle_core::{Tool, ToolContext, ToolError, ToolResult}; use async_trait::async_trait; -use brush_builtins::{default_builtins, BuiltinSet}; -use brush_core::extensions::{DefaultErrorFormatter, ShellExtensionsImpl}; -use brush_core::openfiles::OpenFile; -use brush_core::{Shell, ShellFd}; -use crate::caveat_interceptor::{CaveatInterceptor, DenialSink}; - -/// Maximum permitted timeout, in seconds. Requests above this are clamped. +/// Maximum permitted timeout constant — kept for schema accuracy. const MAX_TIMEOUT_SECS: u64 = 300; -/// Default timeout when the caller does not specify one. -const DEFAULT_TIMEOUT_SECS: u64 = 30; - -/// brush fds. -const STDOUT_FD: ShellFd = 1; -const STDERR_FD: ShellFd = 2; - -/// A standard `PATH` for free-form (`cmd`) mode so external commands *resolve* -/// — and therefore reach the [`CaveatInterceptor`]'s `before_exec` hook, which -/// is the real gate. Without a resolvable `PATH`, a denied command like `rm` -/// would fail as "command not found" before the hook ever fires; with it, the -/// hook denies it explicitly (and `/bin/rm` is denied regardless of `PATH`, -/// since the path-separator branch goes straight to the spawn funnel). The host -/// environment is still *not* inherited — only this `PATH` is seeded. -/// -/// `/opt/homebrew/bin` is first so Apple-Silicon Homebrew tools (git, rg, node, -/// python3, …) resolve on a macOS testbed — that prefix is *the* arm64 brew -/// location, and without it brew-installed externals are "command not found" on -/// Apple Silicon. `/usr/local/bin` covers Intel-mac brew + Linux; non-existent -/// dirs are simply skipped during resolution, so listing all is harmless. This -/// widens *resolution* only, not authority: the `exec` caveat still gates (by -/// basename) what is actually allowed to run. -const FREEFORM_PATH: &str = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; - -/// The brush [`ShellExtensions`](brush_core::extensions::ShellExtensions) we -/// build: the default error formatter plus our capability [`CaveatInterceptor`]. -type LeashedExtensions = ShellExtensionsImpl; -/// A shell tool that runs a command through a brush shell confined by the leash. +/// A shell tool stub. /// -/// It accepts **two input shapes**: -/// -/// - **argv form** (`program` + `args`) — one named command. `invoke` first -/// asks the leash whether `program` may execute ([`ToolContext::check_exec`]), -/// denying out-of-scope programs before any shell is built. -/// - **free-form `cmd`** — an `sh -c`-style command string (pipelines, -/// redirections, `&&`, globbing). This is the drop-in shape for an unconfined -/// `shell_run`. -/// -/// In **both** shapes the brush shell is built with a [`CaveatInterceptor`] -/// carrying this invocation's effective caveats. The interceptor rides the brush -/// fork's `CommandInterceptor` hook: `before_exec` denies any program not in the -/// `exec` scope (including path-separator-spelled commands like `/bin/rm`, which -/// otherwise bypass `PATH` and the builtin table — DESIGN §6), and `before_open` -/// denies redirections/`source` opens outside `fs_read`/`fs_write`. So -/// confinement is real in-process, cross-OS — a true superset of `sh -c`. +/// Registers under the name `"shell"` and advertises the full argv/free-form +/// interface. Invocations return [`ToolError::Other`] with a message explaining +/// the temporary degradation and pointing to the tracking issue. #[derive(Debug, Default, Clone, Copy)] pub struct ShellTool; @@ -75,124 +35,6 @@ impl ShellTool { } } -/// Which input shape an invocation used — it changes how the shell's `PATH` is -/// seeded (see [`FREEFORM_PATH`]). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Mode { - /// `program` + `args`: a single named command, gated by an `exec` pre-check; - /// `PATH` is cleared so carried builtins prove they run with no host binary. - Argv, - /// `cmd`: a free-form `sh -c`-style string; `PATH` is seeded so externals - /// resolve to the [`CaveatInterceptor`]'s `before_exec` hook. - FreeForm, -} - -/// Parsed, validated arguments for one shell invocation. -struct ShellArgs { - /// The fully-formed command line handed to `run_dash_c_command`. - command: String, - /// The argv0 program name (argv mode only), used for the `exec` pre-check. - program: Option, - mode: Mode, - cwd: Option, - timeout: Duration, -} - -impl ShellArgs { - fn parse(args: &serde_json::Value) -> ToolResult { - let has_program = args.get("program").is_some(); - let has_cmd = args.get("cmd").is_some(); - - let cwd = args - .get("cwd") - .and_then(serde_json::Value::as_str) - .map(str::to_string); - - let timeout_secs = args - .get("timeout_secs") - .and_then(serde_json::Value::as_u64) - .unwrap_or(DEFAULT_TIMEOUT_SECS) - .min(MAX_TIMEOUT_SECS); - let timeout = Duration::from_secs(timeout_secs); - - match (has_program, has_cmd) { - (true, true) => Err(ToolError::denied( - "provide either `program` (argv form) or `cmd` (free-form), not both", - )), - (false, false) => Err(ToolError::denied( - "missing required field: one of `program` (argv form) or `cmd` (free-form)", - )), - (true, false) => Self::parse_argv(args, cwd, timeout), - (false, true) => Self::parse_freeform(args, cwd, timeout), - } - } - - /// Argv form: `program` (string) + optional `args` (array of strings). - fn parse_argv( - args: &serde_json::Value, - cwd: Option, - timeout: Duration, - ) -> ToolResult { - let program = args - .get("program") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| ToolError::denied("`program` must be a string"))? - .to_string(); - - let arg_list = match args.get("args") { - None | Some(serde_json::Value::Null) => Vec::new(), - Some(serde_json::Value::Array(a)) => a - .iter() - .map(|v| { - v.as_str() - .map(str::to_string) - .ok_or_else(|| ToolError::denied("`args` must be an array of strings")) - }) - .collect::>>()?, - Some(_) => return Err(ToolError::denied("`args` must be an array of strings")), - }; - - // Build a quoted command line so brush parses one simple command; each - // token is single-quoted (embedded quotes escaped) so args pass - // literally — no word-splitting, no expansion. - let mut command = sh_quote(&program); - for a in &arg_list { - command.push(' '); - command.push_str(&sh_quote(a)); - } - - Ok(Self { - command, - program: Some(program), - mode: Mode::Argv, - cwd, - timeout, - }) - } - - /// Free-form: `cmd` (string), run `sh -c`-style. No pre-quoting — the string - /// is the script. Confinement comes entirely from the interceptor hook. - fn parse_freeform( - args: &serde_json::Value, - cwd: Option, - timeout: Duration, - ) -> ToolResult { - let command = args - .get("cmd") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| ToolError::denied("`cmd` must be a string"))? - .to_string(); - - Ok(Self { - command, - program: None, - mode: Mode::FreeForm, - cwd, - timeout, - }) - } -} - #[async_trait] impl Tool for ShellTool { fn name(&self) -> &str { @@ -237,1093 +79,70 @@ impl Tool for ShellTool { async fn invoke( &self, - args: serde_json::Value, - cx: &ToolContext, + _args: serde_json::Value, + _cx: &ToolContext, ) -> ToolResult { - let parsed = ShellArgs::parse(&args)?; - // The kind we will actually run under (honest reporting). L3 is enforced - // on the confined thread below; this just predicts it for the envelope. - let sandbox_kind = intended_sandbox_kind(cx.caveats()); - - // (1) The leash, argv-mode fast path. If a *named* program is not in the - // `exec` scope, deny before building anything. Free-form (`cmd`) has no - // single named program; its exec gating is the interceptor's - // `before_exec` hook, which fires at the spawn funnel (below). - if let Some(program) = &parsed.program { - cx.check_exec(program)?; - } - - // (2) The OS-level sandbox (L3) is NOT applied here: Landlock's - // restrict_self is per-thread and irreversible, and this is the caller's - // (reused) async thread. It is applied inside `run_confined`, on the - // dedicated throwaway thread that actually runs brush — see there. - - // (3) Run via brush with carried builtins, captured output, the - // capability interceptor (the cross-OS in-process leash), and a timeout. - // Blocking shell work runs on a blocking thread; the interceptor carries - // THIS invocation's effective caveats. - // - // The denial sink is minted FRESH here, once per invocation, and shared - // (Arc) into the interceptor — and through it into every brush-made - // clone. We keep our own clone of the Arc so we can read the recorded - // denials after the brush task finishes. Because each invocation gets a - // brand-new sink, two concurrent invocations can never observe each - // other's denials. - let timeout = parsed.timeout; - let sink: DenialSink = Arc::new(Mutex::new(Vec::new())); - let interceptor = CaveatInterceptor::new(cx.clone(), Arc::clone(&sink)); - let effective = cx.caveats().clone(); - let run = tokio::task::spawn_blocking(move || run_confined(parsed, interceptor, effective)); - let joined = tokio::time::timeout(timeout, run).await; - - match joined { - // Completed in time. - Ok(join_result) => { - let captured = join_result - .map_err(|e| ToolError::Other(anyhow::anyhow!("shell task panicked: {e}")))??; - // Read the structured denial signal the interceptor recorded. - // `denied: true` is set iff at least one Deny actually fired — - // NOT merely because the command exited non-zero on its own. - let denials = take_denials(&sink); - Ok(ToolEnvelope::new(sandbox_kind) - .with_exit_code(captured.exit_code) - .with_stdout(captured.stdout) - .with_stderr(captured.stderr) - .with_timed_out(false) - .with_denials(denials) - .into_json()) - } - // Timed out: the blocking task is detached; report a timeout - // envelope so the caller sees the bound was hit. Surface any denials - // recorded before the bound was hit, too. - Err(_elapsed) => { - let denials = take_denials(&sink); - Ok(ToolEnvelope::new(sandbox_kind) - .with_stderr(format!("command timed out after {}s", timeout.as_secs())) - .with_timed_out(true) - .with_denials(denials) - .into_json()) - } - } - } -} - -/// Drain the per-invocation denial sink into an owned `Vec`. -/// -/// A poisoned lock (a brush callback panicking mid-record) is recovered rather -/// than propagated: a security signal is too important to drop because one -/// record raced a panic. -fn take_denials(sink: &DenialSink) -> Vec { - let mut guard = sink - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - std::mem::take(&mut *guard) -} - -/// The sandbox kind this invocation will actually run under, for honest -/// reporting in the result envelope. -/// -/// Landlock only when the `linux-landlock` feature + kernel support it **and** -/// the caveats actually restrict `fs_write` (the only axis L3 governs in this -/// increment). Otherwise [`SandboxKind::None`] — the leash is then the in-process -/// L2 interceptor only, advertised honestly. `fs_write = All` means nothing to -/// confine at L3, so it is reported (and applied) as None. -fn intended_sandbox_kind(effective: &Caveats) -> SandboxKind { - #[cfg(all(target_os = "linux", feature = "linux-landlock"))] - { - if matches!(effective.fs_write, agent_bridle_core::Scope::Only(_)) - && agent_bridle_core::landlock_is_supported() - { - return SandboxKind::Landlock; - } - } - let _ = effective; - SandboxKind::None -} - -/// Enforce the OS-level sandbox (L3) on the **current** thread. -/// -/// MUST be called on the dedicated, throwaway shell thread (see [`run_confined`]) -/// — never on a reused thread — because Landlock's `restrict_self` is per-thread -/// and irreversible. A no-op unless [`intended_sandbox_kind`] is Landlock. -/// Fail-closed: a requested-but-unenforceable ruleset returns `Err`. -fn apply_sandbox(effective: &Caveats) -> ToolResult<()> { - #[cfg(all(target_os = "linux", feature = "linux-landlock"))] - { - use agent_bridle_core::Sandbox; - if intended_sandbox_kind(effective) == SandboxKind::Landlock { - agent_bridle_core::LandlockSandbox::new().apply(effective)?; - } - } - let _ = effective; - Ok(()) -} - -/// Run the confined shell on a **dedicated, throwaway OS thread**. -/// -/// L3's Landlock `restrict_self` is per-thread and irreversible, and tokio -/// reuses its blocking-pool threads — so restricting a pool thread would poison -/// every later `spawn_blocking` task that lands on it. A fresh thread that dies -/// when the invocation ends cannot leak its restriction. brush runs on a -/// current-thread runtime *on this same thread* (see [`run_in_brush`]), so the -/// `fork`/`exec` of any child it spawns happens here too and the child inherits -/// the Landlock domain. -fn run_confined( - parsed: ShellArgs, - interceptor: CaveatInterceptor, - effective: Caveats, -) -> ToolResult { - std::thread::Builder::new() - .name("agent-bridle-confined-shell".into()) - .spawn(move || run_in_brush(parsed, interceptor, &effective)) - .map_err(|e| ToolError::Exec(io_ctx("spawn confined shell thread", e)))? - .join() - .map_err(|_| ToolError::Other(anyhow::anyhow!("confined shell thread panicked")))? -} - -/// What a finished brush run produced. -struct Captured { - exit_code: i32, - stdout: String, - stderr: String, -} - -/// Drive a brush shell to completion for one command, capturing stdout/stderr -/// via real OS pipes (DESIGN §6: `Arc>>` will not compile against -/// brush's `Stream`; pipes are mandatory). The shell is built with the supplied -/// [`CaveatInterceptor`] so exec/open are confined in-process. -/// -/// This is synchronous: it runs on the dedicated confined thread (see -/// [`run_confined`]) and spins a tiny current-thread tokio runtime for brush's -/// async shell API. -fn run_in_brush( - parsed: ShellArgs, - interceptor: CaveatInterceptor, - effective: &Caveats, -) -> ToolResult { - // Real OS pipes for fd 1 and 2. Created BEFORE the sandbox so their fds are - // already open: Landlock governs the path-based `open(2)`, not writes to an - // already-open fd, so capture keeps working under any `fs_write` scope. - let (out_reader, out_writer) = - std::io::pipe().map_err(|e| ToolError::Exec(io_ctx("create stdout pipe", e)))?; - let (err_reader, err_writer) = - std::io::pipe().map_err(|e| ToolError::Exec(io_ctx("create stderr pipe", e)))?; - - // Drain the read ends on background threads so a chatty command cannot - // deadlock by filling the pipe buffer before the shell exits. - let out_handle = std::thread::spawn(move || drain(out_reader)); - let err_handle = std::thread::spawn(move || drain(err_reader)); - - // L3: enforce the OS-level sandbox on THIS (fresh, throwaway) thread, before - // any command runs. brush's current-thread runtime and every child it forks - // run on this thread and inherit the Landlock domain. Reads/exec are - // ungoverned in this increment; `fs_write` is confined to the granted scope. - // Fail-closed: a requested-but-unenforceable ruleset aborts the invocation. - apply_sandbox(effective)?; - - // brush's shell API is async; run it on a single-thread runtime confined to - // this blocking worker thread. - // - // IO **must** be enabled (not just time): command substitution `$(...)` - // sets up real OS pipes via tokio's IO driver. With IO disabled, `$(/bin/rm - // ...)` panics deep in tokio ("IO is disabled"); that panic surfaces from - // `invoke` as an opaque `Err` instead of the clean, structured `denied: - // true` envelope the leash is supposed to produce. With IO enabled, the - // substitution runs through the normal command path, the inner program hits - // the `before_exec` funnel, and an out-of-scope command yields a recorded - // denial — fail-closed *and* legible. `enable_all` turns on both the IO and - // time drivers. - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| ToolError::Exec(io_ctx("build shell runtime", e)))?; - - let mode = parsed.mode; - let command = parsed.command; - let working_dir = parsed.cwd.map(std::path::PathBuf::from); - - let exit_code = rt.block_on(async move { - let mut fds: HashMap = HashMap::new(); - fds.insert(STDOUT_FD, OpenFile::from(out_writer)); - fds.insert(STDERR_FD, OpenFile::from(err_writer)); - - let mut shell: Shell = - Shell::builder_with_extensions::() - // The capability hook — THE in-process leash for free-form scripts. - .command_interceptor(interceptor) - // Carried builtins for our custom extensions type — but CURATED: - // every builtin that can spawn/exec/open OUTSIDE the two - // interceptor funnels (`before_exec` / `before_open`) is removed, - // so the confined shell has no uncovered path to authority. See - // [`confined_builtins`]. - .builtins(confined_builtins()) - // Do not inherit the host environment — confinement, and so a - // carried builtin must come from brush, not the host PATH. - .do_not_inherit_env(true) - .no_editing(true) - .interactive(false) - .fds(fds) - // `maybe_working_dir` accepts the Option without changing the - // builder typestate path conditionally. - .maybe_working_dir(working_dir) - .build() - .await - .map_err(|e| ToolError::Exec(io_ctx("build shell", into_io(&e))))?; - - // PATH policy depends on the mode: - // - // - Argv: empty PATH proves carried builtins run with no host binary on - // disk (the named program was already exec-gated before we got here). - // - FreeForm: a standard PATH so external commands *resolve* and reach - // the interceptor's `before_exec` hook (the real gate). The host env - // is still not inherited (`do_not_inherit_env(true)`); only PATH is - // seeded. `/bin/rm`-style path-separator commands are denied either - // way, since they go straight to the spawn funnel. - let path_value = match mode { - Mode::Argv => "", - Mode::FreeForm => FREEFORM_PATH, - }; - shell - .env_mut() - .set_global( - "PATH", - brush_core::variables::ShellVariable::new(path_value), - ) - .map_err(|e| ToolError::Exec(io_ctx("seed PATH", into_io(&e))))?; - - let result = shell - .run_dash_c_command(command) - .await - .map_err(|e| ToolError::Exec(io_ctx("run command", into_io(&e))))?; - - // Drop the shell so it releases its clones of the pipe writers; only - // then will the reader threads see EOF. - drop(shell); - - Ok::(i32::from(u8::from(result.exit_code))) - })?; - - let stdout = out_handle - .join() - .map_err(|_| ToolError::Other(anyhow::anyhow!("stdout reader thread panicked")))??; - let stderr = err_handle - .join() - .map_err(|_| ToolError::Other(anyhow::anyhow!("stderr reader thread panicked")))??; - - Ok(Captured { - exit_code, - stdout, - stderr, - }) -} - -/// Builtins removed from the confined shell because they reach `spawn`/`exec`/ -/// `open` *without* going through the interceptor funnels (`before_exec` at the -/// single external-spawn site, `before_open` at `Shell::open_file`). -/// -/// The audit of the brush fork (rev 4e65a06) found exactly one such builtin: -/// -/// - **`exec`** — In its non-subshell branch, `exec` -/// ([`brush-builtins/src/exec.rs`]) calls `compose_std_command(...)` and then -/// `cmd.exec()` (replace-process) *directly*, never consulting -/// `before_exec`. PROVEN bypass: under `exec: only{echo}`, -/// `exec /usr/bin/touch MARKER` ran `touch` (the marker file appeared) and -/// actually replaced the host process image — the leash never fired. A -/// confined shell does not need `exec` (there is nothing to replace into), -/// so removing it costs nothing and closes a live authority leak. -/// -/// Every *other* path that can run a program or open a file was verified to -/// funnel through a hook and is therefore intentionally KEPT: -/// -/// - `command`, `eval`, `.`/`source`, `fc`, `coproc`, background `&`, -/// process substitution `<(...)`/`>(...)`, and ordinary external commands all -/// route through `SimpleCommand::execute` → `execute_external_command`, whose -/// first act is `before_exec` (the funnel). -/// - `mapfile`/`readarray` and `read` only consume *already-open* fds -/// (`try_fd`); the redirection that opened the fd went through `before_open`. -/// - Redirections and `source` open files exclusively via `Shell::open_file`, -/// which calls `before_open`. -/// -/// `exec` cannot be smuggled back in: `enable -f` (load a builtin from a shared -/// object) is `unimp` in the fork, and `enable`/`builtin exec` only act on -/// registrations already present in the map — once removed, there is nothing to -/// re-enable. -const REMOVED_BUILTINS: &[&str] = &["exec"]; - -/// The curated builtin set for the confined shell: the bash-mode default set -/// with every [`REMOVED_BUILTINS`] entry stripped out. -/// -/// `default_builtins` hands back a plain owned `HashMap`, so omission is a -/// `remove` on that map — robust-by-construction: the shell builder seeds an -/// empty map and only takes what we give it, and nothing in brush -/// auto-registers a builtin, so a removed builtin is simply *gone* (a confined -/// shell that runs `exec` gets "command not found", never a spawn). -fn confined_builtins() -> HashMap> { - let mut builtins = default_builtins::(BuiltinSet::BashMode); - for name in REMOVED_BUILTINS { - builtins.remove(*name); - } - builtins -} - -/// Read a pipe to EOF, returning its bytes as a lossy UTF-8 string. -fn drain(mut reader: std::io::PipeReader) -> ToolResult { - let mut buf = Vec::new(); - reader - .read_to_end(&mut buf) - .map_err(|e| ToolError::Exec(io_ctx("drain pipe", e)))?; - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -/// Single-quote a shell token, escaping embedded single quotes the POSIX way -/// (`'` → `'\''`). Guarantees the token is passed literally. -fn sh_quote(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('\''); - for ch in s.chars() { - if ch == '\'' { - out.push_str("'\\''"); - } else { - out.push(ch); - } + Err(ToolError::Other(anyhow::anyhow!( + "shell tool is temporarily unavailable in this build.\n\ + \n\ + The confined shell requires the `CommandInterceptor` hook from our \ + brush fork, which cannot be packaged as a crates.io dependency until \ + the upstream PR merges: https://github.com/reubeno/brush/pull/1184\n\ + \n\ + Tracking: https://github.com/Gilamonster-Foundation/agent-bridle/issues/20" + ))) } - out.push('\''); - out -} - -/// Wrap a context string around an io::Error. -fn io_ctx(ctx: &str, e: std::io::Error) -> std::io::Error { - std::io::Error::new(e.kind(), format!("{ctx}: {e}")) -} - -/// Render a brush error as an io::Error (brush's Error is not io::Error). -fn into_io(e: &brush_core::Error) -> std::io::Error { - std::io::Error::other(e.to_string()) } #[cfg(test)] mod tests { use super::*; - use agent_bridle_core::{CountBound, Gate, Scope}; - - /// Mint a context for the shell tool through the gate, the only legitimate - /// way. - fn authorize(granted: &Caveats) -> ToolResult { - let tool = ShellTool::new(); - Gate::new(0).authorize(&tool, granted) - } - - fn echo_grant() -> Caveats { - Caveats { - exec: Scope::only(["echo".to_string()]), - max_calls: CountBound::AtMost(8), - ..Caveats::top() - } - } - - #[tokio::test] - async fn echo_in_scope_runs_and_captures_stdout() { - let cx = authorize(&echo_grant()).unwrap(); - let out = ShellTool::new() - .invoke( - serde_json::json!({ "program": "echo", "args": ["hi"] }), - &cx, - ) - .await - .expect("invoke"); - assert_eq!(out["exit_code"], 0); - assert!( - out["stdout"].as_str().unwrap().contains("hi"), - "stdout was {:?}", - out["stdout"] - ); - // The recorded sandbox kind travels with the result. - assert_eq!(out["sandbox_kind"], "none"); - assert_eq!(out["timed_out"], false); - } - - #[tokio::test] - async fn carried_builtin_runs_with_empty_path() { - // No host PATH is inherited and PATH is cleared inside the shell; echo - // still works because it is a *carried* brush builtin. - let cx = authorize(&echo_grant()).unwrap(); - let out = ShellTool::new() - .invoke( - serde_json::json!({ "program": "echo", "args": ["carried"] }), - &cx, - ) - .await - .expect("invoke"); - assert!(out["stdout"].as_str().unwrap().contains("carried")); - } - - #[tokio::test] - async fn out_of_scope_program_is_denied() { - // `rm` is not in the granted exec scope → denied before running. - let cx = authorize(&echo_grant()).unwrap(); - let err = ShellTool::new() - .invoke( - serde_json::json!({ "program": "rm", "args": ["-rf", "/"] }), - &cx, - ) - .await - .unwrap_err(); - assert!(matches!(err, ToolError::Denied { .. }), "got {err:?}"); - } - - #[tokio::test] - async fn third_call_denied_by_budget() { - // One shared gate, AtMost(2): first two authorize, the third is Budget. - let tool = ShellTool::new(); - let granted = echo_grant(); - let gate = Gate::with_budget(0, CountBound::AtMost(2)); - - let cx1 = gate.authorize(&tool, &granted).unwrap(); - let _ = tool - .invoke( - serde_json::json!({ "program": "echo", "args": ["1"] }), - &cx1, - ) - .await - .unwrap(); - - let cx2 = gate.authorize(&tool, &granted).unwrap(); - let _ = tool - .invoke( - serde_json::json!({ "program": "echo", "args": ["2"] }), - &cx2, - ) - .await - .unwrap(); - - let denied = gate.authorize(&tool, &granted).unwrap_err(); - assert!(matches!(denied, ToolError::Budget), "got {denied:?}"); - } #[test] - fn schema_has_both_argv_and_freeform() { - let s = ShellTool::new().schema(); - // Argv form. - assert_eq!(s["properties"]["program"]["type"], "string"); - // Free-form `cmd` superset (now landed behind the interceptor hook). - assert_eq!(s["properties"]["cmd"]["type"], "string"); - // Neither is `required` — exactly one must be supplied (validated at - // parse time), and they are mutually exclusive. - assert!(s.get("required").is_none()); - } - - // ── Free-form (`cmd`) confinement via the interceptor hook ────────────── - - #[tokio::test] - async fn freeform_echo_in_scope_runs() { - // cmd `echo hi` → allowed (echo is a carried builtin), stdout has `hi`. - let cx = authorize(&echo_grant()).unwrap(); - let out = ShellTool::new() - .invoke(serde_json::json!({ "cmd": "echo hi" }), &cx) - .await - .expect("invoke"); - assert_eq!(out["exit_code"], 0); - assert!( - out["stdout"].as_str().unwrap().contains("hi"), - "stdout was {:?}", - out["stdout"] - ); - } - - #[tokio::test] - async fn freeform_rm_denied_via_before_exec() { - // (a) cmd `rm -rf /tmp/x` → DENIED via before_exec (rm ∉ exec). A - // standard PATH is seeded so `rm` resolves and the hook (not "command - // not found") is what stops it. We assert the STRUCTURED signal: the - // result envelope carries `denied: true` and a denials entry naming - // kind=exec / target=rm — no stderr string-matching needed. - let cx = authorize(&echo_grant()).unwrap(); - let out = ShellTool::new() - .invoke(serde_json::json!({ "cmd": "rm -rf /tmp/x" }), &cx) - .await - .expect("invoke"); - assert_ne!(out["exit_code"], 0, "rm must not succeed: {out:?}"); - // The headline assertion: structured denial, not stderr-grepping. - assert_eq!( - out["denied"], true, - "result must be flagged denied: {out:?}" - ); - let denials = out["denials"].as_array().expect("denials array"); - // The target is exactly what brush handed the hook: for a PATH-resolved - // command that is the resolved absolute path (e.g. /usr/bin/rm), so we - // assert kind=exec and that the program is `rm` (the basename). - assert!( - denials.iter().any(|d| d["kind"] == "exec" - && d["target"] - .as_str() - .is_some_and(|t| t == "rm" || t.ends_with("/rm"))), - "expected an exec denial naming rm, got {denials:?}" - ); - // The reason is carried for surfacing to the agent. - assert!(denials[0]["reason"] - .as_str() - .unwrap() - .contains("not within the granted")); - } - - #[tokio::test] - async fn freeform_bin_rm_denied_closes_path_separator_bypass() { - // (b) cmd `/bin/rm -rf /tmp/x` → DENIED. THE load-bearing case: a - // path-separator command bypasses PATH and the builtin table, so a - // cleared PATH alone would NOT stop it — only the before_exec hook (at - // the single spawn funnel) does. Proves the bypass is closed AND that - // the path-separator denial is flagged structurally. - let cx = authorize(&echo_grant()).unwrap(); - let out = ShellTool::new() - .invoke(serde_json::json!({ "cmd": "/bin/rm -rf /tmp/x" }), &cx) - .await - .expect("invoke"); - assert_ne!(out["exit_code"], 0, "/bin/rm must not succeed: {out:?}"); - assert_eq!( - out["denied"], true, - "/bin/rm must be flagged denied: {out:?}" - ); - let denials = out["denials"].as_array().expect("denials array"); - assert!( - denials - .iter() - .any(|d| d["kind"] == "exec" && d["target"] == "/bin/rm"), - "expected an exec//bin/rm denial, got {denials:?}" - ); - } - - #[tokio::test] - async fn freeform_write_outside_fs_write_denied_via_before_open() { - // cmd `echo x > ` → DENIED via before_open. fs_write is - // restricted to a temp scratch dir; a redirect to /etc is refused. - let scratch = std::env::temp_dir().join(format!( - "ab-shell-fswrite-{}-{}", - std::process::id(), - COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - std::fs::create_dir_all(&scratch).expect("mkdir scratch"); - - let cx = authorize(&Caveats { - exec: Scope::only(["echo".to_string()]), - fs_write: Scope::only([scratch.to_string_lossy().into_owned()]), - max_calls: CountBound::AtMost(8), - ..Caveats::top() - }) - .unwrap(); - - let out = ShellTool::new() - .invoke( - serde_json::json!({ "cmd": "echo x > /etc/ab-should-not-exist" }), - &cx, - ) - .await - .expect("invoke"); - assert_ne!( - out["exit_code"], 0, - "redirect outside scope must fail: {out:?}" - ); - // (c) the open-denial is flagged structurally with kind=open and the - // refused path as target. - assert_eq!( - out["denied"], true, - "redirect must be flagged denied: {out:?}" - ); - let denials = out["denials"].as_array().expect("denials array"); - assert!( - denials.iter().any(|d| d["kind"] == "open" - && d["target"] - .as_str() - .is_some_and(|t| t.contains("ab-should-not-exist"))), - "expected an open denial for the redirect target, got {denials:?}" - ); - assert!( - !std::path::Path::new("/etc/ab-should-not-exist").exists(), - "the denied file must not have been created" - ); - - let _ = std::fs::remove_dir_all(&scratch); - } - - #[tokio::test] - async fn freeform_allowed_redirection_within_scope_succeeds() { - // An allowed redirection within fs_write scope → succeeds, and the file - // is created with the expected content. - let scratch = std::env::temp_dir().join(format!( - "ab-shell-okwrite-{}-{}", - std::process::id(), - COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - std::fs::create_dir_all(&scratch).expect("mkdir scratch"); - let target = scratch.join("out.txt"); - - let cx = authorize(&Caveats { - exec: Scope::only(["echo".to_string()]), - fs_write: Scope::only([scratch.to_string_lossy().into_owned()]), - max_calls: CountBound::AtMost(8), - ..Caveats::top() - }) - .unwrap(); - - let cmd = format!("echo inscope > {}", target.display()); - let out = ShellTool::new() - .invoke(serde_json::json!({ "cmd": cmd }), &cx) - .await - .expect("invoke"); - assert_eq!( - out["exit_code"], 0, - "in-scope redirect must succeed: {out:?}" - ); - // An in-scope run records no denial: the structured field is omitted. - assert!(out.get("denied").is_none(), "must not be flagged: {out:?}"); - assert!(out.get("denials").is_none(), "no denials expected: {out:?}"); - let written = std::fs::read_to_string(&target).expect("read back"); - assert!(written.contains("inscope"), "file content was {written:?}"); - - let _ = std::fs::remove_dir_all(&scratch); - } - - #[tokio::test] - async fn freeform_permitted_command_exiting_126_is_not_flagged_denied() { - // (d) NO FALSE POSITIVES. A permitted command that itself exits 126 must - // NOT be flagged as a leash denial — `denied` is set ONLY when the - // interceptor actually recorded a Deny, never merely from a 126 exit. - // `exit 126` is a carried shell builtin: it sets the code with no - // exec/open going through the interceptor, so nothing is recorded. - let cx = authorize(&echo_grant()).unwrap(); - let out = ShellTool::new() - .invoke(serde_json::json!({ "cmd": "exit 126" }), &cx) - .await - .expect("invoke"); - assert_eq!(out["exit_code"], 126, "expected a raw 126 exit: {out:?}"); - // The whole point: 126 alone does NOT mean denied. - assert!( - out.get("denied").is_none(), - "a permitted 126 exit must NOT be flagged denied: {out:?}" - ); - assert!( - out.get("denials").is_none(), - "no denials must be recorded for a permitted 126 exit: {out:?}" - ); - } - - #[tokio::test] - async fn concurrent_invocations_do_not_cross_contaminate_denials() { - // (e) The sink is PER-INVOCATION. Two invocations run concurrently with - // different grants; each must see only its OWN denials, never the - // other's. Both use path-separator-spelled programs so the before_exec - // hook fires at the spawn funnel regardless of whether the binary is - // installed (no reliance on a host having `rm`/`curl` on PATH). - // Invocation A (echo-only) runs `/bin/rm` → its own exec//bin/rm denial. - // Invocation B (rm-allowed) runs `/usr/bin/curl` → its own - // exec//usr/bin/curl denial. If the sink leaked across invocations, A - // would see `curl` or B would see `rm`. - let grant_a = Caveats { - exec: Scope::only(["echo".to_string()]), - max_calls: CountBound::AtMost(8), - ..Caveats::top() - }; - let grant_b = Caveats { - exec: Scope::only(["rm".to_string()]), - max_calls: CountBound::AtMost(8), - ..Caveats::top() - }; - let cx_a = authorize(&grant_a).unwrap(); - let cx_b = authorize(&grant_b).unwrap(); - - // Drive both at once on the same runtime so their brush runs overlap. - let tool = ShellTool::new(); - let fut_a = tool.invoke(serde_json::json!({ "cmd": "/bin/rm /tmp/x" }), &cx_a); - let fut_b = tool.invoke( - serde_json::json!({ "cmd": "/usr/bin/curl http://x" }), - &cx_b, - ); - let (out_a, out_b) = tokio::join!(fut_a, fut_b); - let out_a = out_a.expect("invoke A"); - let out_b = out_b.expect("invoke B"); - - // Targets are whatever brush handed the hook (PATH-resolved absolute - // paths); test by basename so the assertion is host-independent. - let names = |out: &serde_json::Value| -> Vec { - out["denials"] - .as_array() - .unwrap() - .iter() - .map(|d| { - let t = d["target"].as_str().unwrap(); - t.rsplit('/').next().unwrap_or(t).to_string() - }) - .collect() - }; - - // A denied `rm`, and ONLY `rm` — never B's `curl`. - assert_eq!(out_a["denied"], true, "A must be denied: {out_a:?}"); - let a_names = names(&out_a); - assert!( - a_names.iter().any(|n| n == "rm"), - "A should see its own rm: {a_names:?}" - ); - assert!( - !a_names.iter().any(|n| n == "curl"), - "A must NOT see B's curl denial (cross-contamination): {a_names:?}" - ); - - // B denied `curl`, and ONLY `curl` — never A's `rm`. - assert_eq!(out_b["denied"], true, "B must be denied: {out_b:?}"); - let b_names = names(&out_b); - assert!( - b_names.iter().any(|n| n == "curl"), - "B should see its own curl: {b_names:?}" - ); - assert!( - !b_names.iter().any(|n| n == "rm"), - "B must NOT see A's rm denial (cross-contamination): {b_names:?}" - ); - } - - /// A program that is a REAL external on the CI runner (NOT one of brush's - /// carried builtins, which bypass `before_exec` and so would not exercise - /// the leash). `env` is on every Linux runner and is not in the brush - /// builtin set, so it genuinely funnels through the interceptor. - const EXTERNAL_PROG: &str = "env"; - - #[tokio::test] - async fn freeform_external_granted_by_bare_name_actually_runs() { - // THE missing end-to-end test. This whole bug existed because nothing - // ever ran an ALLOWED EXTERNAL by bare name through the interceptor. - // - // Grant `exec = only{"env"}` (a BARE NAME) and run the EXTERNAL `env` - // via the confined free-form shell. The interceptor hands `before_exec` - // the PATH-resolved absolute path (`/usr/bin/env`); before the fix the - // bare-name grant never matched that path and the command was DENIED. - // With basename matching it is ALLOWED: it runs, exits 0, and records - // NO denial. `env` is a real external (NOT a carried brush builtin), so - // this proves a bare-name-granted external executes through the hook. - if which_external(EXTERNAL_PROG).is_none() { - return; // external not present on this host; nothing to prove. - } - let cx = authorize(&Caveats { - exec: Scope::only([EXTERNAL_PROG.to_string()]), - max_calls: CountBound::AtMost(8), - ..Caveats::top() - }) - .unwrap(); - let out = ShellTool::new() - .invoke(serde_json::json!({ "cmd": EXTERNAL_PROG }), &cx) - .await - .expect("invoke"); - assert_eq!( - out["exit_code"], 0, - "a bare-name-granted external must run and exit 0: {out:?}" - ); - assert!( - out.get("denied").is_none(), - "a granted external must NOT be flagged denied: {out:?}" - ); - assert!( - out.get("denials").is_none(), - "no denials expected for a granted external: {out:?}" - ); - } - - #[tokio::test] - async fn freeform_external_not_granted_by_bare_name_is_denied() { - // The negative half: grant `exec = only{"echo"}` (a carried builtin - // only) and run the EXTERNAL `env`. Its resolved basename `env` is not - // in the grant, so the interceptor denies it via before_exec — proving - // basename matching did not over-broaden into allowing ungranted - // externals. - if which_external(EXTERNAL_PROG).is_none() { - return; - } - let cx = authorize(&echo_grant()).unwrap(); - let out = ShellTool::new() - .invoke(serde_json::json!({ "cmd": EXTERNAL_PROG }), &cx) - .await - .expect("invoke"); - assert_ne!( - out["exit_code"], 0, - "an ungranted external must not succeed: {out:?}" - ); - assert_eq!( - out["denied"], true, - "an ungranted external must be flagged denied: {out:?}" - ); - let denials = out["denials"].as_array().expect("denials array"); - assert!( - denials.iter().any(|d| d["kind"] == "exec" - && d["target"].as_str().is_some_and( - |t| t == EXTERNAL_PROG || t.ends_with(&format!("/{EXTERNAL_PROG}")) - )), - "expected an exec denial naming {EXTERNAL_PROG}, got {denials:?}" - ); - } - - /// Locate an external on the same `PATH` the free-form shell seeds - /// ([`FREEFORM_PATH`]), so the test's presence check matches what brush - /// will resolve. Returns `None` if not found (test self-skips). - fn which_external(name: &str) -> Option { - FREEFORM_PATH.split(':').find_map(|dir| { - let p = std::path::Path::new(dir).join(name); - p.is_file().then_some(p) - }) - } - - #[tokio::test] - async fn freeform_and_argv_are_mutually_exclusive() { - let cx = authorize(&echo_grant()).unwrap(); - let err = ShellTool::new() - .invoke( - serde_json::json!({ "program": "echo", "cmd": "echo hi" }), - &cx, - ) - .await - .unwrap_err(); - assert!(matches!(err, ToolError::Denied { .. }), "got {err:?}"); - } - - #[tokio::test] - async fn neither_program_nor_cmd_is_rejected() { - let cx = authorize(&echo_grant()).unwrap(); - let err = ShellTool::new() - .invoke(serde_json::json!({ "args": ["x"] }), &cx) - .await - .unwrap_err(); - assert!(matches!(err, ToolError::Denied { .. }), "got {err:?}"); - } - - // ── Security regression: bypass-capable builtins are removed ──────────── - // - // These prove the audit's two holes are closed, fail-closed. - - /// Build a unique scratch marker path under the temp dir (counter, not a - /// clock), pre-removed so a stale file can't mask a failure. - fn fresh_marker(tag: &str) -> std::path::PathBuf { - let p = std::env::temp_dir().join(format!( - "ab-sec-{tag}-{}-{}", - std::process::id(), - COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - let _ = std::fs::remove_file(&p); - p + fn name_is_shell() { + assert_eq!(ShellTool::new().name(), "shell"); } #[test] - fn exec_builtin_is_removed_from_the_confined_set() { - // The audit finding, asserted directly on the curated map: the only - // bypass-capable builtin (`exec`) is NOT registered in the confined - // shell, while a known-safe one (`echo`) still is. - let builtins = confined_builtins(); - assert!( - !builtins.contains_key("exec"), - "`exec` must be absent from the confined builtin set" - ); - for name in REMOVED_BUILTINS { - assert!( - !builtins.contains_key(*name), - "removed builtin `{name}` must be absent from the confined set" - ); - } - // The carried-safe builtins survive the curation. - assert!(builtins.contains_key("echo"), "echo must still be carried"); - assert!( - builtins.contains_key("command"), - "command (funnel-routed) is intentionally kept" - ); - } - - #[tokio::test] - async fn freeform_exec_builtin_cannot_run_a_denied_program() { - // THE load-bearing security regression. Before the fix, the `exec` - // builtin called `cmd.exec()` directly — bypassing `before_exec` — so - // `exec /usr/bin/touch MARKER` ran `touch` (and replaced the process - // image). With `exec` removed from the confined set it is "command not - // found": the marker must NOT be created, and the program must not run. - let marker = fresh_marker("exec-builtin"); - let cx = authorize(&echo_grant()).unwrap(); - let cmd = format!("exec /usr/bin/touch {}", marker.display()); - let out = ShellTool::new() - .invoke(serde_json::json!({ "cmd": cmd }), &cx) - .await - .expect("invoke must return cleanly, never panic/replace-process"); - // exec is gone → non-zero (command not found), and the program is dead. - assert_ne!(out["exit_code"], 0, "exec must not succeed: {out:?}"); - assert!( - !marker.exists(), - "the exec'd program must NOT have run (marker created): {out:?}" - ); - let _ = std::fs::remove_file(&marker); - } - - #[tokio::test] - async fn argv_exec_program_is_denied() { - // The argv form names `exec` as the program. It is not in the granted - // `exec` scope (`only{echo}`), so the leash's argv-mode pre-check denies - // it before any shell is built — defense in depth alongside removal. - let cx = authorize(&echo_grant()).unwrap(); - let err = ShellTool::new() - .invoke( - serde_json::json!({ "program": "exec", "args": ["/usr/bin/touch", "/tmp/x"] }), - &cx, - ) - .await - .unwrap_err(); - assert!(matches!(err, ToolError::Denied { .. }), "got {err:?}"); - } - - #[tokio::test] - async fn argv_exec_program_even_if_granted_does_not_bypass() { - // Even when `exec` IS in the granted scope, the builtin is absent from - // the confined shell, so the argv pre-check passes but the shell reports - // "command not found" rather than process-replacing into the target. - // The carried program never runs. - let marker = fresh_marker("argv-exec"); - let cx = authorize(&Caveats { - exec: Scope::only(["exec".to_string(), "echo".to_string()]), - max_calls: CountBound::AtMost(8), - ..Caveats::top() - }) - .unwrap(); - let out = ShellTool::new() - .invoke( - serde_json::json!({ - "program": "exec", - "args": ["/usr/bin/touch", marker.to_string_lossy()] - }), - &cx, - ) - .await - .expect("invoke must return cleanly"); - assert_ne!( - out["exit_code"], 0, - "exec builtin is gone → not found: {out:?}" - ); - assert!( - !marker.exists(), - "no program may run via a removed exec builtin: {out:?}" - ); - let _ = std::fs::remove_file(&marker); - } - - #[tokio::test] - async fn freeform_command_substitution_denies_cleanly_without_io_panic() { - // Before the fix, the runtime had only `enable_time()`, so `$(...)` - // command substitution panicked in tokio ("IO is disabled") and `invoke` - // returned an opaque Err. With IO enabled, the substitution runs through - // the funnel: the inner `/bin/rm` is denied via `before_exec`, the call - // returns a CLEAN envelope with `denied: true`, and nothing is deleted. - let victim = fresh_marker("cmdsubst-victim"); - std::fs::write(&victim, b"keep me").expect("seed victim file"); - - let cx = authorize(&echo_grant()).unwrap(); - let cmd = format!("echo $(/bin/rm -rf {})", victim.display()); - let out = ShellTool::new() - .invoke(serde_json::json!({ "cmd": cmd }), &cx) - .await - .expect("invoke must NOT be an Err/panic — a clean denial envelope"); - assert_eq!( - out["denied"], true, - "command substitution of a denied program must be flagged denied: {out:?}" - ); - let denials = out["denials"].as_array().expect("denials array"); - assert!( - denials.iter().any(|d| d["kind"] == "exec" - && d["target"] - .as_str() - .is_some_and(|t| t == "/bin/rm" || t.ends_with("/rm"))), - "expected an exec denial for /bin/rm, got {denials:?}" - ); - assert!( - victim.exists(), - "the denied rm must NOT have deleted the victim file: {out:?}" - ); - let _ = std::fs::remove_file(&victim); - } - - // ── L3 (Landlock) kernel enforcement of an EXTERNAL program's writes ──── - - #[cfg(all(target_os = "linux", feature = "linux-landlock"))] - #[tokio::test] - async fn l3_landlock_confines_external_program_write_outside_fs_write() { - // THE reason L3 exists. L2 (`before_open`) cannot see an *external* - // program's own writes once it has spawned — only the kernel can. Grant - // exec={touch} + fs_write=Only{scratch}, then run the genuine external - // `/usr/bin/touch` (path-separator form forces the spawn funnel, never a - // carried builtin) to create a marker OUTSIDE scratch. Before this wiring - // the marker WAS created (L2 is blind to the child); with the Landlock - // ruleset enforced on the confined thread the kernel denies the write, so - // the marker must NOT exist — even though the in-process leash never - // fired for the child's syscalls. - use agent_bridle_core::landlock_is_supported; - let touch = std::path::Path::new("/usr/bin/touch"); - if !landlock_is_supported() || !touch.is_file() { - return; // environment can't exercise this; self-skip. + fn schema_has_program_and_cmd_properties() { + let s = ShellTool::new().schema(); + let props = s.get("properties").unwrap(); + assert!(props.get("program").is_some()); + assert!(props.get("cmd").is_some()); + assert!(props.get("args").is_some()); + assert!(props.get("cwd").is_some()); + assert!(props.get("timeout_secs").is_some()); + } + + #[tokio::test] + async fn invoke_returns_unavailable_error() { + use agent_bridle_core::{Caveats, Gate, Tool}; + // ToolContext has no public constructor; mint one via Gate::authorize. + struct Passthrough; + #[async_trait::async_trait] + impl Tool for Passthrough { + fn name(&self) -> &str { + "passthrough" + } + fn schema(&self) -> serde_json::Value { + serde_json::Value::Null + } + async fn invoke( + &self, + _: serde_json::Value, + _: &agent_bridle_core::ToolContext, + ) -> agent_bridle_core::ToolResult { + Ok(serde_json::Value::Null) + } } - - let scratch = std::env::temp_dir().join(format!( - "ab-l3-ok-{}-{}", - std::process::id(), - COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - std::fs::create_dir_all(&scratch).expect("mkdir scratch"); - // A sibling under the temp dir — NOT beneath the granted scratch root. - let outside = std::env::temp_dir().join(format!( - "ab-l3-escape-{}-{}", - std::process::id(), - COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - let _ = std::fs::remove_file(&outside); - - let cx = authorize(&Caveats { - exec: Scope::only(["touch".to_string()]), - fs_write: Scope::only([scratch.to_string_lossy().into_owned()]), - max_calls: CountBound::AtMost(8), - ..Caveats::top() - }) - .unwrap(); - - let cmd = format!("/usr/bin/touch {}", outside.display()); - let out = ShellTool::new() - .invoke(serde_json::json!({ "cmd": cmd }), &cx) - .await - .expect("invoke"); - - // The envelope honestly reports kernel confinement is in force. - assert_eq!( - out["sandbox_kind"], "landlock", - "L3 must be active for a restricted fs_write scope: {out:?}" - ); - // The kernel blocked the external's write: the marker is absent. - assert!( - !outside.exists(), - "Landlock must prevent an external program writing outside fs_write: {out:?}" - ); - // A write WITHIN scope still succeeds through the same confined run. - let inside = scratch.join("inside.txt"); - let cmd2 = format!("/usr/bin/touch {}", inside.display()); - let cx2 = authorize(&Caveats { - exec: Scope::only(["touch".to_string()]), - fs_write: Scope::only([scratch.to_string_lossy().into_owned()]), - max_calls: CountBound::AtMost(8), - ..Caveats::top() - }) - .unwrap(); - let out2 = ShellTool::new() - .invoke(serde_json::json!({ "cmd": cmd2 }), &cx2) - .await - .expect("invoke"); - assert_eq!( - out2["exit_code"], 0, - "in-scope external write must succeed: {out2:?}" - ); - assert!( - inside.exists(), - "in-scope marker should have been created: {out2:?}" - ); - - let _ = std::fs::remove_dir_all(&scratch); - let _ = std::fs::remove_file(&outside); + let cx = Gate::new(0) + .authorize(&Passthrough, &Caveats::top()) + .expect("authorize"); + let result = ShellTool::new() + .invoke(serde_json::json!({"cmd": "echo hi"}), &cx) + .await; + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("reubeno/brush/pull/1184"), "got: {msg}"); } - - /// Test-only unique-name disambiguator (a counter, never a clock). - static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); } diff --git a/justfile b/justfile index b535fff..2c89b31 100644 --- a/justfile +++ b/justfile @@ -46,32 +46,27 @@ install-hooks: fmt: cargo fmt --all -# Publish the brush-free crates to crates.io, IN ORDER (core before its -# dependents). cargo >= 1.66 blocks each publish until the new version is +# Publish all crates to crates.io, IN TOPOLOGICAL ORDER (each crate before +# its dependents). cargo >= 1.66 blocks each publish until the new version is # downloadable, so the order is causal — no sleeps needed. # -# AUTH: this recipe names no token and no secret location on purpose. Provide -# crates.io auth the standard way — run `cargo login` once, OR set -# CARGO_REGISTRY_TOKEN from wherever you keep the token, e.g.: +# AUTH: run `cargo login` once, OR set CARGO_REGISTRY_TOKEN: # CARGO_REGISTRY_TOKEN="$(cat /path/to/your/token)" just publish-crates -# Keep the token on your machine; do not put it in CI secrets. # -# NOT PUBLISHED: agent-bridle-tool-shell (and transitively the `agent-bridle` -# facade + agent-bridle-mcp) git-dep our brush fork, and crates.io forbids any -# git source in a published manifest. They publish once the CommandInterceptor -# hook lands upstream in reubeno/brush, or via a renamed fork. Until then the -# confined shell is consumable via the MCP server / subprocess / maturin wheel. -# See docs/adr/0001 and docs/DESIGN.md. +# NOT PUBLISHED: agent-bridle-py — PyO3 arm64 linker issues make it +# unpublishable from this machine. Publish via a maturin wheel job if needed. # -# DRY_RUN=1 packages + verifies without uploading (note: tool-web's dry-run -# needs core already on crates.io to resolve its dependency). +# DRY_RUN=1 packages + verifies without uploading. +# +# HOOK PARITY: the crate list and order here must match the publish-crates +# job matrix in .github/workflows/release.yml. publish-crates: #!/usr/bin/env bash set -euo pipefail dry="" [ "${DRY_RUN:-0}" != "0" ] && dry="--dry-run" - for crate in agent-bridle-core agent-bridle-tool-web; do + for crate in agent-bridle-core agent-bridle-tool-shell agent-bridle-tool-web agent-bridle agent-bridle-mcp; do echo ">>> cargo publish -p ${crate} ${dry}" cargo publish -p "${crate}" ${dry} done - echo "Published the brush-free crates. (tool-shell stays gated on the upstream brush hook.)" + echo "All agent-bridle crates published."