From ee9c6b76f34cef8593a9a724817a962a65ddf25d Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Sat, 13 Jun 2026 10:06:56 +1000 Subject: [PATCH 1/4] Add crate core: sidecar acquisition, builder API, rmcp client Milestone 1 of the embedded StackQL MCP server crate (stackql-mcp, name verified free on crates.io): - sidecar mode (default feature): downloads the platform .mcpb from the pinned stackql release (v0.10.500), verifies sha256 against pins baked from the release assets, extracts to the shared cache (~/.stackql/mcp-server-bin///), spawns over stdio - vendored feature: extract-on-first-run from caller-embedded bundle bytes (include_bytes!), keyed by content hash - public API: StackqlMcp::builder().mode(Mode::ReadOnly).auth(json) .start() -> RunningServer exposing the child handle plus a connected rmcp client (Deref to the rmcp RunningService) - canonical cwd-independent launch args per the packaging contract; modes read_only (default) / safe / delete_safe / full_access as defined by stackql's pkg/mcp_server policy - env overrides STACKQL_MCP_BIN and STACKQL_MCP_BUNDLE, builder equivalents, and a sync Builder::command() escape hatch - examples: minimal.rs (connected client) and launcher.rs (conformance launcher for the packaging repo smoke test) - tests: unit (platform keys, pins, args, extract, sha256) plus a conformance integration test porting smoke-test.py (initialize, tools/list, pull_provider github, list_services github); passes against the real v0.10.500 bundle - CI: fmt + clippy gates, linux/macos/windows test matrix including the conformance test Deps held to the justified set: rmcp, zip, sha2, ureq (sidecar only), serde/serde_json (already in rmcp's tree). MSRV 1.88, set by rmcp 1.x. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 37 ++ CLAUDE.md | 91 ++++ Cargo.lock | 1030 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 41 ++ README.md | 83 ++- examples/launcher.rs | 18 + examples/minimal.rs | 22 + src/acquire.rs | 129 +++++ src/bundle.rs | 191 +++++++ src/cache.rs | 62 +++ src/download.rs | 106 ++++ src/error.rs | 86 ++++ src/launch.rs | 96 ++++ src/lib.rs | 246 +++++++++ src/pins.rs | 108 ++++ src/platform.rs | 90 ++++ tests/conformance.rs | 76 +++ 17 files changed, 2510 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 examples/launcher.rs create mode 100644 examples/minimal.rs create mode 100644 src/acquire.rs create mode 100644 src/bundle.rs create mode 100644 src/cache.rs create mode 100644 src/download.rs create mode 100644 src/error.rs create mode 100644 src/launch.rs create mode 100644 src/lib.rs create mode 100644 src/pins.rs create mode 100644 src/platform.rs create mode 100644 tests/conformance.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4a15998 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - run: cargo fmt --check + - run: cargo clippy --all-targets --all-features -- -D warnings + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: unit tests (sidecar) + run: cargo test + - name: unit tests (vendored) + run: cargo test --features vendored + - name: conformance (downloads the pinned server bundle) + run: cargo test --test conformance -- --include-ignored --nocapture diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cd52baf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md - stackql-mcp-rs (embedded StackQL MCP server for Rust) + +## What this project is + +The Rust member of the StackQL embedded-MCP family: a crate that gives Rust +agentic apps an embedded StackQL MCP server (cloud queries and provisioning +over SQL). Target repo: `stackql/stackql-mcp-rs`, published to crates.io as +`stackql-mcp` (verify name availability first; fallback `stackql-mcp-server`). +Publishing to crates.io is manual (API token), consistent with the npm/PyPI +stance. + +Two acquisition modes, both behind one API: + +1. Sidecar (default feature): download the platform's .mcpb at first run, + verify sha256 against pins baked into the crate, cache, spawn over stdio +2. Vendored (`vendored` feature): caller embeds the binary with + `include_bytes!` (we provide the macro/helper + extract-on-first-run) - + the single-shippable-binary story for compiled agent apps + +Public API sketch: `StackqlMcp::builder().mode(Mode::ReadOnly).auth(json) +.start()? -> RunningServer` exposing a child handle plus an `rmcp` +(official Rust MCP SDK) transport/client. Keep the dependency surface tiny: +rmcp, a zip crate, sha2, and an HTTP client (prefer ureq for minimalism) - +justify anything beyond that. + +## The embedding contract (do not deviate) + +Source of truth: stackql/stackql-mcpb-packaging (the packaging repo). + +- Per-version sha256 pins from the release .sha256 assets (a consolidated + platforms.json release asset is planned - prefer it once present); pins + are baked at crate build/render time like npm's platforms.json +- Canonical launch args (cwd-independence mandatory): + `mcp --mcp.server.type=stdio --approot /.stackql + --mcp.config {"server": {"mode": "", "audit": {"disabled": true}}}` +- Default `read_only`; escalation is explicit caller opt-in +- Shared binary cache: `~/.stackql/mcp-server-bin///` + (same as npm/pypi wrappers - check before downloading) +- Platform keys: linux-x64, linux-arm64, windows-x64, darwin-universal +- Env overrides honored: STACKQL_MCP_BIN, STACKQL_MCP_BUNDLE +- Conformance: packaging repo's scripts/smoke-test.py --cmd must pass + against the crate's example launcher; port the same checks to Rust tests + +## Demo app: `auditron` - a terminal compliance copilot + +Business use case: compliance engineers run point-in-time control checks and +walk away with an auditor-ready evidence pack - from a TUI, no cloud console +screenshots. + +A ratatui TUI + clap CLI in one binary (vendored feature - the demo IS the +single-binary pitch): + +1. `auditron scan --pack cis-aws-core` - runs a YAML-defined control pack + (id, description, SQL, pass criteria) through the embedded server in + read_only mode; live TUI table of pass/fail/error as results stream in +2. Select a finding -> the agent (Claude via an HTTP client, or pluggable) + explains the finding and drafts remediation steps; the SQL that produced + the finding is always displayed +3. `auditron evidence --out evidence-2026-06.zip` - emits per-control CSVs, + the exact SQL, timestamps, collector identity, and the run manifest - + the re-runnable evidence pack +4. github provider in null_auth mode is the demo/test fixture (org security + posture checks: repos without branch protection, etc.) so the demo runs + with zero cloud credentials; AWS pack as the credentialed follow-up + +Control packs live in the repo as data (controls/*.yaml) - community +extensible, and shared IP with the compliance content play. + +## Build and test + +- Rust stable, 2021 edition; fmt + clippy clean as CI gates +- Tests: unit (pins/extract/args), integration (spawn + initialize + + tools/list against the github fixture), `cargo test --features vendored` + path exercised in CI; linux+macos+windows matrix +- Examples: examples/minimal.rs (10 lines to a connected client) - the + thing people paste from the talk + +## Milestones + +1. Crate core (sidecar mode) + conformance tests green on 3 OSes +2. Vendored feature + auditron demo with the github control pack, asciinema + recording +3. crates.io publish (manual), README/docs.rs polish, announce (This Week in + Rust, r/rust, a Rust meetup talk: "an auditor in a single binary") + +## Conventions + +- Plain hyphens only (no em dashes); ASCII arrows `->` +- Matter-of-fact tone; no hyperbole +- Stderr for diagnostics, stdout belongs to protocols +- MIT license; mcp-name reference: io.github.stackql/stackql-mcp diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..11d7575 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1030 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[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 = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[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 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +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", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[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", +] + +[[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 = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +dependencies = [ + "async-trait", + "chrono", + "futures", + "pin-project-lite", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "stackql-mcp" +version = "0.1.0" +dependencies = [ + "rmcp", + "serde", + "serde_json", + "sha2", + "tokio", + "ureq", + "zip", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[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 = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4b1f8b8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "stackql-mcp" +version = "0.1.0" +edition = "2021" +resolver = "3" +# Effective MSRV is set by rmcp 1.x (let-chains): 1.88. +rust-version = "1.88" +description = "Embedded StackQL MCP server for Rust agentic apps - cloud queries and provisioning over SQL, served over MCP" +license = "MIT" +repository = "https://github.com/stackql/stackql-mcp-rs" +documentation = "https://docs.rs/stackql-mcp" +keywords = ["mcp", "stackql", "sql", "cloud", "agents"] +categories = ["api-bindings", "development-tools"] +exclude = [".github/", "CLAUDE.md"] + +[features] +default = ["sidecar"] +# Download the platform .mcpb bundle at first run, verify it against the +# sha256 pins baked into the crate, and cache it under ~/.stackql. +sidecar = ["dep:ureq"] +# Caller embeds the .mcpb with include_bytes! and the crate extracts it on +# first run - no network at runtime. +vendored = [] + +[dependencies] +rmcp = { version = "1.7", default-features = false, features = ["client", "transport-async-rw"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1", features = ["process", "io-util", "rt"] } +ureq = { version = "3", optional = true } +zip = { version = "8", default-features = false, features = ["deflate"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } + +[[example]] +name = "minimal" + +[[example]] +name = "launcher" diff --git a/README.md b/README.md index f5c178a..fe11a74 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,81 @@ -# stackql-mcp-rs -embedded StackQL MCP server for Rust +# stackql-mcp + +Embedded [StackQL](https://stackql.io) MCP server for Rust agentic apps. StackQL exposes cloud providers (AWS, GitHub, Google, Azure, and more) as SQL tables; this crate acquires the `stackql` binary, launches it as an MCP server over stdio, and hands you a connected [rmcp](https://crates.io/crates/rmcp) client. + +## Quickstart + +```rust +use stackql_mcp::{Mode, StackqlMcp}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let server = StackqlMcp::builder() + .mode(Mode::ReadOnly) + .auth(serde_json::json!({"github": {"type": "null_auth"}})) + .start() + .await?; + let tools = server.list_all_tools().await?; + println!("{} tools available", tools.len()); + server.shutdown().await?; + Ok(()) +} +``` + +Run it: `cargo run --example minimal`. The github provider in `null_auth` mode needs no cloud credentials. + +## Acquisition modes + +Two ways to get the server binary, both behind the same API: + +- sidecar (default feature): downloads the platform's `.mcpb` bundle at first run, verifies its sha256 against pins baked into the crate, and caches it. Subsequent starts are offline. +- vendored (`vendored` feature): embed the bundle in your binary and extract it on first run - no network at runtime, a single shippable binary: + +```rust +let server = StackqlMcp::builder() + .bundle_bytes(include_bytes!("../stackql-mcp-linux-x64.mcpb")) + .start() + .await?; +``` + +Bundles are published per release at [stackql/stackql](https://github.com/stackql/stackql/releases) by [stackql/stackql-mcpb-packaging](https://github.com/stackql/stackql-mcpb-packaging). Platforms: linux-x64, linux-arm64, windows-x64, darwin-universal. + +## Safety modes + +The server enforces a safety contract per session; the crate defaults to the most restrictive. + +| Mode | Allows | +|---|---| +| `Mode::ReadOnly` (default) | SELECT and metadata tools only | +| `Mode::Safe` | reads plus non-destructive mutations | +| `Mode::DeleteSafe` | safe plus deletes | +| `Mode::FullAccess` | everything, including lifecycle provisioning | + +Escalation is an explicit caller opt-in via `.mode(...)`. + +## Cache and overrides + +The binary cache is shared with the StackQL npm and PyPI wrappers: `~/.stackql/mcp-server-bin///`. Existing cache entries are used before any download. + +Env overrides: + +- `STACKQL_MCP_BIN`: path to a stackql binary to run directly (skips acquisition) +- `STACKQL_MCP_BUNDLE`: path to a local `.mcpb` to extract instead of downloading + +Builder equivalents: `.binary(path)`, `.bundle_path(path)`, plus `.approot(path)` to relocate StackQL's application root (default `~/.stackql`). + +If you bring your own MCP stack, `Builder::command()` returns a `std::process::Command` preloaded with the canonical launch arguments instead of starting anything. + +## Development + +```sh +cargo test # unit tests +cargo test --features vendored # vendored path +cargo test --test conformance -- --include-ignored # downloads the pinned bundle +cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings +``` + +MSRV: 1.88 (set by rmcp 1.x). + +## License + +MIT diff --git a/examples/launcher.rs b/examples/launcher.rs new file mode 100644 index 0000000..00a6f00 --- /dev/null +++ b/examples/launcher.rs @@ -0,0 +1,18 @@ +//! Conformance launcher: resolve the server binary, then run it with the +//! canonical launch args and inherited stdio. This is the command the +//! packaging repo's scripts/smoke-test.py drives. +//! +//! Extra argv (e.g. `--auth={...}`) is forwarded to the server verbatim: +//! +//! ```text +//! cargo run --example launcher -- '--auth={"github": {"type": "null_auth"}}' +//! ``` + +use stackql_mcp::{Mode, StackqlMcp}; + +fn main() -> Result<(), Box> { + let mut cmd = StackqlMcp::builder().mode(Mode::ReadOnly).command()?; + cmd.args(std::env::args().skip(1)); + let status = cmd.status()?; + std::process::exit(status.code().unwrap_or(1)); +} diff --git a/examples/minimal.rs b/examples/minimal.rs new file mode 100644 index 0000000..1a2f4a1 --- /dev/null +++ b/examples/minimal.rs @@ -0,0 +1,22 @@ +//! Ten lines to a connected StackQL MCP client. +//! +//! Uses the github provider in null_auth mode, so it runs with zero cloud +//! credentials. First run downloads and caches the server binary. + +use stackql_mcp::{Mode, StackqlMcp}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let server = StackqlMcp::builder() + .mode(Mode::ReadOnly) + .auth(serde_json::json!({"github": {"type": "null_auth"}})) + .start() + .await?; + let tools = server.list_all_tools().await?; + println!("connected to stackql mcp server: {} tools", tools.len()); + for tool in &tools { + println!(" {}", tool.name); + } + server.shutdown().await?; + Ok(()) +} diff --git a/src/acquire.rs b/src/acquire.rs new file mode 100644 index 0000000..7b9de33 --- /dev/null +++ b/src/acquire.rs @@ -0,0 +1,129 @@ +//! Binary acquisition: resolve a runnable stackql binary from (in order) +//! env overrides, builder overrides, vendored bytes, or sidecar download. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::bundle; +use crate::cache; +use crate::error::{Error, Result}; +#[cfg(feature = "sidecar")] +use crate::platform::Platform; + +/// Acquisition inputs collected from the builder. Env overrides are read at +/// resolve time and take precedence over everything here. +#[derive(Default)] +pub struct Acquisition { + /// Run this binary directly, skipping bundles entirely. + pub binary: Option, + /// Extract this local .mcpb instead of downloading. + pub bundle_path: Option, + /// Embedded .mcpb bytes (vendored feature). + #[cfg(feature = "vendored")] + pub bundle_bytes: Option<&'static [u8]>, +} + +/// Resolve the server binary, acquiring it if needed. Blocking; call from a +/// blocking context (`start()` wraps this in `spawn_blocking`). +pub fn resolve_binary(acq: &Acquisition) -> Result { + // 1. Env binary override: run it as-is. + if let Some(bin) = std::env::var_os(cache::ENV_BIN).filter(|v| !v.is_empty()) { + return existing(PathBuf::from(bin), cache::ENV_BIN); + } + // 2. Builder binary override. + if let Some(bin) = &acq.binary { + return existing(bin.clone(), "Builder::binary"); + } + // 3. Env bundle override: extract a local .mcpb. No pin check - the + // override is explicit operator intent and may be a custom build. + if let Some(bundle_path) = std::env::var_os(cache::ENV_BUNDLE).filter(|v| !v.is_empty()) { + return extract_local_bundle(&PathBuf::from(bundle_path), cache::ENV_BUNDLE); + } + // 4. Builder bundle override. + if let Some(bundle_path) = &acq.bundle_path { + return extract_local_bundle(bundle_path, "Builder::bundle_path"); + } + // 5. Vendored bytes embedded by the caller. + #[cfg(feature = "vendored")] + if let Some(bytes) = acq.bundle_bytes { + return extract_vendored(bytes); + } + // 6. Sidecar: shared cache, then verified download. + #[cfg(feature = "sidecar")] + { + return sidecar(); + } + #[allow(unreachable_code)] + Err(Error::Bundle( + "no binary source: enable the sidecar feature, embed a bundle with the vendored \ + feature, or set STACKQL_MCP_BIN / STACKQL_MCP_BUNDLE" + .into(), + )) +} + +fn existing(path: PathBuf, what: &'static str) -> Result { + if path.is_file() { + Ok(path) + } else { + Err(Error::OverrideNotFound { what, path }) + } +} + +/// Extract a caller-supplied .mcpb into a cache slot keyed by its content +/// hash, so different bundles never collide. +fn extract_local_bundle(bundle_path: &Path, what: &'static str) -> Result { + if !bundle_path.is_file() { + return Err(Error::OverrideNotFound { + what, + path: bundle_path.to_path_buf(), + }); + } + let digest = crate::download::sha256_file(bundle_path)?; + let dest = cache::bin_cache_root()?.join("custom").join(&digest[..16]); + if let Some(binary) = bundle::cached_binary(&dest) { + return Ok(binary); + } + let file = fs::File::open(bundle_path)?; + bundle::extract_bundle(file, &dest) +} + +#[cfg(feature = "vendored")] +fn extract_vendored(bytes: &'static [u8]) -> Result { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(bytes); + let mut key = String::with_capacity(16); + for b in &digest[..8] { + key.push_str(&format!("{b:02x}")); + } + let dest = cache::bin_cache_root()?.join("vendored").join(key); + if let Some(binary) = bundle::cached_binary(&dest) { + return Ok(binary); + } + bundle::extract_bundle(std::io::Cursor::new(bytes), &dest) +} + +#[cfg(feature = "sidecar")] +fn sidecar() -> Result { + let platform = Platform::detect()?; + let pin = crate::pins::pin_for(platform)?; + let dest = cache::bundle_cache_dir(crate::pins::STACKQL_VERSION, platform.key())?; + if let Some(binary) = bundle::cached_binary(&dest) { + return Ok(binary); + } + + let url = crate::pins::bundle_url(pin); + let mcpb = cache::bin_cache_root()? + .join(crate::pins::STACKQL_VERSION) + .join(pin.bundle_name); + eprintln!( + "stackql-mcp: downloading {} (first run, cached at {})", + url, + dest.display() + ); + crate::download::download_verified(&url, pin.sha256, &mcpb)?; + let file = fs::File::open(&mcpb)?; + let binary = bundle::extract_bundle(file, &dest)?; + // The extracted dir is the cache; drop the archive to halve disk use. + let _ = fs::remove_file(&mcpb); + Ok(binary) +} diff --git a/src/bundle.rs b/src/bundle.rs new file mode 100644 index 0000000..6245648 --- /dev/null +++ b/src/bundle.rs @@ -0,0 +1,191 @@ +//! .mcpb bundle extraction. +//! +//! A .mcpb is a zip containing manifest.json and the server binary at the +//! manifest's `server.entry_point` (server/stackql or server/stackql.exe). + +use std::fs; +use std::io::{Read, Seek}; +use std::path::{Component, Path, PathBuf}; + +use serde::Deserialize; + +use crate::error::{Error, Result}; + +#[derive(Deserialize)] +struct Manifest { + server: ManifestServer, +} + +#[derive(Deserialize)] +struct ManifestServer { + entry_point: String, +} + +/// If `dest` already holds a valid extracted bundle, return the binary path. +pub fn cached_binary(dest: &Path) -> Option { + let manifest_path = dest.join("manifest.json"); + let data = fs::read(manifest_path).ok()?; + let manifest: Manifest = serde_json::from_slice(&data).ok()?; + let entry = sanitize_entry_point(&manifest.server.entry_point).ok()?; + let binary = dest.join(entry); + binary.is_file().then_some(binary) +} + +/// Extract a .mcpb from `reader` into `dest` and return the path to the +/// server binary. Extraction goes to a sibling temp dir first and is moved +/// into place with a rename, so a crash never leaves a half-populated cache +/// entry and concurrent extractors race benignly. +pub fn extract_bundle(reader: R, dest: &Path) -> Result { + if let Some(binary) = cached_binary(dest) { + return Ok(binary); + } + + let parent = dest + .parent() + .ok_or_else(|| Error::Bundle(format!("cache dir {} has no parent", dest.display())))?; + fs::create_dir_all(parent)?; + + let tmp = parent.join(format!( + ".extract-{}-{}", + std::process::id(), + dest.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("bundle") + )); + if tmp.exists() { + fs::remove_dir_all(&tmp)?; + } + + let result = extract_into(reader, &tmp).and_then(|entry| { + match fs::rename(&tmp, dest) { + Ok(()) => {} + Err(_) if cached_binary(dest).is_some() => { + // Another process won the race; its copy is valid. + let _ = fs::remove_dir_all(&tmp); + } + Err(e) => return Err(Error::Io(e)), + } + Ok(dest.join(entry)) + }); + if result.is_err() { + let _ = fs::remove_dir_all(&tmp); + } + result +} + +/// Unzip into `dir`, validate the manifest, and return the relative +/// entry_point path. +fn extract_into(reader: R, dir: &Path) -> Result { + let mut archive = + zip::ZipArchive::new(reader).map_err(|e| Error::Bundle(format!("bad zip: {e}")))?; + archive + .extract(dir) + .map_err(|e| Error::Bundle(format!("extraction failed: {e}")))?; + + let manifest_data = fs::read(dir.join("manifest.json")) + .map_err(|_| Error::Bundle("manifest.json missing from bundle".into()))?; + let manifest: Manifest = serde_json::from_slice(&manifest_data) + .map_err(|e| Error::Bundle(format!("manifest.json invalid: {e}")))?; + let entry = sanitize_entry_point(&manifest.server.entry_point)?; + + let binary = dir.join(&entry); + if !binary.is_file() { + return Err(Error::Bundle(format!( + "entry_point {} not found in bundle", + manifest.server.entry_point + ))); + } + make_executable(&binary)?; + Ok(entry) +} + +/// Reject absolute or parent-traversing entry_point values. +fn sanitize_entry_point(entry: &str) -> Result { + let path = PathBuf::from(entry); + let safe = !entry.is_empty() && path.components().all(|c| matches!(c, Component::Normal(_))); + if safe { + Ok(path) + } else { + Err(Error::Bundle(format!("unsafe entry_point: {entry}"))) + } +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; + Ok(()) +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Cursor, Write}; + use zip::write::SimpleFileOptions; + + fn fake_bundle(entry_point: &str) -> Vec { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("manifest.json", opts).unwrap(); + zip.write_all(format!(r#"{{"server": {{"entry_point": "{entry_point}"}}}}"#).as_bytes()) + .unwrap(); + zip.start_file("server/stackql", opts).unwrap(); + zip.write_all(b"#!/bin/sh\necho fake stackql\n").unwrap(); + zip.finish().unwrap().into_inner() + } + + fn temp_dest(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("stackql-mcp-test-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + dir.join("bundle") + } + + #[test] + fn extracts_and_returns_the_entry_point() { + let dest = temp_dest("extract"); + let binary = extract_bundle(Cursor::new(fake_bundle("server/stackql")), &dest).unwrap(); + assert_eq!(binary, dest.join("server").join("stackql")); + assert!(binary.is_file()); + // Second call is a cache hit. + let again = extract_bundle(Cursor::new(fake_bundle("server/stackql")), &dest).unwrap(); + assert_eq!(again, binary); + fs::remove_dir_all(dest.parent().unwrap()).unwrap(); + } + + #[test] + fn rejects_traversal_in_entry_point() { + let dest = temp_dest("traversal"); + let err = extract_bundle(Cursor::new(fake_bundle("../../evil")), &dest).unwrap_err(); + assert!(matches!(err, Error::Bundle(_)), "{err}"); + assert!( + !dest.exists(), + "failed extraction must not populate the cache" + ); + let _ = fs::remove_dir_all(dest.parent().unwrap()); + } + + #[test] + fn missing_entry_point_is_an_error() { + let dest = temp_dest("missing"); + let err = extract_bundle(Cursor::new(fake_bundle("server/nope")), &dest).unwrap_err(); + assert!(matches!(err, Error::Bundle(_)), "{err}"); + let _ = fs::remove_dir_all(dest.parent().unwrap()); + } + + #[cfg(unix)] + #[test] + fn extracted_binary_is_executable() { + use std::os::unix::fs::PermissionsExt; + let dest = temp_dest("exec"); + let binary = extract_bundle(Cursor::new(fake_bundle("server/stackql")), &dest).unwrap(); + let mode = fs::metadata(&binary).unwrap().permissions().mode(); + assert_eq!(mode & 0o111, 0o111, "mode was {mode:o}"); + fs::remove_dir_all(dest.parent().unwrap()).unwrap(); + } +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..d6dfb23 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,62 @@ +//! Cache layout and environment overrides. +//! +//! The binary cache is shared with the npm and PyPI wrappers: +//! `~/.stackql/mcp-server-bin///` - always check it +//! before downloading. + +use std::path::PathBuf; + +use crate::error::{Error, Result}; + +/// Path to a stackql binary to run directly, skipping acquisition entirely. +pub const ENV_BIN: &str = "STACKQL_MCP_BIN"; +/// Path to a local .mcpb bundle to extract instead of downloading. +pub const ENV_BUNDLE: &str = "STACKQL_MCP_BUNDLE"; + +/// Resolve the user's home directory without external crates. +pub fn home_dir() -> Result { + if let Some(home) = std::env::var_os("HOME").filter(|v| !v.is_empty()) { + return Ok(PathBuf::from(home)); + } + if cfg!(windows) { + if let Some(profile) = std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()) { + return Ok(PathBuf::from(profile)); + } + } + Err(Error::NoHomeDir) +} + +/// Default approot: `/.stackql`. +pub fn default_approot() -> Result { + Ok(home_dir()?.join(".stackql")) +} + +/// Root of the shared binary cache: `/.stackql/mcp-server-bin`. +pub fn bin_cache_root() -> Result { + Ok(default_approot()?.join("mcp-server-bin")) +} + +/// Cache directory for one extracted bundle: +/// `/.stackql/mcp-server-bin///`. +pub fn bundle_cache_dir(version: &str, platform_key: &str) -> Result { + Ok(bin_cache_root()?.join(version).join(platform_key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_dir_matches_the_shared_layout() { + let dir = bundle_cache_dir("0.10.500", "linux-x64").unwrap(); + let suffix: PathBuf = [".stackql", "mcp-server-bin", "0.10.500", "linux-x64"] + .iter() + .collect(); + assert!( + dir.ends_with(&suffix), + "{} should end with {}", + dir.display(), + suffix.display() + ); + } +} diff --git a/src/download.rs b/src/download.rs new file mode 100644 index 0000000..81d54e6 --- /dev/null +++ b/src/download.rs @@ -0,0 +1,106 @@ +//! sha256 hashing, and sidecar-mode bundle download with verification. + +use std::fs; +use std::io::Read; +#[cfg(feature = "sidecar")] +use std::io::Write; +use std::path::Path; + +use sha2::{Digest, Sha256}; + +#[cfg(feature = "sidecar")] +use crate::error::Error; +use crate::error::Result; + +/// Download `url` to `dest`, verifying the stream against `expected_sha256` +/// (lowercase hex). Writes to a temp file and renames into place, so `dest` +/// only ever holds a fully verified bundle. +#[cfg(feature = "sidecar")] +pub fn download_verified(url: &str, expected_sha256: &str, dest: &Path) -> Result<()> { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + let tmp = dest.with_extension(format!("part-{}", std::process::id())); + + let result = (|| -> Result<()> { + let mut response = ureq::get(url).call().map_err(|e| Error::Http { + url: url.to_string(), + message: e.to_string(), + })?; + let mut reader = response.body_mut().as_reader(); + let mut file = fs::File::create(&tmp)?; + + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = reader.read(&mut buf).map_err(|e| Error::Http { + url: url.to_string(), + message: e.to_string(), + })?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + file.write_all(&buf[..n])?; + } + file.flush()?; + drop(file); + + let actual = hex(&hasher.finalize()); + if actual != expected_sha256 { + return Err(Error::ChecksumMismatch { + bundle: url.to_string(), + expected: expected_sha256.to_string(), + actual, + }); + } + fs::rename(&tmp, dest)?; + Ok(()) + })(); + + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +/// sha256 of a file on disk, as lowercase hex. +pub fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hex(&hasher.finalize())) +} + +fn hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push_str(&format!("{b:02x}")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_file_matches_known_vector() { + // sha256("abc") + let path = + std::env::temp_dir().join(format!("stackql-mcp-test-sha-{}.txt", std::process::id())); + fs::write(&path, b"abc").unwrap(); + assert_eq!( + sha256_file(&path).unwrap(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + fs::remove_file(&path).unwrap(); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..ba0441e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,86 @@ +use std::fmt; +use std::path::PathBuf; + +/// Errors produced while acquiring, verifying, or running the embedded server. +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + /// No .mcpb bundle is published for the host OS/arch. + UnsupportedPlatform { + os: &'static str, + arch: &'static str, + }, + /// The home directory could not be resolved (HOME / USERPROFILE unset). + NoHomeDir, + /// Downloading the bundle failed. + Http { url: String, message: String }, + /// The downloaded bundle did not match the sha256 pin baked into the crate. + ChecksumMismatch { + bundle: String, + expected: String, + actual: String, + }, + /// The .mcpb bundle is malformed (bad zip, missing or invalid manifest). + Bundle(String), + /// Filesystem error. + Io(std::io::Error), + /// The server process could not be spawned. + Spawn(std::io::Error), + /// MCP handshake or client error. + Mcp(String), + /// A path given via an env override or builder option does not exist. + OverrideNotFound { what: &'static str, path: PathBuf }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::UnsupportedPlatform { os, arch } => { + write!(f, "no stackql mcp bundle is published for {os}/{arch}") + } + Error::NoHomeDir => { + write!( + f, + "could not resolve the home directory (HOME / USERPROFILE unset)" + ) + } + Error::Http { url, message } => write!(f, "download of {url} failed: {message}"), + Error::ChecksumMismatch { + bundle, + expected, + actual, + } => write!( + f, + "sha256 mismatch for {bundle}: expected {expected}, got {actual}" + ), + Error::Bundle(msg) => write!(f, "invalid .mcpb bundle: {msg}"), + Error::Io(e) => write!(f, "io error: {e}"), + Error::Spawn(e) => write!(f, "failed to spawn the stackql mcp server: {e}"), + Error::Mcp(msg) => write!(f, "mcp client error: {msg}"), + Error::OverrideNotFound { what, path } => { + write!( + f, + "{what} points to {} which does not exist", + path.display() + ) + } + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Io(e) | Error::Spawn(e) => Some(e), + _ => None, + } + } +} + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error::Io(e) + } +} + +pub type Result = std::result::Result; diff --git a/src/launch.rs b/src/launch.rs new file mode 100644 index 0000000..3387238 --- /dev/null +++ b/src/launch.rs @@ -0,0 +1,96 @@ +//! Canonical launch arguments for the embedded server. +//! +//! The arg shape is the embedding contract from stackql/stackql-mcpb-packaging +//! and must stay cwd-independent: +//! +//! ```text +//! mcp --mcp.server.type=stdio --approot /.stackql +//! --mcp.config {"server": {"mode": "", "audit": {"disabled": true}}} +//! ``` + +use std::ffi::OsString; +use std::path::Path; + +use crate::Mode; + +/// Build the canonical argument vector. `auth` is appended as `--auth=` +/// when present. +pub fn launch_args(mode: Mode, approot: &Path, auth: Option<&serde_json::Value>) -> Vec { + let config = serde_json::json!({ + "server": { + "mode": mode.as_str(), + "audit": {"disabled": true}, + } + }); + let mut args: Vec = vec![ + "mcp".into(), + "--mcp.server.type=stdio".into(), + "--approot".into(), + approot.into(), + "--mcp.config".into(), + config.to_string().into(), + ]; + if let Some(auth) = auth { + args.push(format!("--auth={auth}").into()); + } + args +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn strings(args: &[OsString]) -> Vec { + args.iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn canonical_args_match_the_contract() { + let approot = PathBuf::from("/home/u/.stackql"); + let args = strings(&launch_args(Mode::ReadOnly, &approot, None)); + assert_eq!( + args, + vec![ + "mcp", + "--mcp.server.type=stdio", + "--approot", + "/home/u/.stackql", + "--mcp.config", + r#"{"server":{"audit":{"disabled":true},"mode":"read_only"}}"#, + ] + ); + } + + #[test] + fn mcp_config_is_valid_json_with_mode_and_audit_disabled() { + for mode in [ + Mode::ReadOnly, + Mode::Safe, + Mode::DeleteSafe, + Mode::FullAccess, + ] { + let args = strings(&launch_args(mode, Path::new("/tmp/approot"), None)); + let config: serde_json::Value = serde_json::from_str(&args[5]).unwrap(); + assert_eq!(config["server"]["mode"], mode.as_str()); + assert_eq!(config["server"]["audit"]["disabled"], true); + } + } + + #[test] + fn auth_is_appended_as_a_single_flag() { + let auth = serde_json::json!({"github": {"type": "null_auth"}}); + let args = strings(&launch_args( + Mode::ReadOnly, + Path::new("/tmp/approot"), + Some(&auth), + )); + let last = args.last().unwrap(); + assert!(last.starts_with("--auth="), "{last}"); + let parsed: serde_json::Value = + serde_json::from_str(last.strip_prefix("--auth=").unwrap()).unwrap(); + assert_eq!(parsed["github"]["type"], "null_auth"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..673a80f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,246 @@ +//! Embedded StackQL MCP server for Rust agentic apps. +//! +//! StackQL exposes cloud providers (AWS, GitHub, Google, Azure, ...) as SQL +//! tables, served over the Model Context Protocol. This crate acquires the +//! `stackql` binary, launches it as an MCP server over stdio, and hands you a +//! connected [`rmcp`] client. +//! +//! Two acquisition modes behind one API: +//! +//! - sidecar (default feature): download the platform's .mcpb bundle at first +//! run, verify its sha256 against pins baked into the crate, and cache it +//! under `~/.stackql/mcp-server-bin/` (shared with the npm and PyPI +//! wrappers) +//! - vendored (`vendored` feature): embed the .mcpb with `include_bytes!` and +//! extract on first run - no network at runtime, single shippable binary +//! +//! ```no_run +//! use stackql_mcp::{Mode, StackqlMcp}; +//! +//! # async fn run() -> Result<(), Box> { +//! let server = StackqlMcp::builder() +//! .mode(Mode::ReadOnly) +//! .auth(serde_json::json!({"github": {"type": "null_auth"}})) +//! .start() +//! .await?; +//! let tools = server.list_all_tools().await?; +//! println!("{} tools available", tools.len()); +//! server.shutdown().await?; +//! # Ok(()) +//! # } +//! ``` + +mod acquire; +mod bundle; +mod cache; +mod download; +mod error; +mod launch; +mod pins; +mod platform; + +use std::ops::Deref; +use std::path::PathBuf; +use std::process::Stdio; + +use rmcp::service::RunningService; +use rmcp::{RoleClient, ServiceExt}; + +pub use cache::{ENV_BIN, ENV_BUNDLE}; +pub use error::{Error, Result}; +pub use pins::{Pin, PINS, STACKQL_VERSION}; +pub use platform::Platform; + +/// Safety contract for query / mutation / lifecycle tools, enforced +/// server-side. Maps to `server.mode` in the server's `--mcp.config`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Mode { + /// SELECT and metadata tools only. The default: escalation is an + /// explicit caller opt-in. + #[default] + ReadOnly, + /// Reads plus non-destructive mutations (the server's own default). + Safe, + /// Safe plus deletes. + DeleteSafe, + /// All operations, including lifecycle provisioning. + FullAccess, +} + +impl Mode { + /// The wire value for `server.mode`. + pub fn as_str(self) -> &'static str { + match self { + Mode::ReadOnly => "read_only", + Mode::Safe => "safe", + Mode::DeleteSafe => "delete_safe", + Mode::FullAccess => "full_access", + } + } +} + +/// Entry point. See the crate docs for the full example. +pub struct StackqlMcp; + +impl StackqlMcp { + pub fn builder() -> Builder { + Builder::default() + } +} + +/// Configures and starts the embedded server. +#[derive(Default)] +pub struct Builder { + mode: Mode, + auth: Option, + approot: Option, + acquisition: acquire::Acquisition, +} + +impl Builder { + /// Safety mode for the server. Defaults to [`Mode::ReadOnly`]. + pub fn mode(mut self, mode: Mode) -> Self { + self.mode = mode; + self + } + + /// Provider auth document, passed to the server as `--auth=`. + /// Example: `json!({"github": {"type": "null_auth"}})`. + pub fn auth(mut self, auth: serde_json::Value) -> Self { + self.auth = Some(auth); + self + } + + /// Override the server's application root. Defaults to `/.stackql`. + pub fn approot(mut self, approot: impl Into) -> Self { + self.approot = Some(approot.into()); + self + } + + /// Run an existing stackql binary instead of acquiring one. The + /// `STACKQL_MCP_BIN` env var takes precedence over this. + pub fn binary(mut self, path: impl Into) -> Self { + self.acquisition.binary = Some(path.into()); + self + } + + /// Extract a local .mcpb bundle instead of downloading. The + /// `STACKQL_MCP_BUNDLE` env var takes precedence over this. + pub fn bundle_path(mut self, path: impl Into) -> Self { + self.acquisition.bundle_path = Some(path.into()); + self + } + + /// Embed the .mcpb bundle in your binary and extract it on first run: + /// `builder.bundle_bytes(include_bytes!("../stackql-mcp-linux-x64.mcpb"))`. + #[cfg(feature = "vendored")] + pub fn bundle_bytes(mut self, bytes: &'static [u8]) -> Self { + self.acquisition.bundle_bytes = Some(bytes); + self + } + + /// Resolve the binary (acquiring it if needed) and return a + /// [`std::process::Command`] preloaded with the canonical launch args. + /// Blocking. The escape hatch for callers bringing their own MCP stack + /// or process supervision; stdio configuration is left to the caller. + pub fn command(&self) -> Result { + let binary = acquire::resolve_binary(&self.acquisition)?; + let approot = self.resolved_approot()?; + let mut cmd = std::process::Command::new(binary); + cmd.args(launch::launch_args(self.mode, &approot, self.auth.as_ref())); + Ok(cmd) + } + + /// Acquire the binary if needed, spawn the server, and complete the MCP + /// handshake. Must be called from within a tokio runtime. + pub async fn start(self) -> Result { + let approot = self.resolved_approot()?; + let acquisition = self.acquisition; + let binary = tokio::task::spawn_blocking(move || acquire::resolve_binary(&acquisition)) + .await + .map_err(|e| Error::Mcp(format!("acquisition task failed: {e}")))??; + + let mut child = tokio::process::Command::new(&binary) + .args(launch::launch_args(self.mode, &approot, self.auth.as_ref())) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // Diagnostics belong on stderr; let them flow through. + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .map_err(Error::Spawn)?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Mcp("child stdout not captured".into()))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| Error::Mcp("child stdin not captured".into()))?; + + let client = () + .serve((stdout, stdin)) + .await + .map_err(|e| Error::Mcp(format!("initialize failed: {e}")))?; + + Ok(RunningServer { + child, + client, + binary, + }) + } + + fn resolved_approot(&self) -> Result { + match &self.approot { + Some(p) => Ok(p.clone()), + None => cache::default_approot(), + } + } +} + +/// A running embedded server: the child process handle plus a connected +/// rmcp client. Derefs to the client, so rmcp peer methods +/// (`list_all_tools`, `call_tool`, ...) are available directly. +pub struct RunningServer { + child: tokio::process::Child, + client: RunningService, + binary: PathBuf, +} + +impl RunningServer { + /// The connected rmcp client. + pub fn client(&self) -> &RunningService { + &self.client + } + + /// OS process id of the server, if it is still running. + pub fn pid(&self) -> Option { + self.child.id() + } + + /// Path of the stackql binary that was launched. + pub fn binary_path(&self) -> &std::path::Path { + &self.binary + } + + /// Close the MCP session and stop the server process. + pub async fn shutdown(self) -> Result<()> { + let RunningServer { + mut child, client, .. + } = self; + // Cancelling drops the transport; the server sees EOF on stdin and + // exits. The kill is a backstop for a wedged process. + let _ = client.cancel().await; + let _ = child.kill().await; + Ok(()) + } +} + +impl Deref for RunningServer { + type Target = RunningService; + + fn deref(&self) -> &Self::Target { + &self.client + } +} diff --git a/src/pins.rs b/src/pins.rs new file mode 100644 index 0000000..cde8157 --- /dev/null +++ b/src/pins.rs @@ -0,0 +1,108 @@ +//! Per-platform sha256 pins for the packaged stackql release. +//! +//! Rendered from the .sha256 assets on the stackql/stackql release that the +//! packaging repo (stackql/stackql-mcpb-packaging) targets. Update this table +//! when bumping STACKQL_VERSION. Once the packaging repo publishes a +//! consolidated platforms.json release asset, prefer rendering from that. + +use crate::error::{Error, Result}; +use crate::platform::Platform; + +/// The stackql release this crate version pins (release.yaml in the +/// packaging repo, leading v stripped). +pub const STACKQL_VERSION: &str = "0.10.500"; + +/// A pinned bundle: name and sha256 as published on the GitHub release. +#[derive(Clone, Copy, Debug)] +pub struct Pin { + pub platform_key: &'static str, + pub bundle_name: &'static str, + pub sha256: &'static str, +} + +pub const PINS: &[Pin] = &[ + Pin { + platform_key: "linux-x64", + bundle_name: "stackql-mcp-linux-x64.mcpb", + sha256: "6615737747156b1a8413a976afb23af2e7eec29ebc98a6f0a0f65d1b153c44be", + }, + Pin { + platform_key: "linux-arm64", + bundle_name: "stackql-mcp-linux-arm64.mcpb", + sha256: "594bedbabc3096dc3563c907724e845ce0b61a67de4b3fed4158b40c0363786c", + }, + Pin { + platform_key: "windows-x64", + bundle_name: "stackql-mcp-windows-x64.mcpb", + sha256: "d2ce895e88f9c6b557df07073158629808f56d75598f3a701164d65506b791b0", + }, + Pin { + platform_key: "darwin-universal", + bundle_name: "stackql-mcp-darwin-universal.mcpb", + sha256: "4eed70af5cfa67295ae0b42fa3a6dca71ac9acabd0d67914fd96ad1247a9b4cc", + }, +]; + +/// Look up the pin for a platform. Every `Platform` variant has a pin; a miss +/// here is a crate bug, so it surfaces as `UnsupportedPlatform`. +pub fn pin_for(platform: Platform) -> Result<&'static Pin> { + PINS.iter() + .find(|p| p.platform_key == platform.key()) + .ok_or(Error::UnsupportedPlatform { + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + }) +} + +/// Download URL for a pinned bundle. Bundles are attached to the matching +/// stackql/stackql release. +pub fn bundle_url(pin: &Pin) -> String { + format!( + "https://github.com/stackql/stackql/releases/download/v{STACKQL_VERSION}/{}", + pin.bundle_name + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_platform_has_a_pin() { + for platform in [ + Platform::LinuxX64, + Platform::LinuxArm64, + Platform::WindowsX64, + Platform::DarwinUniversal, + ] { + let pin = pin_for(platform).unwrap(); + assert_eq!(pin.platform_key, platform.key()); + assert_eq!( + pin.bundle_name, + format!("stackql-mcp-{}.mcpb", platform.key()) + ); + } + } + + #[test] + fn pins_are_well_formed_sha256_hex() { + for pin in PINS { + assert_eq!(pin.sha256.len(), 64, "{}", pin.bundle_name); + assert!( + pin.sha256.chars().all(|c| c.is_ascii_hexdigit()), + "{}", + pin.bundle_name + ); + assert_eq!(pin.sha256, pin.sha256.to_lowercase()); + } + } + + #[test] + fn bundle_url_points_at_the_pinned_release() { + let pin = pin_for(Platform::LinuxX64).unwrap(); + assert_eq!( + bundle_url(pin), + "https://github.com/stackql/stackql/releases/download/v0.10.500/stackql-mcp-linux-x64.mcpb" + ); + } +} diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..9a89a80 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,90 @@ +use crate::error::{Error, Result}; + +/// A platform the packaging repo publishes a .mcpb bundle for. +/// +/// Keys mirror stackql/stackql-mcpb-packaging: linux-x64, linux-arm64, +/// windows-x64, darwin-universal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Platform { + LinuxX64, + LinuxArm64, + WindowsX64, + DarwinUniversal, +} + +impl Platform { + /// Detect the host platform, or fail if no bundle is published for it. + pub fn detect() -> Result { + Self::from_os_arch(std::env::consts::OS, std::env::consts::ARCH) + } + + pub(crate) fn from_os_arch(os: &'static str, arch: &'static str) -> Result { + match (os, arch) { + ("linux", "x86_64") => Ok(Platform::LinuxX64), + ("linux", "aarch64") => Ok(Platform::LinuxArm64), + ("windows", "x86_64") => Ok(Platform::WindowsX64), + // The darwin bundle is a universal binary, so any macOS arch works. + ("macos", _) => Ok(Platform::DarwinUniversal), + _ => Err(Error::UnsupportedPlatform { os, arch }), + } + } + + /// The platform key used in bundle names and cache paths. + pub fn key(self) -> &'static str { + match self { + Platform::LinuxX64 => "linux-x64", + Platform::LinuxArm64 => "linux-arm64", + Platform::WindowsX64 => "windows-x64", + Platform::DarwinUniversal => "darwin-universal", + } + } + + /// Name of the server binary inside the bundle's server/ directory. + pub fn binary_name(self) -> &'static str { + match self { + Platform::WindowsX64 => "stackql.exe", + _ => "stackql", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_platforms_map_to_keys() { + assert_eq!( + Platform::from_os_arch("linux", "x86_64").unwrap().key(), + "linux-x64" + ); + assert_eq!( + Platform::from_os_arch("linux", "aarch64").unwrap().key(), + "linux-arm64" + ); + assert_eq!( + Platform::from_os_arch("windows", "x86_64").unwrap().key(), + "windows-x64" + ); + assert_eq!( + Platform::from_os_arch("macos", "aarch64").unwrap().key(), + "darwin-universal" + ); + assert_eq!( + Platform::from_os_arch("macos", "x86_64").unwrap().key(), + "darwin-universal" + ); + } + + #[test] + fn unsupported_platform_is_an_error() { + assert!(Platform::from_os_arch("freebsd", "x86_64").is_err()); + assert!(Platform::from_os_arch("windows", "aarch64").is_err()); + } + + #[test] + fn windows_binary_has_exe_suffix() { + assert_eq!(Platform::WindowsX64.binary_name(), "stackql.exe"); + assert_eq!(Platform::LinuxX64.binary_name(), "stackql"); + } +} diff --git a/tests/conformance.rs b/tests/conformance.rs new file mode 100644 index 0000000..a1df9bd --- /dev/null +++ b/tests/conformance.rs @@ -0,0 +1,76 @@ +//! Conformance test mirroring stackql-mcpb-packaging's scripts/smoke-test.py: +//! initialize -> tools/list -> pull_provider github -> list_services github, +//! using the github provider in null_auth mode (no cloud credentials). +//! +//! Ignored by default: first run downloads the ~35 MB server bundle. CI runs +//! it with `cargo test -- --include-ignored`. + +use std::time::Duration; + +use rmcp::model::CallToolRequestParams; +use stackql_mcp::{Mode, StackqlMcp}; + +const CALL_TIMEOUT: Duration = Duration::from_secs(120); + +#[tokio::test] +#[ignore = "network: downloads the server bundle on first run"] +async fn handshake_tools_and_github_fixture() { + let server = StackqlMcp::builder() + .mode(Mode::ReadOnly) + .auth(serde_json::json!({"github": {"type": "null_auth"}})) + .start() + .await + .expect("server should start and complete the MCP handshake"); + + assert!(server.pid().is_some(), "server process should be running"); + + // tools/list must include the tools the smoke test requires. + let tools = tokio::time::timeout(CALL_TIMEOUT, server.list_all_tools()) + .await + .expect("tools/list timed out") + .expect("tools/list failed"); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + for required in ["pull_provider", "list_services", "list_providers"] { + assert!( + names.contains(&required), + "missing tool {required} in {names:?}" + ); + } + + // pull_provider github must succeed. + let mut pull = CallToolRequestParams::new("pull_provider"); + pull.arguments = serde_json::json!({"provider": "github"}) + .as_object() + .cloned(); + let pulled = tokio::time::timeout(CALL_TIMEOUT, server.call_tool(pull)) + .await + .expect("pull_provider timed out") + .expect("pull_provider failed"); + assert_ne!( + pulled.is_error, + Some(true), + "pull_provider errored: {pulled:?}" + ); + + // list_services github must return known github services. + let mut list = CallToolRequestParams::new("list_services"); + list.arguments = serde_json::json!({"provider": "github", "row_limit": 5}) + .as_object() + .cloned(); + let services = tokio::time::timeout(CALL_TIMEOUT, server.call_tool(list)) + .await + .expect("list_services timed out") + .expect("list_services failed"); + assert_ne!( + services.is_error, + Some(true), + "list_services errored: {services:?}" + ); + let rendered = serde_json::to_string(&services).unwrap(); + assert!( + rendered.contains("actions") || rendered.contains("apps"), + "list_services did not include expected github services: {rendered}" + ); + + server.shutdown().await.expect("shutdown should succeed"); +} From aedbed3316d0bda8283c8918b6732f238dfbb315 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Sat, 13 Jun 2026 10:47:12 +1000 Subject: [PATCH 2/4] Add auditron demo app and vendored single-binary build path Milestone 2: the terminal compliance copilot demo plus the vendored embedding story. stackql-mcp: - fetch_bundle(): download the pinned platform .mcpb into the shared cache (sha256-verified, idempotent) - the producer step for vendored builds; examples/fetch_bundle.rs prints the path for pipelines - include_bundle! macro: embed the bundle named by the compile-time STACKQL_MCP_BUNDLE_FILE env var for Builder::bundle_bytes auditron (workspace member, not published): - scan: runs a YAML control pack through the embedded server in read_only mode; ratatui TUI streams pass/fail/error per control with the producing SQL always displayed; --no-tui line output for CI (exit 0 all pass, 2 any fail/error) - press e on a finding: Claude (Messages API over ureq, pluggable Explainer trait) explains it and drafts remediation steps; without ANTHROPIC_API_KEY the SQL and a hint are shown instead - evidence: re-runs the pack and writes an auditor-ready zip - run manifest (pack sha256, collector identity, server_info, timings), the exact pack source and per-control SQL, per-control CSVs - controls/github-core.yaml: org security posture pack on the github provider in null_auth mode (zero credentials) - branch protection, default branch, descriptions, licenses, staleness, plus an inventory evidence control that doubles as the run canary (provider errors surface as empty result sets, which would silently pass no_rows controls but fail the canary) Engine learnings baked into the pack authoring notes: tool results carry the typed DTO in structuredContent (markdown in text content); boolean storage varies per resource (compare = 0/1, never the strings true/false); avoid OR and NOT in WHERE (pushdown quirks). Verified end to end in WSL: live scan against the stackql org finds real violations; evidence zip inspected; vendored release build (~80MB) runs on a clean HOME with no downloads. CI extended for the workspace including a vendored build check on all three OSes. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 20 +- Cargo.lock | 935 +++++++++++++++++++++++++++++++++++++- Cargo.toml | 9 +- README.md | 30 +- auditron/Cargo.toml | 31 ++ auditron/src/engine.rs | 253 +++++++++++ auditron/src/explain.rs | 102 +++++ auditron/src/main.rs | 194 ++++++++ auditron/src/pack.rs | 179 ++++++++ auditron/src/report.rs | 239 ++++++++++ auditron/src/tui.rs | 439 ++++++++++++++++++ controls/github-core.yaml | 127 ++++++ examples/fetch_bundle.rs | 13 + src/lib.rs | 44 ++ 14 files changed, 2603 insertions(+), 12 deletions(-) create mode 100644 auditron/Cargo.toml create mode 100644 auditron/src/engine.rs create mode 100644 auditron/src/explain.rs create mode 100644 auditron/src/main.rs create mode 100644 auditron/src/pack.rs create mode 100644 auditron/src/report.rs create mode 100644 auditron/src/tui.rs create mode 100644 controls/github-core.yaml create mode 100644 examples/fetch_bundle.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a15998..2983436 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,10 @@ jobs: with: components: rustfmt, clippy - run: cargo fmt --check - - run: cargo clippy --all-targets --all-features -- -D warnings + - run: cargo clippy --workspace --all-targets -- -D warnings + # auditron's vendored feature needs a bundle at compile time, so + # all-features lint is scoped to the library crate. + - run: cargo clippy -p stackql-mcp --all-targets --all-features -- -D warnings test: strategy: @@ -29,9 +32,14 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: unit tests (sidecar) - run: cargo test - - name: unit tests (vendored) - run: cargo test --features vendored + - name: unit tests (workspace) + run: cargo test --workspace + - name: unit tests (stackql-mcp vendored) + run: cargo test -p stackql-mcp --features vendored - name: conformance (downloads the pinned server bundle) - run: cargo test --test conformance -- --include-ignored --nocapture + run: cargo test -p stackql-mcp --test conformance -- --include-ignored --nocapture + - name: vendored single-binary build (auditron) + shell: bash + run: | + BUNDLE=$(cargo run -q -p stackql-mcp --example fetch_bundle) + STACKQL_MCP_BUNDLE_FILE=$BUNDLE cargo build -p auditron --features vendored diff --git a/Cargo.lock b/Cargo.lock index 11d7575..4ed2c5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[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" @@ -17,6 +23,62 @@ 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 = "async-trait" version = "0.1.89" @@ -28,6 +90,27 @@ dependencies = [ "syn", ] +[[package]] +name = "auditron" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "crossterm", + "csv", + "ratatui", + "rmcp", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "stackql-mcp", + "tokio", + "ureq", + "zip", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -40,6 +123,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "block-buffer" version = "0.10.4" @@ -61,6 +150,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.64" @@ -89,6 +193,95 @@ dependencies = [ "windows-link", ] +[[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", +] + +[[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 = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -113,6 +306,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -123,6 +341,70 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[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.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -133,6 +415,32 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "equivalent" version = "1.0.2" @@ -166,6 +474,21 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.32" @@ -275,12 +598,29 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[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 = "http" version = "1.4.2" @@ -321,6 +661,115 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -328,7 +777,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[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.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", ] [[package]] @@ -354,12 +840,48 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.8.2" @@ -383,10 +905,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -402,6 +931,41 @@ 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 = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -414,6 +978,21 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -432,6 +1011,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "ring" version = "0.17.14" @@ -465,6 +1074,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.23.40" @@ -506,6 +1128,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -549,6 +1183,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.9" @@ -566,6 +1213,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -588,6 +1256,18 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "stackql-mcp" version = "0.1.0" @@ -601,6 +1281,40 @@ dependencies = [ "zip", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -618,6 +1332,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -638,6 +1363,47 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -737,6 +1503,41 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -750,11 +1551,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64", + "cookie_store", "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", + "serde", + "serde_json", "ureq-proto", "utf8-zero", "webpki-roots", @@ -772,12 +1576,36 @@ dependencies = [ "log", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +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 = "version_check" version = "0.9.5" @@ -844,6 +1672,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -985,12 +1835,95 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zip" version = "8.6.0" diff --git a/Cargo.toml b/Cargo.toml index 4b1f8b8..7e05d1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,11 @@ +[workspace] +members = ["auditron"] +resolver = "3" + [package] name = "stackql-mcp" version = "0.1.0" edition = "2021" -resolver = "3" # Effective MSRV is set by rmcp 1.x (let-chains): 1.88. rust-version = "1.88" description = "Embedded StackQL MCP server for Rust agentic apps - cloud queries and provisioning over SQL, served over MCP" @@ -39,3 +42,7 @@ name = "minimal" [[example]] name = "launcher" + +[[example]] +name = "fetch_bundle" +required-features = ["sidecar"] diff --git a/README.md b/README.md index fe11a74..8dcd5b6 100644 --- a/README.md +++ b/README.md @@ -65,13 +65,35 @@ Builder equivalents: `.binary(path)`, `.bundle_path(path)`, plus `.approot(path) If you bring your own MCP stack, `Builder::command()` returns a `std::process::Command` preloaded with the canonical launch arguments instead of starting anything. +## Demo app: auditron + +The repo ships `auditron`, a terminal compliance copilot built on this crate: point-in-time control checks with auditor-ready evidence packs. Control packs are YAML data under [controls/](controls/); the github pack runs unauthenticated, so it works with zero cloud credentials. + +```sh +cargo run -p auditron -- scan # live TUI, github-core pack +cargo run -p auditron -- scan --no-tui # line output for CI/pipes +cargo run -p auditron -- scan --var org=your-org # point it at your org +cargo run -p auditron -- evidence --out evidence-2026-06.zip +``` + +The TUI streams pass/fail/error per control and always shows the SQL that produced a finding. Select a finding and press `e` to have Claude explain it and draft remediation steps (needs `ANTHROPIC_API_KEY`). The evidence zip contains the run manifest, the exact pack and SQL, and per-control CSVs - re-runnable by an auditor. + +auditron is also the single-binary pitch. Build it with the server embedded: + +```sh +BUNDLE=$(cargo run -p stackql-mcp --example fetch_bundle) +STACKQL_MCP_BUNDLE_FILE=$BUNDLE cargo build -p auditron --features vendored --release +``` + +The resulting binary (~80 MB) carries the StackQL server inside and runs on a clean machine with no downloads. + ## Development ```sh -cargo test # unit tests -cargo test --features vendored # vendored path -cargo test --test conformance -- --include-ignored # downloads the pinned bundle -cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings +cargo test --workspace # unit tests +cargo test -p stackql-mcp --features vendored # vendored path +cargo test -p stackql-mcp --test conformance -- --include-ignored # downloads the pinned bundle +cargo fmt --check && cargo clippy --workspace --all-targets -- -D warnings ``` MSRV: 1.88 (set by rmcp 1.x). diff --git a/auditron/Cargo.toml b/auditron/Cargo.toml new file mode 100644 index 0000000..1110f79 --- /dev/null +++ b/auditron/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "auditron" +version = "0.1.0" +edition = "2021" +rust-version = "1.88" +publish = false +description = "Terminal compliance copilot: point-in-time control checks with auditor-ready evidence packs, on an embedded StackQL MCP server" +license = "MIT" + +[features] +default = [] +# Single-binary build: embed the .mcpb at compile time via +# STACKQL_MCP_BUNDLE_FILE (see stackql_mcp::fetch_bundle). +vendored = ["stackql-mcp/vendored"] + +[dependencies] +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +clap = { version = "4", features = ["derive"] } +crossterm = "0.28" +csv = "1" +ratatui = "0.29" +rmcp = { version = "1.7", default-features = false, features = ["client"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +stackql-mcp = { path = ".." } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } +ureq = { version = "3", features = ["json"] } +zip = { version = "8", default-features = false, features = ["deflate"] } diff --git a/auditron/src/engine.rs b/auditron/src/engine.rs new file mode 100644 index 0000000..878dbbf --- /dev/null +++ b/auditron/src/engine.rs @@ -0,0 +1,253 @@ +//! Scan engine: drives the embedded StackQL MCP server through a control +//! pack and streams per-control results. + +use std::collections::BTreeMap; + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, SecondsFormat, Utc}; +use rmcp::model::CallToolRequestParams; +use serde::Serialize; +use stackql_mcp::{Mode, RunningServer, StackqlMcp}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::pack::{Pack, PassWhen}; + +/// One row of a control's result set: column -> value (stackql returns all +/// values as strings; SQL NULL arrives as the string "null"). +pub type Row = BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Pass, + Fail, + Error, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ControlResult { + pub id: String, + pub title: String, + pub status: Status, + /// The exact SQL sent to the server (variables rendered). + pub sql: String, + /// Findings (pass_when: no_rows) or evidence rows (pass_when: rows). + pub rows: Vec, + /// Set when status is Error. + pub error: Option, + pub started_at: String, + pub finished_at: String, + pub duration_ms: u64, +} + +/// Events streamed to the UI / headless reporter as the scan progresses. +#[derive(Debug)] +pub enum Event { + /// Server is up; identity payload from server_info. + ServerReady { + server_info: serde_json::Value, + }, + ControlStarted { + index: usize, + }, + ControlFinished { + index: usize, + result: ControlResult, + }, + /// The scan is over; the run is complete. + Finished, + /// The scan could not run at all (acquisition/spawn/handshake failure). + Fatal { + message: String, + }, +} + +/// Outcome summary of a finished scan. +#[derive(Clone, Debug, Serialize)] +pub struct RunSummary { + pub started_at: String, + pub finished_at: String, + pub server_info: serde_json::Value, + pub results: Vec, +} + +impl RunSummary { + pub fn counts(&self) -> (usize, usize, usize) { + let mut counts = (0, 0, 0); + for result in &self.results { + match result.status { + Status::Pass => counts.0 += 1, + Status::Fail => counts.1 += 1, + Status::Error => counts.2 += 1, + } + } + counts + } +} + +fn now_iso() -> (DateTime, String) { + let now = Utc::now(); + let iso = now.to_rfc3339_opts(SecondsFormat::Secs, true); + (now, iso) +} + +/// Run the pack end to end, emitting [`Event`]s as controls complete. +/// Returns the summary (also derivable from the events) for callers that +/// just await the whole run. +pub async fn run_scan( + pack: &Pack, + row_limit: u32, + events: &UnboundedSender, +) -> Result { + let (_, started_at) = now_iso(); + + let server = match start_server(pack).await { + Ok(server) => server, + Err(e) => { + let _ = events.send(Event::Fatal { + message: format!("{e:#}"), + }); + return Err(e); + } + }; + + let server_info = call_json(&server, "server_info", serde_json::json!({})) + .await + .unwrap_or_else(|e| serde_json::json!({"error": e.to_string()})); + let _ = events.send(Event::ServerReady { + server_info: server_info.clone(), + }); + + // Install the pack's provider before the first query. + call_json( + &server, + "pull_provider", + serde_json::json!({"provider": pack.provider}), + ) + .await + .with_context(|| format!("pulling provider {}", pack.provider))?; + + let mut results = Vec::with_capacity(pack.controls.len()); + for (index, control) in pack.controls.iter().enumerate() { + let _ = events.send(Event::ControlStarted { index }); + let result = run_control(&server, control, row_limit).await; + let _ = events.send(Event::ControlFinished { + index, + result: result.clone(), + }); + results.push(result); + } + + let _ = events.send(Event::Finished); + server.shutdown().await.ok(); + + let (_, finished_at) = now_iso(); + Ok(RunSummary { + started_at, + finished_at, + server_info, + results, + }) +} + +async fn start_server(pack: &Pack) -> Result { + let builder = StackqlMcp::builder() + // Scans are point-in-time reads; the server enforces it. + .mode(Mode::ReadOnly) + .auth(pack.auth.clone()); + #[cfg(feature = "vendored")] + let builder = builder.bundle_bytes(stackql_mcp::include_bundle!()); + builder + .start() + .await + .context("starting the embedded stackql mcp server") +} + +async fn run_control( + server: &RunningServer, + control: &crate::pack::Control, + row_limit: u32, +) -> ControlResult { + let (start, started_at) = now_iso(); + let outcome = call_json( + server, + "run_select_query", + serde_json::json!({"sql": control.sql, "row_limit": row_limit}), + ) + .await; + let (end, finished_at) = now_iso(); + let duration_ms = (end - start).num_milliseconds().max(0) as u64; + + let base = ControlResult { + id: control.id.clone(), + title: control.title.clone(), + status: Status::Error, + sql: control.sql.clone(), + rows: Vec::new(), + error: None, + started_at, + finished_at, + duration_ms, + }; + + match outcome { + Ok(value) => { + let rows: Vec = match serde_json::from_value(value["rows"].clone()) { + Ok(rows) => rows, + Err(e) => { + return ControlResult { + error: Some(format!("unexpected result shape: {e}")), + ..base + } + } + }; + let pass = match control.pass_when { + PassWhen::NoRows => rows.is_empty(), + PassWhen::Rows => !rows.is_empty(), + }; + ControlResult { + status: if pass { Status::Pass } else { Status::Fail }, + rows, + ..base + } + } + Err(e) => ControlResult { + error: Some(format!("{e:#}")), + ..base + }, + } +} + +/// Call a stackql MCP tool and return its structured payload. The server +/// puts the typed DTO in structuredContent and a markdown rendering in the +/// text content; prefer the former, fall back to parsing text as JSON. +async fn call_json( + server: &RunningServer, + tool: &str, + arguments: serde_json::Value, +) -> Result { + let mut params = CallToolRequestParams::new(tool.to_string()); + params.arguments = arguments.as_object().cloned(); + let result = server + .call_tool(params) + .await + .with_context(|| format!("calling tool {tool}"))?; + + // Dig payloads out via serde to stay independent of rmcp's content + // accessor surface. + let value = serde_json::to_value(&result)?; + let text = value["content"][0]["text"].as_str(); + if result.is_error == Some(true) { + return Err(anyhow!( + "tool {tool} failed: {}", + text.unwrap_or("(no error text)") + )); + } + let structured = &value["structuredContent"]; + if !structured.is_null() { + return Ok(structured.clone()); + } + let text = text.ok_or_else(|| anyhow!("tool {tool} returned no content: {value}"))?; + serde_json::from_str(text) + .with_context(|| format!("parsing {tool} response as JSON: {text:.200}")) +} diff --git a/auditron/src/explain.rs b/auditron/src/explain.rs new file mode 100644 index 0000000..194173e --- /dev/null +++ b/auditron/src/explain.rs @@ -0,0 +1,102 @@ +//! Finding explanation via an agent. Claude (Messages API over plain HTTP) +//! is the default implementation; the trait keeps it pluggable. + +use anyhow::{anyhow, Context, Result}; + +use crate::engine::ControlResult; +use crate::pack::Control; + +/// Rows included in the prompt at most; evidence packs carry the full set. +const PROMPT_ROW_SAMPLE: usize = 20; + +pub trait Explainer: Send + Sync { + /// Explain a finding and draft remediation steps. Blocking. + fn explain(&self, control: &Control, result: &ControlResult) -> Result; +} + +/// Claude via the Messages API (no official Rust SDK; raw HTTP via ureq). +pub struct ClaudeExplainer { + api_key: String, + model: String, +} + +impl ClaudeExplainer { + /// Returns None when ANTHROPIC_API_KEY is unset; the TUI then shows the + /// SQL and a hint instead of an explanation. + pub fn from_env() -> Option { + let api_key = std::env::var("ANTHROPIC_API_KEY") + .ok() + .filter(|k| !k.is_empty())?; + let model = + std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| "claude-opus-4-8".to_string()); + Some(ClaudeExplainer { api_key, model }) + } +} + +impl Explainer for ClaudeExplainer { + fn explain(&self, control: &Control, result: &ControlResult) -> Result { + let sample: Vec<_> = result.rows.iter().take(PROMPT_ROW_SAMPLE).collect(); + let user_message = format!( + "Control: {} - {}\n\nDescription: {}\n\nSQL that produced the finding:\n{}\n\n\ + Status: {:?} ({} rows{}).\n\nFinding rows (sample of {}):\n{}\n{}", + control.id, + control.title, + control.description, + result.sql, + result.status, + result.rows.len(), + result + .error + .as_deref() + .map(|e| format!(", error: {e}")) + .unwrap_or_default(), + sample.len(), + serde_json::to_string_pretty(&sample)?, + control + .remediation + .as_deref() + .map(|r| format!("\nPack-suggested remediation: {r}")) + .unwrap_or_default(), + ); + + let body = serde_json::json!({ + "model": self.model, + "max_tokens": 16000, + "thinking": {"type": "adaptive"}, + "system": "You are a compliance copilot embedded in auditron, a terminal \ + audit tool. The user is a compliance engineer reviewing a failed or \ + errored control from a point-in-time scan run over StackQL (cloud APIs \ + exposed as SQL). Explain what the finding means, why it matters, and \ + give concrete, numbered remediation steps. Be matter-of-fact and \ + concise; plain text only, no markdown headings.", + "messages": [{"role": "user", "content": user_message}], + }); + + let mut response = ureq::post("https://api.anthropic.com/v1/messages") + .header("x-api-key", &self.api_key) + .header("anthropic-version", "2023-06-01") + .send_json(&body) + .context("calling the Claude Messages API")?; + let parsed: serde_json::Value = response + .body_mut() + .read_json() + .context("parsing Messages API response")?; + + if parsed["stop_reason"] == "refusal" { + return Err(anyhow!("the model declined to answer this request")); + } + // Content is a block array; thinking blocks precede text blocks. + let text: String = parsed["content"] + .as_array() + .ok_or_else(|| anyhow!("unexpected response shape: {parsed}"))? + .iter() + .filter(|block| block["type"] == "text") + .filter_map(|block| block["text"].as_str()) + .collect::>() + .join("\n"); + if text.is_empty() { + return Err(anyhow!("model returned no text content: {parsed}")); + } + Ok(text) + } +} diff --git a/auditron/src/main.rs b/auditron/src/main.rs new file mode 100644 index 0000000..b6cd9bc --- /dev/null +++ b/auditron/src/main.rs @@ -0,0 +1,194 @@ +//! auditron - terminal compliance copilot. +//! +//! Point-in-time control checks over cloud/SaaS estates via an embedded +//! StackQL MCP server, with auditor-ready evidence packs. Diagnostics go to +//! stderr; stdout carries results. + +mod engine; +mod explain; +mod pack; +mod report; +mod tui; + +use std::path::PathBuf; +use std::process::ExitCode; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use tokio::sync::mpsc; + +use engine::{Event, Status}; +use pack::Pack; + +#[derive(Parser)] +#[command( + name = "auditron", + version, + about = "Terminal compliance copilot on embedded StackQL" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a control pack and watch results live. + Scan { + #[command(flatten)] + run: RunArgs, + /// Print results line by line instead of the TUI (for CI and pipes). + #[arg(long)] + no_tui: bool, + }, + /// Run a control pack and write an auditor-ready evidence zip. + Evidence { + #[command(flatten)] + run: RunArgs, + /// Output path for the evidence zip. + #[arg(long, short)] + out: PathBuf, + }, + /// List available control packs. + Packs, +} + +#[derive(clap::Args)] +struct RunArgs { + /// Builtin pack name (github-core) or path to a pack YAML. + #[arg(long, default_value = "github-core")] + pack: String, + /// Override a pack variable: --var org=your-org (repeatable). + #[arg(long = "var", value_parser = parse_var)] + vars: Vec<(String, String)>, + /// Max rows fetched per control query. + #[arg(long, default_value_t = 1000)] + row_limit: u32, +} + +fn parse_var(s: &str) -> Result<(String, String), String> { + s.split_once('=') + .map(|(k, v)| (k.trim().to_string(), v.to_string())) + .ok_or_else(|| format!("expected key=value, got {s:?}")) +} + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + Err(e) => { + eprintln!("auditron: {e:#}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result { + let cli = Cli::parse(); + match cli.command { + Command::Packs => { + println!("github-core (builtin) - GitHub Organization Security Posture"); + if let Ok(entries) = std::fs::read_dir("controls") { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "yaml" || e == "yml") { + println!("{}", path.display()); + } + } + } + Ok(ExitCode::SUCCESS) + } + Command::Scan { run, no_tui } => { + let pack = Pack::load(&run.pack, &run.vars)?; + if no_tui { + headless(&pack, run.row_limit, None) + } else { + tui::run(pack, run.row_limit) + } + } + Command::Evidence { run, out } => { + let pack = Pack::load(&run.pack, &run.vars)?; + let source = Pack::source(&run.pack)?; + headless(&pack, run.row_limit, Some((out, source))) + } + } +} + +/// Run the scan without a TUI, printing one line per control. With an +/// evidence target, also write the zip. Exit code: 0 all pass, 2 any +/// fail/error, 1 the scan itself could not run. +fn headless(pack: &Pack, row_limit: u32, evidence: Option<(PathBuf, String)>) -> Result { + let runtime = tokio::runtime::Runtime::new().context("starting tokio runtime")?; + let (tx, mut rx) = mpsc::unbounded_channel(); + + let summary = runtime.block_on(async { + let scan = engine::run_scan(pack, row_limit, &tx); + tokio::pin!(scan); + loop { + tokio::select! { + event = rx.recv() => { + if let Some(event) = event { + print_event(pack, &event); + } + } + summary = &mut scan => break summary, + } + } + })?; + // Drain anything emitted between the last poll and completion. + while let Ok(event) = rx.try_recv() { + print_event(pack, &event); + } + + let (passed, failed, errored) = summary.counts(); + println!( + "{}: {passed} passed, {failed} failed, {errored} errored ({} controls)", + pack.id, + summary.results.len() + ); + + if let Some((out, source)) = evidence { + report::write_evidence(&out, pack, &source, &summary, row_limit)?; + println!("evidence pack written to {}", out.display()); + } + + Ok(if failed + errored == 0 { + ExitCode::SUCCESS + } else { + ExitCode::from(2) + }) +} + +fn print_event(pack: &Pack, event: &Event) { + match event { + Event::ServerReady { server_info } => { + eprintln!( + "auditron: stackql {} ready (read_only)", + server_info["version"].as_str().unwrap_or("?") + ); + } + Event::ControlStarted { index } => { + eprintln!("auditron: running {} ...", pack.controls[*index].id); + } + Event::ControlFinished { result, .. } => { + let label = match result.status { + Status::Pass => "PASS ", + Status::Fail => "FAIL ", + Status::Error => "ERROR", + }; + println!( + "[{label}] {} {} ({} rows, {} ms){}", + result.id, + result.title, + result.rows.len(), + result.duration_ms, + result + .error + .as_deref() + .map(|e| format!(" - {e}")) + .unwrap_or_default() + ); + } + Event::Finished => {} + Event::Fatal { message } => eprintln!("auditron: fatal: {message}"), + } +} diff --git a/auditron/src/pack.rs b/auditron/src/pack.rs new file mode 100644 index 0000000..cfb4f10 --- /dev/null +++ b/auditron/src/pack.rs @@ -0,0 +1,179 @@ +//! Control pack schema and loading. +//! +//! Packs are YAML data (controls/*.yaml in the repo): id, description, SQL, +//! pass criteria per control, plus pack-level provider/auth/variables. + +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; + +/// The github fixture pack, embedded so the single binary carries its demo. +const GITHUB_CORE: &str = include_str!("../../controls/github-core.yaml"); + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Pack { + /// Schema marker; only auditron/v1 is accepted. + pub schema: String, + pub id: String, + pub name: String, + #[serde(default)] + pub description: String, + /// Provider to pull before running controls (e.g. "github"). + pub provider: String, + /// Auth document handed to the embedded server as --auth. + pub auth: serde_json::Value, + /// Substituted into control SQL as {{name}}, overridable with --var. + #[serde(default)] + pub variables: BTreeMap, + pub controls: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Control { + pub id: String, + pub title: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub remediation: Option, + pub sql: String, + #[serde(default)] + pub pass_when: PassWhen, +} + +/// Pass criteria for a control's query result. +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PassWhen { + /// Pass when the query returns no rows; returned rows are findings. + #[default] + NoRows, + /// Pass when the query returns rows (evidence-collection controls); + /// returned rows are the evidence. + Rows, +} + +impl Pack { + /// Load a pack by builtin name or filesystem path, then apply --var + /// overrides and render the SQL. + pub fn load(name_or_path: &str, overrides: &[(String, String)]) -> Result { + let yaml = if name_or_path == "github-core" { + GITHUB_CORE.to_string() + } else { + let path = Path::new(name_or_path); + std::fs::read_to_string(path) + .with_context(|| format!("reading control pack {}", path.display()))? + }; + Self::parse(&yaml, overrides) + } + + /// Raw YAML for the pack (for evidence manifests). + pub fn source(name_or_path: &str) -> Result { + if name_or_path == "github-core" { + Ok(GITHUB_CORE.to_string()) + } else { + std::fs::read_to_string(name_or_path) + .with_context(|| format!("reading control pack {name_or_path}")) + } + } + + fn parse(yaml: &str, overrides: &[(String, String)]) -> Result { + let mut pack: Pack = serde_yaml::from_str(yaml).context("parsing control pack YAML")?; + if pack.schema != "auditron/v1" { + bail!( + "unsupported pack schema {:?} (expected auditron/v1)", + pack.schema + ); + } + if pack.controls.is_empty() { + bail!("control pack {} has no controls", pack.id); + } + for (key, value) in overrides { + if !pack.variables.contains_key(key) { + bail!( + "--var {key} is not declared by pack {} (declared: {})", + pack.id, + pack.variables + .keys() + .cloned() + .collect::>() + .join(", ") + ); + } + pack.variables.insert(key.clone(), value.clone()); + } + for control in &mut pack.controls { + control.sql = render(&control.sql, &pack.variables)?; + } + Ok(pack) + } +} + +/// Substitute {{name}} placeholders; unknown placeholders are an error so a +/// typo never reaches the server as literal SQL. +fn render(sql: &str, vars: &BTreeMap) -> Result { + let mut out = sql.to_string(); + for (key, value) in vars { + out = out.replace(&format!("{{{{{key}}}}}"), value); + } + if let Some(start) = out.find("{{") { + let rest = &out[start..]; + let end = rest.find("}}").map(|i| i + 2).unwrap_or(rest.len()); + bail!("undeclared variable {} in control SQL", &rest[..end]); + } + Ok(out.trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_pack_parses_and_renders() { + let pack = Pack::load("github-core", &[]).unwrap(); + assert_eq!(pack.id, "github-core"); + assert_eq!(pack.provider, "github"); + assert!(pack.controls.len() >= 5); + for control in &pack.controls { + assert!( + !control.sql.contains("{{"), + "{} still has placeholders", + control.id + ); + } + assert_eq!(pack.controls.last().unwrap().pass_when, PassWhen::Rows); + } + + #[test] + fn var_overrides_apply() { + let pack = Pack::load("github-core", &[("org".into(), "octocat".into())]).unwrap(); + assert!(pack.controls[1].sql.contains("org = 'octocat'")); + } + + #[test] + fn undeclared_var_override_is_rejected() { + let err = Pack::load("github-core", &[("nope".into(), "x".into())]).unwrap_err(); + assert!(err.to_string().contains("not declared"), "{err}"); + } + + #[test] + fn undeclared_placeholder_in_sql_is_rejected() { + let yaml = r#" +schema: auditron/v1 +id: t +name: T +provider: github +auth: {} +controls: + - id: C1 + title: c + sql: SELECT * FROM x WHERE y = '{{missing}}' +"#; + let err = Pack::parse(yaml, &[]).unwrap_err(); + assert!(err.to_string().contains("{{missing}}"), "{err}"); + } +} diff --git a/auditron/src/report.rs b/auditron/src/report.rs new file mode 100644 index 0000000..647ea7f --- /dev/null +++ b/auditron/src/report.rs @@ -0,0 +1,239 @@ +//! Evidence pack output: a zip with the run manifest, the exact pack source +//! and SQL, and per-control CSVs. The point is re-runnability - an auditor +//! can re-execute the same pack and compare. + +use std::collections::BTreeSet; +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; +use zip::write::SimpleFileOptions; + +use crate::engine::{ControlResult, RunSummary, Status}; +use crate::pack::Pack; + +/// Identity of the machine/user that collected the evidence. +#[derive(serde::Serialize)] +struct Collector { + user: String, + hostname: String, + auditron_version: String, +} + +fn collector() -> Collector { + let env_any = |keys: &[&str]| { + keys.iter() + .find_map(|k| std::env::var(*k).ok().filter(|v| !v.is_empty())) + .unwrap_or_else(|| "unknown".to_string()) + }; + let mut hostname = env_any(&["HOSTNAME", "COMPUTERNAME"]); + if hostname == "unknown" { + if let Ok(contents) = std::fs::read_to_string("/etc/hostname") { + let trimmed = contents.trim(); + if !trimmed.is_empty() { + hostname = trimmed.to_string(); + } + } + } + Collector { + user: env_any(&["USER", "USERNAME"]), + hostname, + auditron_version: env!("CARGO_PKG_VERSION").to_string(), + } +} + +/// Write the evidence zip for a finished run. +pub fn write_evidence( + out: &Path, + pack: &Pack, + pack_source: &str, + summary: &RunSummary, + row_limit: u32, +) -> Result<()> { + let file = std::fs::File::create(out) + .with_context(|| format!("creating evidence pack {}", out.display()))?; + let mut zip = zip::ZipWriter::new(file); + let opts = SimpleFileOptions::default(); + + let (passed, failed, errored) = summary.counts(); + let manifest = serde_json::json!({ + "schema": "auditron-evidence/v1", + "run": { + "started_at": summary.started_at, + "finished_at": summary.finished_at, + "mode": "read_only", + "row_limit": row_limit, + "passed": passed, + "failed": failed, + "errored": errored, + }, + "pack": { + "id": pack.id, + "name": pack.name, + "description": pack.description, + "provider": pack.provider, + "variables": pack.variables, + "sha256": hex(&Sha256::digest(pack_source.as_bytes())), + }, + "collector": collector(), + "server": summary.server_info, + "results": summary.results.iter().map(|r| serde_json::json!({ + "id": r.id, + "title": r.title, + "status": r.status, + "rows": r.rows.len(), + "duration_ms": r.duration_ms, + "started_at": r.started_at, + "finished_at": r.finished_at, + "error": r.error, + })).collect::>(), + }); + + zip.start_file("manifest.json", opts)?; + zip.write_all(serde_json::to_string_pretty(&manifest)?.as_bytes())?; + + // The exact pack that ran, for re-execution. + zip.start_file("pack.yaml", opts)?; + zip.write_all(pack_source.as_bytes())?; + + zip.start_file("summary.csv", opts)?; + zip.write_all(summary_csv(&summary.results)?.as_bytes())?; + + for result in &summary.results { + zip.start_file(format!("controls/{}.sql", result.id), opts)?; + zip.write_all(result.sql.as_bytes())?; + zip.write_all(b"\n")?; + + zip.start_file(format!("controls/{}.csv", result.id), opts)?; + zip.write_all(rows_csv(result)?.as_bytes())?; + } + + zip.finish()?; + Ok(()) +} + +fn summary_csv(results: &[ControlResult]) -> Result { + let mut w = csv::Writer::from_writer(Vec::new()); + w.write_record([ + "id", + "title", + "status", + "rows", + "duration_ms", + "started_at", + "finished_at", + "error", + ])?; + for r in results { + w.write_record([ + r.id.as_str(), + r.title.as_str(), + status_str(r.status), + &r.rows.len().to_string(), + &r.duration_ms.to_string(), + r.started_at.as_str(), + r.finished_at.as_str(), + r.error.as_deref().unwrap_or(""), + ])?; + } + Ok(String::from_utf8(w.into_inner()?)?) +} + +/// Findings/evidence rows as CSV. Header is the union of keys across rows +/// (BTreeSet keeps column order deterministic). +fn rows_csv(result: &ControlResult) -> Result { + let columns: BTreeSet<&str> = result + .rows + .iter() + .flat_map(|row| row.keys().map(String::as_str)) + .collect(); + let mut w = csv::Writer::from_writer(Vec::new()); + w.write_record(&columns)?; + for row in &result.rows { + w.write_record( + columns + .iter() + .map(|c| row.get(*c).map(String::as_str).unwrap_or("")), + )?; + } + Ok(String::from_utf8(w.into_inner()?)?) +} + +pub fn status_str(status: Status) -> &'static str { + match status { + Status::Pass => "pass", + Status::Fail => "fail", + Status::Error => "error", + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::Row; + + fn result_with_rows() -> ControlResult { + let mut row1 = Row::new(); + row1.insert("name".into(), "repo-a".into()); + row1.insert("visibility".into(), "public".into()); + let mut row2 = Row::new(); + row2.insert("name".into(), "repo-b".into()); + ControlResult { + id: "GH-003".into(), + title: "Repos have a description".into(), + status: Status::Fail, + sql: "SELECT 1".into(), + rows: vec![row1, row2], + error: None, + started_at: "2026-06-13T00:00:00Z".into(), + finished_at: "2026-06-13T00:00:01Z".into(), + duration_ms: 1000, + } + } + + #[test] + fn rows_csv_uses_union_header_and_blank_gaps() { + let csv = rows_csv(&result_with_rows()).unwrap(); + let mut lines = csv.lines(); + assert_eq!(lines.next(), Some("name,visibility")); + assert_eq!(lines.next(), Some("repo-a,public")); + assert_eq!(lines.next(), Some("repo-b,")); + } + + #[test] + fn evidence_zip_contains_expected_entries() { + let pack = Pack::load("github-core", &[]).unwrap(); + let source = Pack::source("github-core").unwrap(); + let summary = RunSummary { + started_at: "2026-06-13T00:00:00Z".into(), + finished_at: "2026-06-13T00:00:05Z".into(), + server_info: serde_json::json!({"version": "0.10.500"}), + results: vec![result_with_rows()], + }; + let out = std::env::temp_dir().join(format!("auditron-test-{}.zip", std::process::id())); + write_evidence(&out, &pack, &source, &summary, 1000).unwrap(); + + let mut archive = zip::ZipArchive::new(std::fs::File::open(&out).unwrap()).unwrap(); + let names: Vec = (0..archive.len()) + .map(|i| archive.by_index(i).unwrap().name().to_string()) + .collect(); + for expected in [ + "manifest.json", + "pack.yaml", + "summary.csv", + "controls/GH-003.sql", + "controls/GH-003.csv", + ] { + assert!( + names.contains(&expected.to_string()), + "missing {expected} in {names:?}" + ); + } + std::fs::remove_file(&out).unwrap(); + } +} diff --git a/auditron/src/tui.rs b/auditron/src/tui.rs new file mode 100644 index 0000000..0b3fdea --- /dev/null +++ b/auditron/src/tui.rs @@ -0,0 +1,439 @@ +//! Live scan TUI: a control table streaming pass/fail/error as results +//! arrive, with a detail pane showing the SQL that produced each finding and +//! agent-drafted explanations on demand. + +use std::process::ExitCode; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use crossterm::event::{Event as TermEvent, KeyCode, KeyEventKind}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Wrap}; +use ratatui::Frame; +use tokio::sync::mpsc; + +use crate::engine::{self, ControlResult, Event, Status}; +use crate::explain::{ClaudeExplainer, Explainer}; +use crate::pack::Pack; + +const SPINNER: [&str; 4] = ["|", "/", "-", "\\"]; + +enum ControlState { + Pending, + Running, + Done(ControlResult), +} + +enum ExplainState { + None, + Loading, + Ready(String), + Failed(String), +} + +enum UiMsg { + Engine(Event), + Explain { + index: usize, + result: std::result::Result, + }, +} + +struct App { + pack: Pack, + states: Vec, + explanations: Vec, + table: TableState, + server_version: Option, + fatal: Option, + finished: bool, + quit: bool, + tick: usize, + detail_scroll: u16, +} + +impl App { + fn new(pack: Pack) -> App { + let n = pack.controls.len(); + let mut table = TableState::default(); + table.select(Some(0)); + App { + pack, + states: (0..n).map(|_| ControlState::Pending).collect(), + explanations: (0..n).map(|_| ExplainState::None).collect(), + table, + server_version: None, + fatal: None, + finished: false, + quit: false, + tick: 0, + detail_scroll: 0, + } + } + + fn selected(&self) -> usize { + self.table.selected().unwrap_or(0) + } + + fn apply(&mut self, msg: UiMsg) { + match msg { + UiMsg::Engine(Event::ServerReady { server_info }) => { + self.server_version = server_info["version"].as_str().map(String::from); + } + UiMsg::Engine(Event::ControlStarted { index }) => { + self.states[index] = ControlState::Running; + } + UiMsg::Engine(Event::ControlFinished { index, result }) => { + self.states[index] = ControlState::Done(result); + } + UiMsg::Engine(Event::Finished) => self.finished = true, + UiMsg::Engine(Event::Fatal { message }) => { + self.fatal = Some(message); + self.finished = true; + } + UiMsg::Explain { index, result } => { + self.explanations[index] = match result { + Ok(text) => ExplainState::Ready(text), + Err(e) => ExplainState::Failed(e), + }; + } + } + } + + fn move_selection(&mut self, delta: isize) { + let n = self.pack.controls.len() as isize; + let next = (self.selected() as isize + delta).rem_euclid(n); + self.table.select(Some(next as usize)); + self.detail_scroll = 0; + } + + fn exit_code(&self) -> ExitCode { + if self.fatal.is_some() { + return ExitCode::FAILURE; + } + let bad = self + .states + .iter() + .any(|s| matches!(s, ControlState::Done(r) if r.status != Status::Pass)); + if bad { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + } + } +} + +pub fn run(pack: Pack, row_limit: u32) -> Result { + let runtime = tokio::runtime::Runtime::new().context("starting tokio runtime")?; + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // Engine task: forward scan events into the UI channel. + let (etx, mut erx) = mpsc::unbounded_channel::(); + let scan_pack = pack.clone(); + runtime.spawn(async move { + let _ = engine::run_scan(&scan_pack, row_limit, &etx).await; + }); + let fwd_tx = tx.clone(); + runtime.spawn(async move { + while let Some(event) = erx.recv().await { + if fwd_tx.send(UiMsg::Engine(event)).is_err() { + break; + } + } + }); + + let explainer: Option> = ClaudeExplainer::from_env().map(Arc::new); + let mut app = App::new(pack); + + let mut terminal = ratatui::init(); + let loop_result = (|| -> Result<()> { + loop { + while let Ok(msg) = rx.try_recv() { + app.apply(msg); + } + terminal.draw(|frame| draw(frame, &mut app, explainer.is_some()))?; + app.tick = app.tick.wrapping_add(1); + + if crossterm::event::poll(Duration::from_millis(80))? { + if let TermEvent::Key(key) = crossterm::event::read()? { + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => app.quit = true, + KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1), + KeyCode::Down | KeyCode::Char('j') => app.move_selection(1), + KeyCode::PageUp => app.detail_scroll = app.detail_scroll.saturating_sub(5), + KeyCode::PageDown => { + app.detail_scroll = app.detail_scroll.saturating_add(5) + } + KeyCode::Char('e') => { + request_explanation(&runtime, &tx, &mut app, &explainer) + } + _ => {} + } + } + } + if app.quit { + break; + } + } + Ok(()) + })(); + ratatui::restore(); + loop_result?; + + Ok(app.exit_code()) +} + +fn request_explanation( + runtime: &tokio::runtime::Runtime, + tx: &mpsc::UnboundedSender, + app: &mut App, + explainer: &Option>, +) { + let index = app.selected(); + let Some(explainer) = explainer else { + app.explanations[index] = + ExplainState::Failed("set ANTHROPIC_API_KEY to enable agent explanations".into()); + return; + }; + if matches!( + app.explanations[index], + ExplainState::Loading | ExplainState::Ready(_) + ) { + return; + } + let ControlState::Done(result) = &app.states[index] else { + return; + }; + app.explanations[index] = ExplainState::Loading; + let explainer = Arc::clone(explainer); + let control = app.pack.controls[index].clone(); + let result = result.clone(); + let tx = tx.clone(); + runtime.spawn_blocking(move || { + let outcome = explainer + .explain(&control, &result) + .map_err(|e| format!("{e:#}")); + let _ = tx.send(UiMsg::Explain { + index, + result: outcome, + }); + }); +} + +fn draw(frame: &mut Frame, app: &mut App, has_explainer: bool) { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(app.pack.controls.len() as u16 + 3), + Constraint::Min(5), + Constraint::Length(1), + ]) + .split(frame.area()); + + draw_header(frame, app, outer[0]); + draw_table(frame, app, outer[1]); + draw_detail(frame, app, outer[2]); + draw_footer(frame, app, has_explainer, outer[3]); +} + +fn draw_header(frame: &mut Frame, app: &App, area: Rect) { + let (mut pass, mut fail, mut error, mut done) = (0, 0, 0, 0); + for state in &app.states { + if let ControlState::Done(r) = state { + done += 1; + match r.status { + Status::Pass => pass += 1, + Status::Fail => fail += 1, + Status::Error => error += 1, + } + } + } + let progress = if app.fatal.is_some() { + Span::styled( + "FATAL", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ) + } else if app.finished { + Span::styled("done", Style::default().fg(Color::Green)) + } else { + Span::raw(format!( + "{} {}/{}", + SPINNER[app.tick % SPINNER.len()], + done, + app.states.len() + )) + }; + let line = Line::from(vec![ + Span::styled(" auditron ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!( + "| {} | stackql {} | read_only | ", + app.pack.name, + app.server_version.as_deref().unwrap_or("starting...") + )), + progress, + Span::raw(format!(" pass {pass} fail {fail} error {error}")), + ]); + frame.render_widget(Paragraph::new(line), area); +} + +fn status_cell(app: &App, state: &ControlState) -> Cell<'static> { + match state { + ControlState::Pending => Cell::from("-").style(Style::default().fg(Color::DarkGray)), + ControlState::Running => Cell::from(SPINNER[app.tick % SPINNER.len()].to_string()) + .style(Style::default().fg(Color::Cyan)), + ControlState::Done(r) => match r.status { + Status::Pass => Cell::from("PASS").style(Style::default().fg(Color::Green)), + Status::Fail => Cell::from("FAIL") + .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Status::Error => Cell::from("ERROR").style(Style::default().fg(Color::Yellow)), + }, + } +} + +fn draw_table(frame: &mut Frame, app: &mut App, area: Rect) { + let rows: Vec = app + .pack + .controls + .iter() + .zip(&app.states) + .map(|(control, state)| { + let (rows_text, ms_text) = match state { + ControlState::Done(r) => { + (r.rows.len().to_string(), format!("{} ms", r.duration_ms)) + } + _ => (String::new(), String::new()), + }; + Row::new(vec![ + Cell::from(control.id.clone()), + status_cell(app, state), + Cell::from(control.title.clone()), + Cell::from(rows_text), + Cell::from(ms_text), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(8), + Constraint::Length(6), + Constraint::Min(30), + Constraint::Length(6), + Constraint::Length(9), + ], + ) + .header( + Row::new(vec!["id", "status", "control", "rows", "time"]) + .style(Style::default().add_modifier(Modifier::BOLD)), + ) + .block(Block::default().borders(Borders::ALL).title("controls")) + .row_highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + + frame.render_stateful_widget(table, area, &mut app.table); +} + +fn draw_detail(frame: &mut Frame, app: &App, area: Rect) { + let index = app.selected(); + let control = &app.pack.controls[index]; + let mut lines: Vec = Vec::new(); + + if let Some(fatal) = &app.fatal { + lines.push(Line::styled( + format!("scan failed: {fatal}"), + Style::default().fg(Color::Red), + )); + } + lines.push(Line::styled( + format!("{} - {}", control.id, control.title), + Style::default().add_modifier(Modifier::BOLD), + )); + if !control.description.is_empty() { + lines.push(Line::raw(control.description.trim().to_string())); + } + lines.push(Line::raw("")); + // The SQL that produced the finding is always displayed. + lines.push(Line::styled("sql:", Style::default().fg(Color::Cyan))); + for sql_line in control.sql.lines() { + lines.push(Line::raw(format!(" {sql_line}"))); + } + + if let ControlState::Done(result) = &app.states[index] { + if let Some(error) = &result.error { + lines.push(Line::raw("")); + lines.push(Line::styled( + format!("error: {error}"), + Style::default().fg(Color::Yellow), + )); + } else if !result.rows.is_empty() { + lines.push(Line::raw("")); + lines.push(Line::styled( + format!("rows ({}):", result.rows.len()), + Style::default().fg(Color::Cyan), + )); + for row in result.rows.iter().take(10) { + lines.push(Line::raw(format!( + " {}", + serde_json::to_string(row).unwrap_or_default() + ))); + } + if result.rows.len() > 10 { + lines.push(Line::raw(format!(" ... {} more", result.rows.len() - 10))); + } + } + } + + match &app.explanations[index] { + ExplainState::None => {} + ExplainState::Loading => { + lines.push(Line::raw("")); + lines.push(Line::styled( + format!("{} asking the agent...", SPINNER[app.tick % SPINNER.len()]), + Style::default().fg(Color::Magenta), + )); + } + ExplainState::Ready(text) => { + lines.push(Line::raw("")); + lines.push(Line::styled("agent:", Style::default().fg(Color::Magenta))); + for explain_line in text.lines() { + lines.push(Line::raw(format!(" {explain_line}"))); + } + } + ExplainState::Failed(error) => { + lines.push(Line::raw("")); + lines.push(Line::styled( + format!("explain failed: {error}"), + Style::default().fg(Color::Yellow), + )); + } + } + + let detail = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title("detail")) + .wrap(Wrap { trim: false }) + .scroll((app.detail_scroll, 0)); + frame.render_widget(detail, area); +} + +fn draw_footer(frame: &mut Frame, app: &App, has_explainer: bool, area: Rect) { + let explain_hint = if has_explainer { + "e explain" + } else if matches!(app.explanations[app.selected()], ExplainState::Failed(_)) { + "e explain (needs ANTHROPIC_API_KEY)" + } else { + "e explain (set ANTHROPIC_API_KEY)" + }; + let footer = format!(" q quit | up/down select | pgup/pgdn scroll | {explain_hint}"); + frame.render_widget( + Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)), + area, + ); +} diff --git a/controls/github-core.yaml b/controls/github-core.yaml new file mode 100644 index 0000000..830810b --- /dev/null +++ b/controls/github-core.yaml @@ -0,0 +1,127 @@ +# auditron control pack: GitHub organization security posture. +# +# Runs against public org data using the github provider in null_auth mode, +# so it needs zero credentials. Unauthenticated GitHub API calls are rate +# limited to 60/hour per IP; this pack stays well under that. +# +# Authoring notes (stackql github provider): +# - All result values arrive as strings; booleans render as 'true'/'false' +# - Boolean storage varies per resource: compare with = 0 / = 1, never with +# the strings 'true'/'false' (repos.archived = 'false' matches nothing) +# - Avoid OR and NOT in WHERE clauses (predicate pushdown quirks); use +# coalesce() and = 0 instead +# - IN (...) on key columns fans out to one API call per value +# - SQLite date functions work: datetime('now', '-12 months') +schema: auditron/v1 +id: github-core +name: GitHub Organization Security Posture +description: >- + Point-in-time posture checks over public GitHub organization data. + Runs unauthenticated (null_auth); suitable as a demo and as a baseline + org hygiene pack. +provider: github +auth: + github: + type: null_auth +variables: + # Override per run: auditron scan --var org=your-org + org: stackql + # SQL list fragment: repos whose default branch must be protected. + critical_repos: "'stackql', 'stackql-deploy'" +controls: + - id: GH-001 + title: Critical repos have default branch protection + description: >- + The default branch of designated critical repositories must have + branch protection enabled. + remediation: >- + Enable branch protection (or a ruleset) on the default branch: + Settings -> Branches -> Add branch protection rule. Require pull + request reviews and status checks before merging. + sql: | + SELECT repo, name AS branch, protected + FROM github.repos.branches + WHERE owner = '{{org}}' + AND repo IN ({{critical_repos}}) + AND name IN ('main', 'master') + AND protected = 0 + pass_when: no_rows + + - id: GH-002 + title: Default branch is main + description: >- + Repositories should use 'main' as the default branch for consistency + with org tooling and branch protection rulesets. + remediation: >- + Rename the default branch: Settings -> General -> Default branch. + GitHub retargets open PRs automatically. + sql: | + SELECT name, default_branch + FROM github.repos.repos + WHERE org = '{{org}}' + AND archived = 0 + AND default_branch <> 'main' + pass_when: no_rows + + - id: GH-003 + title: Repos have a description + description: >- + Public repositories must carry a description so consumers can + identify supported projects. + remediation: >- + Add a one-line description in Settings -> General, or via + 'gh repo edit --description'. + sql: | + SELECT name, visibility + FROM github.repos.repos + WHERE org = '{{org}}' + AND archived = 0 + AND coalesce(description, '') = '' + pass_when: no_rows + + - id: GH-004 + title: Public repos declare a license + description: >- + Public, non-archived repositories must declare a license; unlicensed + public code is a legal risk for downstream users. + remediation: >- + Add a LICENSE file (Add file -> Create new file -> name it LICENSE + and pick a template), or archive the repo if it is not maintained. + sql: | + SELECT name, visibility + FROM github.repos.repos + WHERE org = '{{org}}' + AND archived = 0 + AND visibility = 'public' + AND license IS NULL + pass_when: no_rows + + - id: GH-005 + title: No stale active repos + description: >- + Repositories not updated in 12 months should be archived so they are + read-only and clearly unmaintained. + remediation: >- + Archive the repository: Settings -> General -> Archive this + repository. Archiving is reversible. + sql: | + SELECT name, updated_at + FROM github.repos.repos + WHERE org = '{{org}}' + AND archived = 0 + AND updated_at < datetime('now', '-12 months') + pass_when: no_rows + + - id: GH-006 + title: Repository inventory captured + description: >- + Evidence control: a point-in-time inventory of all organization + repositories with visibility and freshness, for the audit record. + Doubles as the run's canary: provider errors (e.g. API rate limits) + surface as empty result sets, which would silently pass no_rows + controls - but they fail this one, flagging the whole run. + sql: | + SELECT name, visibility, default_branch, archived, updated_at + FROM github.repos.repos + WHERE org = '{{org}}' + pass_when: rows diff --git a/examples/fetch_bundle.rs b/examples/fetch_bundle.rs new file mode 100644 index 0000000..8eb2824 --- /dev/null +++ b/examples/fetch_bundle.rs @@ -0,0 +1,13 @@ +//! Fetch the pinned platform .mcpb into the shared cache and print its path. +//! The producer step for vendored builds: +//! +//! ```text +//! BUNDLE=$(cargo run --example fetch_bundle) +//! STACKQL_MCP_BUNDLE_FILE=$BUNDLE cargo build -p auditron --features vendored --release +//! ``` + +fn main() -> Result<(), Box> { + let path = stackql_mcp::fetch_bundle()?; + println!("{}", path.display()); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 673a80f..d2fb26e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,6 +79,50 @@ impl Mode { } } +/// Download the pinned .mcpb bundle for the host platform into the shared +/// cache (verified against the baked sha256 pin) and return its path. Skips +/// the download when a verified copy is already present. +/// +/// This is the producer side of vendored builds: fetch the bundle once on the +/// build machine, then embed it with [`include_bundle!`]. +#[cfg(feature = "sidecar")] +pub fn fetch_bundle() -> Result { + let platform = Platform::detect()?; + let pin = pins::pin_for(platform)?; + let dest = cache::bin_cache_root()? + .join(pins::STACKQL_VERSION) + .join(pin.bundle_name); + if dest.is_file() && download::sha256_file(&dest)? == pin.sha256 { + return Ok(dest); + } + download::download_verified(&pins::bundle_url(pin), pin.sha256, &dest)?; + Ok(dest) +} + +/// Embed the .mcpb bundle named by the compile-time env var +/// `STACKQL_MCP_BUNDLE_FILE`, for use with `Builder::bundle_bytes` (vendored +/// feature): +/// +/// ```ignore +/// let server = StackqlMcp::builder() +/// .bundle_bytes(stackql_mcp::include_bundle!()) +/// .start() +/// .await?; +/// ``` +/// +/// Build with `STACKQL_MCP_BUNDLE_FILE=/abs/path/to/bundle.mcpb cargo build`. +/// Pair with [`fetch_bundle`] to produce the bundle. +#[macro_export] +macro_rules! include_bundle { + () => { + include_bytes!(env!( + "STACKQL_MCP_BUNDLE_FILE", + "set STACKQL_MCP_BUNDLE_FILE to the absolute path of the platform .mcpb bundle \ + (see stackql_mcp::fetch_bundle)" + )) + }; +} + /// Entry point. See the crate docs for the full example. pub struct StackqlMcp; From 61db835706f2a7c5e864c6ff82e74c07e641eab3 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Sat, 13 Jun 2026 11:13:52 +1000 Subject: [PATCH 3/4] Prepare crates.io release: package metadata, badges, launch kit - readme field and docs.rs all-features metadata; exclude the auditron demo, control packs, and launch kit from the published package (verified with cargo package --list) - README badges for crates.io, docs.rs, and CI - pr.md launch kit: docusaurus blog draft, r/rust and This Week in Rust posts, and a Rust meetup talk synopsis Co-Authored-By: Claude Fable 5 --- .gitignore | 2 + Cargo.toml | 6 +- README.md | 4 + pr.md | 261 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 pr.md diff --git a/.gitignore b/.gitignore index ad67955..8f18702 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +tmp/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 7e05d1d..a4ddd94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,13 @@ description = "Embedded StackQL MCP server for Rust agentic apps - cloud queries license = "MIT" repository = "https://github.com/stackql/stackql-mcp-rs" documentation = "https://docs.rs/stackql-mcp" +readme = "README.md" keywords = ["mcp", "stackql", "sql", "cloud", "agents"] categories = ["api-bindings", "development-tools"] -exclude = [".github/", "CLAUDE.md"] +exclude = [".github/", "CLAUDE.md", "auditron/", "controls/", "pr.md"] + +[package.metadata.docs.rs] +all-features = true [features] default = ["sidecar"] diff --git a/README.md b/README.md index 8dcd5b6..99938fa 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # stackql-mcp +[![crates.io](https://img.shields.io/crates/v/stackql-mcp.svg)](https://crates.io/crates/stackql-mcp) +[![docs.rs](https://docs.rs/stackql-mcp/badge.svg)](https://docs.rs/stackql-mcp) +[![ci](https://github.com/stackql/stackql-mcp-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/stackql/stackql-mcp-rs/actions/workflows/ci.yml) + Embedded [StackQL](https://stackql.io) MCP server for Rust agentic apps. StackQL exposes cloud providers (AWS, GitHub, Google, Azure, and more) as SQL tables; this crate acquires the `stackql` binary, launches it as an MCP server over stdio, and hands you a connected [rmcp](https://crates.io/crates/rmcp) client. ## Quickstart diff --git a/pr.md b/pr.md new file mode 100644 index 0000000..871bb5e --- /dev/null +++ b/pr.md @@ -0,0 +1,261 @@ +# stackql-mcp launch kit + +Working drafts for the crates.io launch of `stackql-mcp` and the auditron demo. Blog publishes first; the Reddit and This Week in Rust posts point back to it. + +Placeholders to fill before posting: + +- `BLOG_URL` - final URL of the blog post on stackql.io +- `ASCIINEMA_URL` - recording of the auditron TUI scan + +## a) Blog post (Docusaurus) + +Suggested path: `blog/2026-06-embedded-mcp-rust.md` + +~~~markdown +--- +title: An embedded StackQL MCP server for Rust agents +description: Query and provision cloud infrastructure over SQL from inside your Rust agent process, with no external server to deploy. Plus auditron, a compliance copilot in a single binary. +authors: [jeffreyaven] +tags: [mcp, rust, agents, compliance] +--- + +StackQL exposes cloud and SaaS providers (AWS, GitHub, Google, Azure, and +more) as SQL tables: `SELECT` to query your estate, `INSERT`/`DELETE` to +provision and tear down. The StackQL MCP server makes that capability +available to AI agents over the Model Context Protocol. + +Today we are releasing [stackql-mcp](https://crates.io/crates/stackql-mcp), +a Rust crate that embeds that server inside your agent process. No +deployment, no sidecar to operate, no network dependency at runtime if you +choose so. It joins the npm and PyPI wrappers in the embedded MCP family, +sharing the same binary cache and the same launch contract. + + + +## Why embedded + +MCP servers are usually deployed as standalone processes that agents +connect to. That is the right shape for shared, multi-tenant capability, +but it is a poor fit for distributable agent software: your users now have +two things to install, version, and secure. + +The embedded model inverts this. Your Rust binary owns the server +lifecycle: it acquires the `stackql` binary, launches it as a child over +stdio, completes the MCP handshake, and hands you a connected client from +[rmcp](https://crates.io/crates/rmcp), the official Rust MCP SDK. The +server is an implementation detail of your app. + +```rust +use stackql_mcp::{Mode, StackqlMcp}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let server = StackqlMcp::builder() + .mode(Mode::ReadOnly) + .auth(serde_json::json!({"github": {"type": "null_auth"}})) + .start() + .await?; + let tools = server.list_all_tools().await?; + println!("{} tools available", tools.len()); + server.shutdown().await?; + Ok(()) +} +``` + +Two acquisition modes sit behind that one API: + +- **Sidecar** (the default): the platform's `.mcpb` bundle is downloaded at + first run, verified against sha256 pins baked into the crate at release + time, and cached under `~/.stackql/mcp-server-bin/` - the same cache the + npm and PyPI wrappers use. Subsequent starts are offline. +- **Vendored**: the bundle is embedded in your binary with + `include_bytes!` and extracted on first run. No network at runtime, one + artifact to ship. + +```rust +let server = StackqlMcp::builder() + .bundle_bytes(stackql_mcp::include_bundle!()) + .start() + .await?; +``` + +Safety is a first-class part of the contract. The server enforces a mode +per session (`read_only`, `safe`, `delete_safe`, `full_access`) and the +crate defaults to the most restrictive; escalation is an explicit caller +opt-in. + +## auditron: an auditor in a single binary + +To show what the vendored mode is for, the repo ships +[auditron](https://github.com/stackql/stackql-mcp-rs/tree/main/auditron), +a terminal compliance copilot. The business problem: compliance engineers +run point-in-time control checks and need auditor-ready evidence, and the +usual answer involves cloud consoles and screenshots. + +auditron runs a YAML control pack (id, description, SQL, pass criteria per +control) through the embedded server in read-only mode and streams +pass/fail/error into a ratatui table as results arrive. The SQL that +produced each finding is always on screen. Select a finding and press `e` +and Claude explains it and drafts remediation steps. + +[ASCIINEMA_URL] + +Then there is the part the auditor actually wants: + +```sh +auditron evidence --out evidence-2026-06.zip +``` + +The zip contains the run manifest (pack checksum, collector identity, +server version, timings), the exact pack source and per-control SQL, and +per-control CSVs. Another auditor can re-run the same pack against the +same estate and diff the results. + +The demo pack checks GitHub org security posture (branch protection, +default branches, licenses, staleness) using the github provider in +`null_auth` mode, so it runs against public org data with zero +credentials. Point it at your own org: + +```sh +auditron scan --var org=your-org +``` + +Control packs are data, not code. They live in the repo as +`controls/*.yaml` and are community extensible. + +## Lessons from building on live cloud APIs + +Two things we hit while building the demo are worth knowing if you build +on this stack: + +First, MCP tool results from the server carry the typed payload in +`structuredContent` and a markdown rendering in the text content. Read the +structured payload; parsing the markdown is a mistake we made so you do +not have to. + +Second, provider API errors (rate limits especially) can surface as empty +result sets rather than tool errors. For a compliance tool, where "no rows" +means "compliant", that distinction is everything. The github pack includes +an inventory control that must return rows, doubling as a canary for the +whole run; we have also raised the underlying behavior upstream. + +## Getting started + +```sh +cargo add stackql-mcp +``` + +The crate is on [crates.io](https://crates.io/crates/stackql-mcp) with API +docs on [docs.rs](https://docs.rs/stackql-mcp). The source, the auditron +demo, and the control packs are at +[stackql/stackql-mcp-rs](https://github.com/stackql/stackql-mcp-rs), and +the engine itself lives at +[stackql/stackql](https://github.com/stackql/stackql). Issues and control +pack contributions welcome. +~~~ + +## b) Community posts + +### Reddit (r/rust) + +Title: + +~~~text +stackql-mcp: embed a cloud-querying MCP server in your Rust agent (and auditron, a compliance TUI in a single 80MB binary) +~~~ + +Body: + +~~~markdown +We just published [stackql-mcp](https://crates.io/crates/stackql-mcp), a +crate that embeds the StackQL MCP server (cloud infrastructure as SQL: +AWS, GitHub, Google, Azure...) inside a Rust process. Builder API in, +connected [rmcp](https://crates.io/crates/rmcp) client out: + + let server = StackqlMcp::builder() + .mode(Mode::ReadOnly) + .auth(json!({"github": {"type": "null_auth"}})) + .start() + .await?; + +Things r/rust might find interesting: + +- Two acquisition modes behind one API: sidecar (download at first run, + sha256-verified against pins baked into the crate, shared cache) and + vendored (`include_bytes!` the server bundle, extract on first run). + The vendored mode is the fun one: your agent plus a full SQL engine for + cloud APIs in one shippable binary, no network needed at runtime. +- The demo app, auditron, is a ratatui compliance copilot: it streams + control checks (defined as YAML with SQL and pass criteria) into a live + table, always shows the SQL behind a finding, has Claude draft + remediation steps on demand, and exports an auditor-ready evidence zip + (manifest, exact SQL, per-control CSVs - re-runnable by an auditor). + The github demo pack runs unauthenticated against public org data, so + `cargo run -p auditron -- scan` works with zero credentials. +- Dependency surface is deliberately small: rmcp, ureq, zip, sha2, + serde. Server modes (read_only by default) are enforced server-side, + not by the client promising to behave. + +Blog with the design rationale: BLOG_URL +Repo (crate + demo + control packs): https://github.com/stackql/stackql-mcp-rs +The engine: https://github.com/stackql/stackql + +Feedback welcome, especially on the builder API surface and the vendored +build flow (compile-time env var + `include_bundle!` macro - we went back +and forth on build.rs vs explicit pipeline and chose explicit). +~~~ + +### This Week in Rust + +For the "Project/Tooling Updates" section (PR adding a line to the next +draft in [rust-lang/this-week-in-rust](https://github.com/rust-lang/this-week-in-rust)): + +~~~markdown +* [stackql-mcp](https://github.com/stackql/stackql-mcp-rs) - a new crate + embedding the StackQL MCP server (query AWS, GitHub, Google and more + over SQL) in Rust agentic apps, with sha256-pinned sidecar download or + a fully vendored single-binary mode; ships with + [auditron](BLOG_URL), a ratatui compliance copilot demo that produces + auditor-ready evidence packs +~~~ + +## c) Meetup talk synopsis + +Title: + +~~~text +An auditor in a single binary: embedded MCP backends for native Rust agents +~~~ + +Abstract (~150 words): + +~~~text +AI agents are mostly wired together from hosted services: a model API +here, an MCP server deployed there. This talk makes the case for the +opposite shape: a native Rust agent that ships as one binary and owns its +entire backend. + +We build up in three layers. First, the anatomy of a native Rust agent: +tokio, the official rmcp MCP SDK, and a child process speaking MCP over +stdio. Second, the embedded backend: stackql-mcp, a crate that embeds +StackQL (cloud infrastructure as SQL) with two acquisition modes - +sha256-pinned sidecar download, or a server bundle vendored into the +binary with include_bytes!, including what it takes to make extraction, +caching, and process lifecycle boring and reliable. Third, a real agent +on top: auditron, a terminal compliance copilot that runs SQL control +packs against live cloud APIs, explains findings with an LLM, and emits +re-runnable, auditor-ready evidence zips. + +Live demo: zero credentials, one binary, real findings. +~~~ + +Talk notes (not for publication): + +- Demo flow: `auditron scan` TUI against a public GitHub org (null_auth, + no setup on conference wifi beyond GitHub API reachability; mind the + 60 req/h unauthenticated rate limit - warm the cache or bring a token) +- War stories section maps to the upstream issues: markdown vs + structuredContent, boolean typing across resources, errors surfacing as + empty result sets and the canary-control mitigation +- Closing slide: cargo add stackql-mcp, repo links, control packs as the + community contribution surface From 26c9d495657c29e1c4a43e2733a5945c9dceb4f7 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Sat, 13 Jun 2026 11:48:29 +1000 Subject: [PATCH 4/4] minor ci updates --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2983436..f4376a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -29,7 +29,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: unit tests (workspace)