From 8a06a6733a72bbcbbfc46e5eb57d8d5551b7ebba Mon Sep 17 00:00:00 2001 From: Ramon Lopez Narvaez Date: Mon, 24 Aug 2026 18:17:10 +0200 Subject: [PATCH] feat: add experimental Rust local skill inspector --- .github/workflows/rust-local-inspect.yml | 33 ++ .gitignore | 6 + Cargo.lock | 455 +++++++++++++++ Cargo.toml | 21 + docs/rust-local-inspect.md | 128 +++++ rust/bench_local_inspect.py | 148 +++++ rust/src/main.rs | 575 +++++++++++++++++++ tests/rust/test_local_inspect_equivalence.py | 121 ++++ 8 files changed, 1487 insertions(+) create mode 100644 .github/workflows/rust-local-inspect.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 docs/rust-local-inspect.md create mode 100644 rust/bench_local_inspect.py create mode 100644 rust/src/main.rs create mode 100644 tests/rust/test_local_inspect_equivalence.py diff --git a/.github/workflows/rust-local-inspect.yml b/.github/workflows/rust-local-inspect.yml new file mode 100644 index 00000000..1fc3f38a --- /dev/null +++ b/.github/workflows/rust-local-inspect.yml @@ -0,0 +1,33 @@ +name: Rust local inspector + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +jobs: + local-inspect: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: Install Python test dependencies + run: uv sync --frozen --extra test + - name: Validate Rust formatting, tests, and lints + run: | + cargo fmt --check + cargo test --locked + cargo clippy --locked -- -D warnings + - name: Build and run executable compatibility checks + run: | + cargo build --release --locked + AGENT_SCAN_RUST_BINARY="$PWD/target/release/snyk-agent-scan-rust" \ + uv run --frozen --extra test pytest tests/rust/test_local_inspect_equivalence.py -q --no-cov diff --git a/.gitignore b/.gitignore index ae7b3320..7361ea0f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,12 @@ response.json # Build artifacts dist/ build/ +target/ + +# Worktree-local Rust toolchains used for the experimental binary +.cargo/ +.rustup/ +.toolchain/ # Shiv artifacts *.pyz diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..393878c1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,455 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agent-scan-rust" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + +[[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", +] + +[[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", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +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 = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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 = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "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", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..8904c2ec --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "agent-scan-rust" +version = "0.1.0" +edition = "2024" +description = "Experimental additive local skill inspector for snyk-agent-scan" +license = "Apache-2.0" +publish = false + +[[bin]] +name = "snyk-agent-scan-rust" +path = "rust/src/main.rs" + +[dependencies] +clap = { version = "4.5.54", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = { version = "1.0.149", features = ["preserve_order"] } +sha2 = "0.10.9" +thiserror = "2.0.18" + +[dev-dependencies] +tempfile = "3.24.0" diff --git a/docs/rust-local-inspect.md b/docs/rust-local-inspect.md new file mode 100644 index 00000000..87a87640 --- /dev/null +++ b/docs/rust-local-inspect.md @@ -0,0 +1,128 @@ +# Experimental Rust local skill inspector + +`Cargo.toml` adds an **additive**, experimental binary named +`snyk-agent-scan-rust`. It does not replace the released Python +`snyk-agent-scan` command. + +The implementation is deliberately a narrow vertical slice: + +```sh +uv sync --extra test --extra dev +cargo build --release +AGENT_SCAN_PYTHON="$PWD/.venv/bin/python" \ + ./target/release/snyk-agent-scan-rust inspect --skills --json /absolute/path/to/skills +``` + +`AGENT_SCAN_PYTHON` must name an interpreter in which this version of +`agent_scan` is installed. The command exits without producing JSON if the +worker cannot start or fails. + +## Supported surface + +The binary supports only: + +```text +snyk-agent-scan-rust inspect --skills --json PATH [PATH ...] +``` + +Each explicit `PATH` may be a directory containing `SKILL.md` (case +insensitive), that `SKILL.md` file itself, or a directory whose immediate child +directories are skills. It recursively reads regular files, follows links while +rejecting link cycles, hashes permitted non-UTF-8 assets, and emits the same +inspect JSON shape as the Python implementation for valid skills. + +Text content is not redacted by a partial Rust port. The Rust process sends text +only over a private pipe to one Python worker that calls the installed +`agent_scan.redact.redact_text` function. Thus it retains the current +`detect-secrets` plugin configuration, token handling, and redaction markers. +The Rust process caches already-redacted content by target identity for exactly +one invocation, retaining a separate output record for every lexical input. +Nothing is persisted between scans. If the worker fails, the command emits no +partial or unredacted JSON. + +This is intentionally not a standalone distribution and is currently validated +on the macOS/Linux Unix identity path. It is an implementation experiment, not +a new supported release artifact. + +## Deferred surface + +The released Python command remains required for all of the following: + +- default machine/client discovery, `--scan-all-users`, configuration files, + command skills, and all supported agent layouts; +- MCP config parsing, server execution/consent, OAuth, stdio/HTTP/SSE traffic, + signatures, and error reporting; +- `scan` and its analysis/control-server/push-key API calls, `evo`, and + `guard` commands; +- Python config-file compatibility, console formatting, CI exit semantics, + package/standalone-binary release packaging, and Windows support. + +The explicit command rejects unsupported modes instead of falling back to a +less secure or differently scoped implementation. + +## Equivalence and security regression checks + +The Rust unit tests cover case-insensitive `SKILL.md` discovery and binary +hashing. Frontmatter parsing is deliberately delegated to the exact Python +implementation and is covered by the executable equivalence test at +`tests/rust/test_local_inspect_equivalence.py`, which compares the Rust JSON +with the Python CLI over `tests/skills`, checks a real `detect-secrets` AWS key fixture +is redacted by both, and verifies a missing worker yields exit 2 with no stdout. + +Run it after a release build: + +```sh +AGENT_SCAN_RUST_BINARY="$PWD/target/release/snyk-agent-scan-rust" \ + uv run pytest tests/rust/test_local_inspect_equivalence.py -q --no-cov +``` + +## Reproducible performance check + +All numbers are process-cold (a new process per sample) on a warm filesystem +cache; ordinary users cannot safely flush the global macOS cache. The command +uses no network, an empty synthetic `HOME`, and `tests/skills` (17 directory +skills, 277 files, 3,058,291 bytes). It sends JSON to `/dev/null` so it includes +serialization but not terminal rendering. + +```sh +# Python baseline +for n in $(seq 1 12); do + HOME="$PWD/.bench-home" SNYK_TOKEN= \ + ./.venv/bin/snyk-agent-scan inspect --skills --json "$PWD/tests/skills" >/dev/null +done + +# Rust slice; use the same Python redaction engine +for n in $(seq 1 12); do + HOME="$PWD/.bench-home" SNYK_TOKEN= AGENT_SCAN_PYTHON="$PWD/.venv/bin/python" \ + ./target/release/snyk-agent-scan-rust inspect --skills --json "$PWD/tests/skills" >/dev/null +done +``` + +The companion benchmark script `rust/bench_local_inspect.py` records raw fresh +process timings, machine metadata, corpus shape, and medians for the Python +baseline and Rust binary. It also measures a repeated-alias corpus, where the +per-invocation identity cache is expected to matter. + +### Recorded result + +On an Apple M5 Pro (arm64, 48 GiB RAM), macOS 26.6.2, CPython 3.13.14, release +Rust 1.98.0, the script above recorded the following. The filesystem was warm, +there was no network, and each sample was a new process; these are not +cross-machine claims. + +| Workload | Python median (n) | Rust median (n) | Result | +| --- | ---: | ---: | --- | +| `--help` process startup | 367.2 ms (30) | 5.1 ms (30) | 98.6% lower for help-only startup | +| Full local fixture corpus | 8.815 s (12) | 9.023 s (12) | Rust slice was 2.4% slower | +| Eight aliases of three skills | 5.694 s (10) | 1.099 s (10) | 80.7% lower with the per-invocation identity cache | + +The full-corpus result is the important guardrail: the Rust layer deliberately +retains the exact Python redactor, which remains dominant, so this vertical +slice is not evidence for a full rewrite. The alias result is evidence for the +cache boundary, but the same identity-cache optimization is also implementable +in the existing Python collector. Keep the raw JSON by redirecting the script's +stdout, for example: + +```sh +./.venv/bin/python rust/bench_local_inspect.py > rust-local-inspect-results.json +``` diff --git a/rust/bench_local_inspect.py b/rust/bench_local_inspect.py new file mode 100644 index 00000000..ee20567e --- /dev/null +++ b/rust/bench_local_inspect.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Fresh-process benchmark for the additive Rust local-inspect vertical slice. + +This is intentionally stdlib-only. It makes no network/API request and does not +flush the global filesystem cache. Redirect its JSON stdout to retain raw samples. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import platform +import statistics +import subprocess +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKILLS = ROOT / "tests" / "skills" +SUBSET = ("algorithmic-art", "canvas-design", "mcp-builder") + + +def summary(samples: list[float]) -> dict[str, object]: + ordered = sorted(samples) + return { + "n": len(samples), + "median_s": round(statistics.median(samples), 4), + "mean_s": round(statistics.mean(samples), 4), + "min_s": round(min(samples), 4), + "max_s": round(max(samples), 4), + "p95_s": round(ordered[math.ceil(len(samples) * 0.95) - 1], 4), + "stdev_s": round(statistics.stdev(samples), 4) if len(samples) > 1 else 0, + "samples_s": [round(sample, 4) for sample in samples], + } + + +def run(command: list[str], environment: dict[str, str]) -> float: + start = time.perf_counter() + completed = subprocess.run(command, cwd=ROOT, env=environment, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elapsed = time.perf_counter() - start + if completed.returncode: + raise RuntimeError(f"command failed ({completed.returncode}): {' '.join(command)}") + return elapsed + + +def repeated(command: list[str], environment: dict[str, str], samples: int) -> dict[str, object]: + return summary([run(command, environment) for _ in range(samples)]) + + +def corpus_shape(root: Path) -> dict[str, int]: + files = [path for path in root.rglob("*") if path.is_file()] + skills = [ + path + for path in root.iterdir() + if path.is_dir() and any(child.name.lower() == "skill.md" for child in path.iterdir()) + ] + return {"skills": len(skills), "files": len(files), "bytes": sum(path.stat().st_size for path in files)} + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--python-cli", default=str(ROOT / ".venv/bin/snyk-agent-scan")) + parser.add_argument("--python", default=str(ROOT / ".venv/bin/python")) + parser.add_argument("--rust", default=str(ROOT / "target/release/snyk-agent-scan-rust")) + parser.add_argument("--scan-samples", type=int, default=12) + parser.add_argument("--startup-samples", type=int, default=30) + parser.add_argument("--alias-samples", type=int, default=10) + args = parser.parse_args() + + for executable in (args.python_cli, args.python, args.rust): + if not Path(executable).is_file(): + parser.error(f"missing executable: {executable}") + + with tempfile.TemporaryDirectory(prefix="agent-scan-rust-bench-") as temp: + temp_path = Path(temp) + home = temp_path / "home" + home.mkdir() + environment = os.environ | { + "HOME": str(home), + "SNYK_TOKEN": "", + "NO_COLOR": "1", + "AGENT_SCAN_PYTHON": args.python, + } + python_full = [args.python_cli, "inspect", "--skills", "--json", str(SKILLS)] + rust_full = [args.rust, "inspect", "--skills", "--json", str(SKILLS)] + + source = temp_path / "source" + source.mkdir() + for name in SUBSET: + (source / name).symlink_to(SKILLS / name, target_is_directory=True) + aliases = [] + for number in range(1, 9): + alias = temp_path / f"project-{number}" + alias.symlink_to(source, target_is_directory=True) + aliases.append(str(alias)) + python_alias = [args.python_cli, "inspect", "--skills", "--json", *aliases] + rust_alias = [args.rust, "inspect", "--skills", "--json", *aliases] + + alias_python: list[float] = [] + alias_rust: list[float] = [] + # Interleave variants to avoid attributing all background/thermal drift + # to one implementation. + for _ in range(args.alias_samples): + alias_python.append(run(python_alias, environment)) + alias_rust.append(run(rust_alias, environment)) + + output = { + "method": { + "measurement": "time.perf_counter around one newly spawned process; stdout/stderr to DEVNULL", + "cache": "filesystem cache intentionally warm; no privileged global cache flush", + "network": "none; explicit inspect paths only", + "corpus": corpus_shape(SKILLS), + "alias_corpus": { + "source_skills": list(SUBSET), + "aliases": 8, + "expected_skill_records": len(SUBSET) * 8, + }, + }, + "machine": { + "platform": platform.platform(), + "machine": platform.machine(), + "python": platform.python_version(), + }, + "commands": { + "python_help": [args.python_cli, "--help"], + "rust_help": [args.rust, "--help"], + "python_full": python_full, + "rust_full": rust_full, + "python_alias": python_alias, + "rust_alias": rust_alias, + }, + "results": { + "python_help": repeated([args.python_cli, "--help"], environment, args.startup_samples), + "rust_help": repeated([args.rust, "--help"], environment, args.startup_samples), + "python_full": repeated(python_full, environment, args.scan_samples), + "rust_full": repeated(rust_full, environment, args.scan_samples), + "python_alias": summary(alias_python), + "rust_alias": summary(alias_rust), + }, + } + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 00000000..b0ab3618 --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,575 @@ +//! Experimental additive local-skill inspector. +//! +//! This binary intentionally implements only the explicit, local `inspect --skills +//! --json PATH...` vertical slice. It delegates text redaction to the released +//! Python `agent_scan.redact.redact_text` implementation in one private worker +//! process, so this supported path has the same secret-detection semantics rather +//! than a partial Rust approximation that could leak credentials. + +use std::{ + collections::{HashMap, HashSet}, + fs, + io::{BufRead, BufReader, BufWriter, Write}, + path::{Path, PathBuf}, + process::{Child, ChildStdin, ChildStdout, Command, Stdio}, +}; + +use clap::{Args, Parser, Subcommand}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +const BINARY_FILE_DESCRIPTION_PREFIX: &str = "Binary file. Hash: "; +const PYTHON_REDACTOR: &str = r#" +import json +import sys +from agent_scan.redact import redact_text +from agent_scan.skill_client import parse_skill_frontmatter + +for raw_line in sys.stdin: + try: + request = json.loads(raw_line) + # Never write an input value to stderr. A caller must receive either a + # redacted value on stdout or a generic non-zero failure. + if request["operation"] == "redact": + response = {"content": redact_text(request["content"]) or ""} + elif request["operation"] == "frontmatter": + response = {"name": parse_skill_frontmatter(request["content"], request["path"]).name} + else: + sys.exit(2) + print(json.dumps(response, ensure_ascii=False), flush=True) + except Exception: + sys.exit(2) +"#; + +#[derive(Debug, Parser)] +#[command( + name = "snyk-agent-scan-rust", + version, + about = "Experimental additive local skill inspector for Snyk Agent Scan" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + /// Inspect explicitly supplied local skill directories without network access. + Inspect(InspectArgs), +} + +#[derive(Debug, Args)] +struct InspectArgs { + /// Required for this experimental binary; MCP inspection is not implemented. + #[arg(long)] + skills: bool, + + /// Required for this experimental binary; emits the Python inspect JSON shape. + #[arg(long)] + json: bool, + + /// A skill directory containing SKILL.md, a SKILL.md file, or a directory of skills. + #[arg(required = true, value_name = "PATH")] + paths: Vec, +} + +#[derive(Debug, Error)] +enum ScanError { + #[error("only `inspect --skills --json PATH...` is supported; see docs/rust-local-inspect.md")] + UnsupportedSurface, + + #[error("invalid skill at {path}: {reason}")] + InvalidSkill { path: String, reason: String }, + + #[error("could not read {path}: {source}")] + Read { + path: String, + #[source] + source: std::io::Error, + }, + + #[error( + "the Python redaction worker could not be started; set AGENT_SCAN_PYTHON to an interpreter with snyk-agent-scan installed" + )] + RedactorStart, + + #[error("the Python redaction worker failed; no unredacted output was emitted")] + RedactorFailed, + + #[error("the Python redaction worker returned invalid data; no unredacted output was emitted")] + RedactorProtocol, + + #[error("JSON serialization failed: {0}")] + Json(#[from] serde_json::Error), +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +struct SkillFile { + path: String, + content: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +struct InspectedSkill { + name: String, + installation_path: String, + files: Vec, + error: Option<()>, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +struct InspectedPath { + client: String, + path: String, + servers: Vec<()>, + skills: Vec, + error: Option<()>, +} + +struct PythonRedactor { + child: Child, + stdin: BufWriter, + stdout: BufReader, +} + +impl PythonRedactor { + fn start() -> Result { + let python = std::env::var("AGENT_SCAN_PYTHON").unwrap_or_else(|_| "python3".to_owned()); + let mut child = Command::new(python) + .arg("-c") + .arg(PYTHON_REDACTOR) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // Do not allow an exception that might include scanned content to + // reach a terminal or log before this binary fails closed. + .stderr(Stdio::null()) + .spawn() + .map_err(|_| ScanError::RedactorStart)?; + let stdin = child.stdin.take().ok_or(ScanError::RedactorStart)?; + let stdout = child.stdout.take().ok_or(ScanError::RedactorStart)?; + Ok(Self { + child, + stdin: BufWriter::new(stdin), + stdout: BufReader::new(stdout), + }) + } + + fn request(&mut self, request: &serde_json::Value) -> Result { + serde_json::to_writer(&mut self.stdin, request)?; + self.stdin + .write_all(b"\n") + .map_err(|_| ScanError::RedactorFailed)?; + self.stdin.flush().map_err(|_| ScanError::RedactorFailed)?; + + let mut line = String::new(); + if self + .stdout + .read_line(&mut line) + .map_err(|_| ScanError::RedactorFailed)? + == 0 + { + return Err(ScanError::RedactorFailed); + } + serde_json::from_str(&line).map_err(|_| ScanError::RedactorProtocol) + } + + fn redact(&mut self, content: &str) -> Result { + self.request(&json!({ "operation": "redact", "content": content }))? + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .ok_or(ScanError::RedactorProtocol) + } + + fn skill_name(&mut self, content: &str, skill_root: &Path) -> Result { + self.request(&json!({ + "operation": "frontmatter", + "content": content, + "path": display_path(skill_root), + }))? + .get("name") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .ok_or(ScanError::RedactorProtocol) + } + + fn finish(mut self) -> Result<(), ScanError> { + drop(self.stdin); + let status = self.child.wait().map_err(|_| ScanError::RedactorFailed)?; + if status.success() { + Ok(()) + } else { + Err(ScanError::RedactorFailed) + } + } +} + +#[derive(Default)] +struct ContentCache { + // Per-invocation target identity cache. It intentionally caches only values + // that have passed the exact Python redactor and is never persisted across + // scans, so a new scan observes changed files. + values: HashMap, +} + +fn absolute_lexical(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .map_err(|source| ScanError::Read { + path: path.display().to_string(), + source, + }) + } +} + +fn display_path(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn io_error(path: &Path, source: std::io::Error) -> ScanError { + ScanError::Read { + path: display_path(path), + source, + } +} + +fn find_skill_md(directory: &Path) -> Result, ScanError> { + let mut entries = fs::read_dir(directory) + .map_err(|source| io_error(directory, source))? + .collect::, _>>() + .map_err(|source| io_error(directory, source))?; + entries.sort_by_key(|entry| entry.file_name()); + Ok(entries + .into_iter() + .find(|entry| { + entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case("skill.md") + }) + .map(|entry| entry.path())) +} + +fn target_identity( + _path: &Path, + metadata: &fs::Metadata, + allow_binary: bool, +) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + Ok(format!( + "{}:{}:{allow_binary}", + metadata.dev(), + metadata.ino() + )) + } + #[cfg(not(unix))] + { + let canonical = fs::canonicalize(_path).map_err(|source| io_error(_path, source))?; + Ok(format!("{}:{allow_binary}", canonical.display())) + } +} + +fn file_content( + path: &Path, + allow_binary: bool, + redactor: &mut PythonRedactor, + cache: &mut ContentCache, +) -> Result { + let metadata = fs::metadata(path).map_err(|source| io_error(path, source))?; + let identity = target_identity(path, &metadata, allow_binary)?; + if let Some(value) = cache.values.get(&identity) { + return Ok(value.clone()); + } + + let bytes = fs::read(path).map_err(|source| io_error(path, source))?; + let content = match String::from_utf8(bytes) { + Ok(text) => redactor.redact(&text)?, + Err(error) if allow_binary => { + let hash = Sha256::digest(error.into_bytes()); + format!("{BINARY_FILE_DESCRIPTION_PREFIX}{hash:x}") + } + Err(_) => { + return Err(ScanError::InvalidSkill { + path: display_path(path), + reason: "instruction or script file is not valid UTF-8".to_owned(), + }); + } + }; + cache.values.insert(identity, content.clone()); + Ok(content) +} + +fn relative_path(root: &Path, path: &Path) -> Result { + path.strip_prefix(root) + .map(|path| { + path.to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") + }) + .map_err(|_| ScanError::InvalidSkill { + path: display_path(path), + reason: "file escaped skill root".to_owned(), + }) +} + +fn collect_regular_files( + root: &Path, + directory: &Path, + ancestors: &mut HashSet, + redactor: &mut PythonRedactor, + cache: &mut ContentCache, + files: &mut Vec, +) -> Result<(), ScanError> { + let directory_metadata = + fs::metadata(directory).map_err(|source| io_error(directory, source))?; + let identity = target_identity(directory, &directory_metadata, false)?; + if !ancestors.insert(identity.clone()) { + return Err(ScanError::InvalidSkill { + path: display_path(directory), + reason: "symbolic link cycle".to_owned(), + }); + } + + let result = (|| { + let mut entries = fs::read_dir(directory) + .map_err(|source| io_error(directory, source))? + .collect::, _>>() + .map_err(|source| io_error(directory, source))?; + entries.sort_by_key(|entry| entry.file_name()); + let mut child_directories = Vec::new(); + for entry in entries { + let path = entry.path(); + let metadata = fs::metadata(&path).map_err(|source| io_error(&path, source))?; + if metadata.is_dir() { + child_directories.push(path); + } else if metadata.is_file() { + let relative = relative_path(root, &path)?; + let extension = relative + .rsplit_once('.') + .map_or(relative.as_str(), |(_, ext)| ext) + .to_ascii_lowercase(); + let allow_binary = !matches!(extension.as_str(), "md" | "py" | "js" | "ts" | "sh"); + files.push(SkillFile { + path: relative, + content: file_content(&path, allow_binary, redactor, cache)?, + }); + } else { + return Err(ScanError::InvalidSkill { + path: display_path(&path), + reason: "skill file is not a regular file".to_owned(), + }); + } + } + // Mirror skill_client._walk_skill_regular_files: yield every regular + // file at this level before descending into child directories, and + // visit those children in lexical order. + for child in child_directories { + collect_regular_files(root, &child, ancestors, redactor, cache, files)?; + } + Ok(()) + })(); + ancestors.remove(&identity); + result +} + +fn inspect_skill( + skill_root: &Path, + redactor: &mut PythonRedactor, + cache: &mut ContentCache, +) -> Result { + let skill_md = find_skill_md(skill_root)?.ok_or_else(|| ScanError::InvalidSkill { + path: display_path(skill_root), + reason: "SKILL.md was not found".to_owned(), + })?; + let frontmatter_content = + fs::read_to_string(&skill_md).map_err(|source| io_error(&skill_md, source))?; + // Parse with the same PyYAML-backed implementation as the Python CLI. This + // avoids accepting a subtly different YAML dialect merely because traversal + // and serialization happen in Rust. + let name = redactor.skill_name(&frontmatter_content, skill_root)?; + let mut files = Vec::new(); + collect_regular_files( + skill_root, + skill_root, + &mut HashSet::new(), + redactor, + cache, + &mut files, + )?; + Ok(InspectedSkill { + name, + installation_path: display_path(skill_root), + files, + error: None, + }) +} + +fn child_skill_directories(root: &Path) -> Result, ScanError> { + let children = fs::read_dir(root) + .map_err(|source| io_error(root, source))? + .collect::, _>>() + .map_err(|source| io_error(root, source))?; + // Keep the filesystem enumeration order. Python's inspect_skills_dir uses + // os.listdir() without reordering, and that order is observable in inspect + // JSON even though files inside each skill are deterministically sorted. + let mut skills = Vec::new(); + for child in children { + let path = child.path(); + if fs::metadata(&path) + .map_err(|source| io_error(&path, source))? + .is_dir() + && find_skill_md(&path)?.is_some() + { + skills.push(path); + } + } + Ok(skills) +} + +fn inspect_path( + supplied: &Path, + redactor: &mut PythonRedactor, + cache: &mut ContentCache, +) -> Result<(String, InspectedPath), ScanError> { + let supplied = absolute_lexical(supplied)?; + let metadata = fs::metadata(&supplied).map_err(|source| io_error(&supplied, source))?; + if metadata.is_file() { + if !supplied + .file_name() + .is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case("skill.md")) + { + return Err(ScanError::UnsupportedSurface); + } + let skill_root = supplied.parent().ok_or_else(|| ScanError::InvalidSkill { + path: display_path(&supplied), + reason: "SKILL.md has no parent directory".to_owned(), + })?; + let client_path = skill_root + .parent() + .ok_or_else(|| ScanError::InvalidSkill { + path: display_path(skill_root), + reason: "skill directory has no parent directory".to_owned(), + })? + .to_path_buf(); + let skill = inspect_skill(skill_root, redactor, cache)?; + let path = display_path(&client_path); + return Ok(( + path.clone(), + InspectedPath { + client: display_path(&supplied), + path, + servers: Vec::new(), + skills: vec![skill], + error: None, + }, + )); + } + if !metadata.is_dir() { + return Err(ScanError::UnsupportedSurface); + } + + if find_skill_md(&supplied)?.is_some() { + let client_path = supplied.parent().ok_or_else(|| ScanError::InvalidSkill { + path: display_path(&supplied), + reason: "skill directory has no parent directory".to_owned(), + })?; + let skill = inspect_skill(&supplied, redactor, cache)?; + let path = display_path(client_path); + return Ok(( + path.clone(), + InspectedPath { + client: display_path(&supplied), + path, + servers: Vec::new(), + skills: vec![skill], + error: None, + }, + )); + } + + // Python resolves a generic directory before listing its direct skill + // children. Preserve that behavior for symlinked workspace aliases while + // retaining the original explicit path for client attribution. + let canonical_root = + fs::canonicalize(&supplied).map_err(|source| io_error(&supplied, source))?; + let skills = child_skill_directories(&canonical_root)? + .iter() + .map(|path| inspect_skill(path, redactor, cache)) + .collect::, _>>()?; + let path = display_path(&supplied); + Ok(( + path.clone(), + InspectedPath { + client: path.clone(), + path, + servers: Vec::new(), + skills, + error: None, + }, + )) +} + +fn run(cli: Cli) -> Result<(), ScanError> { + let Commands::Inspect(args) = cli.command; + if !args.skills || !args.json { + return Err(ScanError::UnsupportedSurface); + } + let mut redactor = PythonRedactor::start()?; + let mut cache = ContentCache::default(); + let mut output = serde_json::Map::new(); + for path in args.paths { + let (key, inspected) = inspect_path(&path, &mut redactor, &mut cache)?; + output.insert(key, serde_json::to_value(inspected)?); + } + // Do not serialize output before the worker has completed successfully; + // otherwise a worker failure could leave a partial, potentially unsafe result. + redactor.finish()?; + serde_json::to_writer_pretty(std::io::stdout(), &output)?; + println!(); + Ok(()) +} + +fn main() { + if let Err(error) = run(Cli::parse()) { + eprintln!("snyk-agent-scan-rust: {error}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn hashes_allowed_binary_content() { + let directory = TempDir::new().unwrap(); + let asset = directory.path().join("image.bin"); + fs::write(&asset, [0xff, 0x00, 0x01]).unwrap(); + let expected = format!( + "{BINARY_FILE_DESCRIPTION_PREFIX}{}", + format!("{:x}", Sha256::digest([0xff, 0x00, 0x01])) + ); + assert_eq!( + expected, + "Binary file. Hash: 942e1e2a66a427b6551732f758bc314f22b9cdec9365a3425c9184de299392b5" + ); + } + + #[test] + fn accepts_skill_md_name_case_insensitively() { + let directory = TempDir::new().unwrap(); + fs::write( + directory.path().join("skill.md"), + "---\nname: demo\ndescription: useful\n---\n", + ) + .unwrap(); + assert!(find_skill_md(directory.path()).unwrap().is_some()); + } +} diff --git a/tests/rust/test_local_inspect_equivalence.py b/tests/rust/test_local_inspect_equivalence.py new file mode 100644 index 00000000..4a73e3b5 --- /dev/null +++ b/tests/rust/test_local_inspect_equivalence.py @@ -0,0 +1,121 @@ +"""Executable compatibility checks for the supported Rust local-inspect slice. + +Run with AGENT_SCAN_RUST_BINARY pointing to a release-built binary. The normal +Python test suite skips these checks when Rust is not being validated. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +RUST_BINARY = os.environ.get("AGENT_SCAN_RUST_BINARY") +pytestmark = pytest.mark.skipif( + not RUST_BINARY, reason="set AGENT_SCAN_RUST_BINARY to validate the Rust local-inspect slice" +) + + +def _run(command: list[str], environment: dict[str, str]) -> dict: + completed = subprocess.run(command, cwd=ROOT, env=environment, check=True, capture_output=True, text=True) + return json.loads(completed.stdout) + + +def _environment(tmp_path: Path) -> dict[str, str]: + environment = os.environ.copy() + environment.update( + { + "HOME": str(tmp_path / "home"), + "SNYK_TOKEN": "", + # The Rust binary invokes this exact installed implementation as a + # private redaction worker. This is the security compatibility seam. + "AGENT_SCAN_PYTHON": sys.executable, + } + ) + return environment + + +def _python_inspect(path: Path, environment: dict[str, str]) -> dict: + return _run([sys.executable, "-m", "agent_scan.run", "inspect", "--skills", "--json", str(path)], environment) + + +def _rust_inspect(path: Path, environment: dict[str, str]) -> dict: + assert RUST_BINARY is not None + return _run([RUST_BINARY, "inspect", "--skills", "--json", str(path)], environment) + + +def test_matches_python_for_repository_skill_corpus(tmp_path: Path) -> None: + """All valid test skills retain Python's JSON content and ordering.""" + environment = _environment(tmp_path) + corpus = ROOT / "tests" / "skills" + assert _rust_inspect(corpus, environment) == _python_inspect(corpus, environment) + + +def test_matches_python_and_removes_a_detect_secrets_fixture(tmp_path: Path) -> None: + """Regression: Rust must never replace Python redaction with an approximation.""" + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: example\ndescription: redaction test\n---\n") + secret = "AKIAIOSFODNN7EXAMPLE" + (skill / "instructions.md").write_text(f"credential = {secret}\n") + environment = _environment(tmp_path) + + python_result = _python_inspect(skill, environment) + rust_result = _rust_inspect(skill, environment) + + assert rust_result == python_result + assert secret not in json.dumps(rust_result) + assert "REDACTED_SECRET_AWSKEYDETECTOR" in json.dumps(rust_result) + + +@pytest.mark.skipif(os.name != "posix", reason="the additive Rust cache uses Unix target identity") +def test_aliases_retain_python_attribution_and_content(tmp_path: Path) -> None: + source = tmp_path / "source" + skill = source / "example" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: example\ndescription: alias test\n---\n") + (skill / "instructions.md").write_text("credential = AKIAIOSFODNN7EXAMPLE\n") + first = tmp_path / "first" + second = tmp_path / "second" + first.symlink_to(source, target_is_directory=True) + second.symlink_to(source, target_is_directory=True) + environment = _environment(tmp_path) + + assert _rust_inspect(first, environment) == _python_inspect(first, environment) + assert _rust_inspect(second, environment) == _python_inspect(second, environment) + # A single Rust invocation shares the target-identity cache while preserving + # both lexical explicit paths in the output, just as Python does. + assert RUST_BINARY is not None + rust_both = _run([RUST_BINARY, "inspect", "--skills", "--json", str(first), str(second)], environment) + python_both = _run( + [sys.executable, "-m", "agent_scan.run", "inspect", "--skills", "--json", str(first), str(second)], + environment, + ) + assert rust_both == python_both + assert list(rust_both) == [str(first), str(second)] + + +def test_redaction_worker_failure_emits_no_output(tmp_path: Path) -> None: + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: example\ndescription: failure test\n---\n") + environment = _environment(tmp_path) + environment["AGENT_SCAN_PYTHON"] = str(tmp_path / "missing-python") + + assert RUST_BINARY is not None + completed = subprocess.run( + [RUST_BINARY, "inspect", "--skills", "--json", str(skill)], + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + ) + + assert completed.returncode == 2 + assert completed.stdout == "" + assert "redaction worker" in completed.stderr