diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f08bf39 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @rustaceans diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a613546 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + pull_request: + +jobs: + build-test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + env: + PYO3_PYTHON: python + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Install just + if: runner.os == 'Windows' + uses: taiki-e/install-action@v2 + with: + tool: just + - name: Set OpenSSL env vars (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $candidates = @("C:\Program Files\OpenSSL-Win64", "C:\Program Files\OpenSSL", "C:\OpenSSL-Win64") + $opensslDir = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $opensslDir) { + choco install openssl -y --no-progress + $opensslDir = "C:\Program Files\OpenSSL-Win64" + } + $libDir = if (Test-Path "$opensslDir\lib\VC\x64\MD") { "$opensslDir\lib\VC\x64\MD" } else { "$opensslDir\lib" } + "OPENSSL_DIR=$opensslDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "OPENSSL_LIB_DIR=$libDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "OPENSSL_INCLUDE_DIR=$opensslDir\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y pkg-config nettle-dev + - name: Install macOS dependencies + if: runner.os == 'macOS' + run: brew install python@3.12 nettle pkg-config + - name: Set Python for PyO3 (macOS) + if: runner.os == 'macOS' + run: echo "PYO3_PYTHON=/opt/homebrew/opt/python@3.12/bin/python3.12" >> "$GITHUB_ENV" + - name: Build and test + if: runner.os != 'Windows' + run: cargo test --workspace + - name: Build and test (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: just windows-test diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..2c3855c --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,32 @@ +name: Coverage + +on: + pull_request: + +jobs: + coverage: + runs-on: macos-latest + env: + PYO3_PYTHON: "/opt/homebrew/opt/python@3.12/bin/python3.12" + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Install Homebrew dependencies + run: brew install python@3.12 just llvm nettle pkg-config + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov + - name: Generate coverage + run: just coverage + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage/lcov.info + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f61beb9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,29 @@ +name: Publish + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Rust cache + uses: Swatinem/rust-cache@v2 + - name: Publish crates + run: | + cargo publish -p agent_secrets + cargo publish -p shadi_sandbox + cargo publish -p shadi_memory + cargo publish -p agent_transport_slim + cargo publish -p shadi_py + cargo publish -p slim_mas + cargo publish -p shadictl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07265f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Rust +/target/ +**/*.rs.bk + +# Python +.venv/ +.venv-py312/ +__pycache__/ +**/*.py[cod] +.adk/ + +# MkDocs +/site/ + +# Coverage +/coverage/ + +# Local data +.tmp/ +tmp/ +*.db + +# Editors +.vscode/ + +# OS +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d9e0cb0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3726 @@ +# 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 = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array 0.14.7", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "agent_secrets" +version = "0.1.0" +dependencies = [ + "security-framework", + "security-framework-sys", + "zeroize", +] + +[[package]] +name = "agent_transport_slim" +version = "0.1.0" +dependencies = [ + "agent_secrets", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +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.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "buffered-reader" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db26bf1f092fd5e05b5ab3be2f290915aeb6f3f20c4e9f86ce0f07f336c2412f" +dependencies = [ + "bzip2 0.5.2", + "flate2", + "libc", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2", + "signature", + "zeroize", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead", + "cipher", + "cmac", + "ctr", + "subtle", +] + +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array 0.14.7", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[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 = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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-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-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[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-core", + "futures-io", + "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", + "zeroize", +] + +[[package]] +name = "generic-array" +version = "1.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" +dependencies = [ + "rustversion", + "typenum", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array 0.14.7", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[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]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memsec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nettle" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e6ff4a94e5d34a1fd5abbd39418074646e2fa51b257198701330f22fcd6936" +dependencies = [ + "getrandom 0.2.17", + "libc", + "nettle-sys", + "thiserror 1.0.69", + "typenum", +] + +[[package]] +name = "nettle-sys" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a3f5406064d310d59b1a219d3c5c9a49caf4047b6496032e3f930876488c34" +dependencies = [ + "bindgen", + "cc", + "libc", + "pkg-config", + "tempfile", + "vcpkg", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[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 = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[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 = "pyo3" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +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 = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array 0.14.7", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "sequoia-openpgp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0620e44a7d514adf7df87b44db235f13b81fed7ddc265adb26f014d42626ac47" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "argon2", + "base64", + "block-padding", + "blowfish", + "buffered-reader", + "bzip2 0.6.1", + "camellia", + "cast5", + "cbc", + "cfb-mode", + "chrono", + "cipher", + "des", + "digest", + "dsa", + "dyn-clone", + "eax", + "ecb", + "ecdsa", + "ed25519", + "ed25519-dalek", + "flate2", + "getrandom 0.2.17", + "hkdf", + "idea", + "idna", + "lalrpop", + "lalrpop-util", + "libc", + "md-5", + "memsec", + "nettle", + "num-bigint-dig", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.9.2", + "rand_core 0.6.4", + "regex", + "regex-syntax", + "ripemd", + "rsa", + "sha1collisiondetection", + "sha2", + "sha3", + "thiserror 1.0.69", + "twofish", + "typenum", + "x25519-dalek", + "xxhash-rust", +] + +[[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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1collisiondetection" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f606421e4a6012877e893c399822a4ed4b089164c5969424e1b9d1e66e6964b" +dependencies = [ + "const-oid", + "digest", + "generic-array 1.3.5", +] + +[[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 = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shadi_memory" +version = "0.1.0" +dependencies = [ + "agent_secrets", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "shadi_py" +version = "0.1.0" +dependencies = [ + "agent_secrets", + "pyo3", + "pyo3-build-config", + "shadi_memory", + "shadi_sandbox", +] + +[[package]] +name = "shadi_sandbox" +version = "0.1.0" +dependencies = [ + "libc", + "thiserror 1.0.69", + "windows-sys 0.61.2", +] + +[[package]] +name = "shadictl" +version = "0.1.0" +dependencies = [ + "agent_secrets", + "base64", + "bs58", + "clap", + "ed25519-dalek", + "hkdf", + "reqwest", + "sequoia-openpgp", + "serde", + "serde_json", + "sha2", + "shadi_memory", + "shadi_sandbox", + "tempfile", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slim_mas" +version = "0.1.0" +dependencies = [ + "agent_secrets", + "clap", + "serde", + "tempfile", + "toml", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "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 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b5b0efb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[workspace] +members = [ + "crates/agent_secrets", + "crates/agent_transport_slim", + "crates/slim_mas", + "crates/shadi_memory", + "crates/shadi_py", + "crates/shadi_sandbox", + "crates/shadictl" +] +resolver = "2" diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..500dafc --- /dev/null +++ b/Justfile @@ -0,0 +1,176 @@ +set shell := ["zsh", "-uc"] +set windows-shell := ["pwsh", "-NoLogo", "-Command"] + +python_prefix := if os() == "windows" { "" } else { `brew --prefix python@3.12` } +python312 := if os() == "windows" { if env_var_or_default("PYO3_PYTHON", "") != "" { env_var("PYO3_PYTHON") } else { `uv python find 3.12` } } else { python_prefix + "/bin/python3.12" } +PROVIDER := "google" +TIMEOUT := "60" +REMEDIATE := "false" + +build: + PYO3_PYTHON="{{python312}}" RUSTFLAGS="-C link-arg=-undefined -C link-arg=dynamic_lookup" cargo build + +windows-build: + $env:PYO3_PYTHON = "{{python312}}"; cargo build + +windows-test: + $env:PYO3_PYTHON = "{{python312}}"; cargo test --workspace + +test: + PYO3_PYTHON="{{python312}}" RUSTFLAGS="-C link-arg=-L{{python_prefix}}/Frameworks/Python.framework/Versions/3.12/lib/python3.12/config-3.12-darwin -C link-arg=-lpython3.12 -C link-arg=-framework -C link-arg=CoreFoundation" cargo test + +lint: + PYO3_PYTHON="{{python312}}" RUSTFLAGS="-C link-arg=-undefined -C link-arg=dynamic_lookup" cargo clippy --all-targets --all-features + +clean: + cargo clean + +coverage: + mkdir -p coverage + LLVM_SYSROOT="$(rustc --print sysroot)" \ + LLVM_HOST="$(rustc -Vv | awk '/host/ {print $2}')" \ + LLVM_BREW="$(brew --prefix llvm 2>/dev/null || true)" \ + LLVM_COV="$(command -v llvm-cov || true)" \ + LLVM_PROFDATA="$(command -v llvm-profdata || true)" \ + LLVM_COV="${LLVM_COV:-$LLVM_BREW/bin/llvm-cov}" \ + LLVM_PROFDATA="${LLVM_PROFDATA:-$LLVM_BREW/bin/llvm-profdata}" \ + LLVM_COV="${LLVM_COV:-$LLVM_SYSROOT/lib/rustlib/$LLVM_HOST/bin/llvm-cov}" \ + LLVM_PROFDATA="${LLVM_PROFDATA:-$LLVM_SYSROOT/lib/rustlib/$LLVM_HOST/bin/llvm-profdata}" \ + SHADI_KEYCHAIN_TESTS=1 \ + PYO3_PYTHON="{{python312}}" RUSTFLAGS="-C link-arg=-L{{python_prefix}}/Frameworks/Python.framework/Versions/3.12/lib/python3.12/config-3.12-darwin -C link-arg=-lpython3.12 -C link-arg=-framework -C link-arg=CoreFoundation" \ + LLVM_COV="$LLVM_COV" LLVM_PROFDATA="$LLVM_PROFDATA" \ + cargo llvm-cov --workspace --features coverage --lcov --output-path coverage/lcov.info --ignore-filename-regex "/rustc-[^/]+/" + +coverage-html: + mkdir -p coverage + LLVM_SYSROOT="$(rustc --print sysroot)" \ + LLVM_HOST="$(rustc -Vv | awk '/host/ {print $2}')" \ + LLVM_BREW="$(brew --prefix llvm 2>/dev/null || true)" \ + LLVM_COV="$(command -v llvm-cov || true)" \ + LLVM_PROFDATA="$(command -v llvm-profdata || true)" \ + LLVM_COV="${LLVM_COV:-$LLVM_BREW/bin/llvm-cov}" \ + LLVM_PROFDATA="${LLVM_PROFDATA:-$LLVM_BREW/bin/llvm-profdata}" \ + LLVM_COV="${LLVM_COV:-$LLVM_SYSROOT/lib/rustlib/$LLVM_HOST/bin/llvm-cov}" \ + LLVM_PROFDATA="${LLVM_PROFDATA:-$LLVM_SYSROOT/lib/rustlib/$LLVM_HOST/bin/llvm-profdata}" \ + SHADI_KEYCHAIN_TESTS=1 \ + PYO3_PYTHON="{{python312}}" RUSTFLAGS="-C link-arg=-L{{python_prefix}}/Frameworks/Python.framework/Versions/3.12/lib/python3.12/config-3.12-darwin -C link-arg=-lpython3.12 -C link-arg=-framework -C link-arg=CoreFoundation" \ + LLVM_COV="$LLVM_COV" LLVM_PROFDATA="$LLVM_PROFDATA" \ + cargo llvm-cov --workspace --features coverage --html --output-dir coverage/html --ignore-filename-regex "/rustc-[^/]+/" + +shadi: + cargo run -p shadi_cli -- + +demo: + cargo run -p shadi_cli -- \ + --allow . \ + --read / \ + --net-block \ + --inject-keychain tourist_api_key=SHADI_BROKER_SECRET \ + -- \ + ./.venv-py312/bin/python agents/secops/secops.py + +demo-policy: + cargo run -p shadi_cli -- \ + --policy policies/demo/secops-a.json \ + --inject-keychain tourist_api_key=SHADI_BROKER_SECRET \ + -- \ + ./.venv-py312/bin/python agents/secops/secops.py + +demo-steps: + SHADI_POLICY_PATH=policies/demo/secops-a.json ./.venv-py312/bin/python agents/secops/secops.py + +secops-import: + source ~/.env-phoenix && export SHADI_OPERATOR_PRESENTATION="local-operator" && \ + uv run --no-project --python .venv/bin/python agents/secops/import_secops_secrets.py + +secops-run: + SHADI_LLM_TIMEOUT={{ replace(TIMEOUT, "TIMEOUT=", "") }} \ + uv run --no-project --python .venv/bin/python agents/secops/secops.py \ + --provider {{ replace(PROVIDER, "PROVIDER=", "") }} \ + {{ if replace(REMEDIATE, "REMEDIATE=", "") == "true" { "--remediate" } else { "" } }} + +secops-approve-prs: + uv run --no-project --python .venv/bin/python agents/secops/secops.py --approve-prs + +secops-a2a: + uv run --no-project --python .venv/bin/python agents/secops/a2a_server.py + +shadi-prompt: + uv run --no-project --python .venv/bin/python tools/shadi_prompt.py + +secops-run-google: + just secops-run PROVIDER="google" + +secops-run-azure: + just secops-run PROVIDER="azure" + +secops-run-anthropic: + just secops-run PROVIDER="anthropic" + +secops-secrets: + cargo run -p shadictl -- --list-keychain --list-prefix secops/ + +secops-policy: + cargo run -p shadictl -- --policy policies/demo/secops-a.json --print-policy + +secops-memory-list: + cargo run -p shadictl -- -- memory list \ + --db "${SHADI_SECOPS_MEMORY_DB:-${SHADI_MEMORY_DB:-${SHADI_TMP_DIR:-./.tmp}/${SHADI_AGENT_ID:-${SHADI_OPERATOR_AGENT_ID:-secops_agent}}/shadi-secops/secops_memory.db}}" \ + --key-name secops/memory_key --scope secops + +secops-memory-init: + cargo run -p shadictl -- -- memory init \ + --db "${SHADI_SECOPS_MEMORY_DB:-${SHADI_MEMORY_DB:-${SHADI_TMP_DIR:-./.tmp}/${SHADI_AGENT_ID:-${SHADI_OPERATOR_AGENT_ID:-secops_agent}}/shadi-secops/secops_memory.db}}" \ + --key-name secops/memory_key + +secops-memory-get: + cargo run -p shadictl -- -- memory get \ + --db "${SHADI_SECOPS_MEMORY_DB:-${SHADI_MEMORY_DB:-${SHADI_TMP_DIR:-./.tmp}/${SHADI_AGENT_ID:-${SHADI_OPERATOR_AGENT_ID:-secops_agent}}/shadi-secops/secops_memory.db}}" \ + --key-name secops/memory_key \ + --scope secops --entry-key security_report + +demo-tourist: + AGENTIC_APPS_PATH={{env_var_or_default("AGENTIC_APPS_PATH", "")}} \ + TOURIST_CMD={{env_var_or_default("TOURIST_CMD", "")}} \ + SHADI_POLICY_PATH=policies/demo/tourist.json \ + ./.venv-py312/bin/python agents/adk_demo/run_tourist_demo.py + +windows-integration: + SHADI_WINDOWS_INTEGRATION=1 cargo test -p shadi_sandbox + +docs-build: + mkdocs build + +docs-serve: + mkdocs serve + +launch-slim: + ./scripts/launch_slim.sh + +launch-slim-example: + SHADI_TMP_DIR="./.tmp" SLIM_ENDPOINT="127.0.0.1:47357" ./scripts/launch_slim.sh + +launch-secops-a2a: + ./scripts/launch_secops_a2a.sh + +launch-secops-a2a-example: + SHADI_TMP_DIR="./.tmp" SHADI_AGENT_ID="secops-a" SHADI_OPERATOR_PRESENTATION="local-operator" ./scripts/import_secops_secrets.sh + SHADI_TMP_DIR="./.tmp" SHADI_AGENT_ID="secops-a" SHADI_OPERATOR_PRESENTATION="local-operator" ./scripts/launch_secops_a2a.sh + +launch-avatar: + ./scripts/launch_avatar.sh + +launch-avatar-example: + SHADI_TMP_DIR="./.tmp" SHADI_AGENT_ID="avatar-1" SHADI_OPERATOR_PRESENTATION="local-operator" ./scripts/launch_avatar.sh + +import-secops-secrets: + ./scripts/import_secops_secrets.sh + +secure-profile-strict: + cargo run -p shadictl -- --profile strict --print-policy + +secure-profile-balanced: + cargo run -p shadictl -- --profile balanced --print-policy + +secure-profile-connected: + cargo run -p shadictl -- --profile connected --print-policy diff --git a/README.md b/README.md index f8aebbb..a1af67b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,192 @@ # SHADI -Secure Host Agentic AI Dynamic Instantiation (SHADI) is a host runtime for autonomous, multi-agent systems. It focuses on secrets hygiene, identity checks, and OS-level sandboxing to reduce the blast radius of agent actions. +Secure Host for Agentic AI Dynamic Instantiation (SHADI) is a secure host runtime for autonomous, multi-agent systems. -See docs/ for the full documentation. +SHADI is designed for environments where agents are long-lived, hold real credentials, and run close to sensitive data. It combines identity verification, keychain-backed secrets, OS sandboxing, and encrypted local memory to reduce blast radius and make agent behavior auditable. + +## What SHADI provides + +- Verified secret access gates (`agent_secrets`) with OS keychain backends. +- Deterministic human -> agent identity derivation (`did:key`) and provenance verification. +- Kernel-enforced sandbox execution policies (`shadi_sandbox`) with portable profile defaults. +- SQLCipher-backed encrypted local memory (`shadi_memory`). +- Python bindings (`shadi_py`) for secrets, memory, and sandboxed execution. +- SLIM transport integration for secure agent-to-agent messaging. +- A practical SecOps agent demo and launch scripts. + +## Repository layout + +- `crates/shadictl`: main CLI (`shadi`) for policy, sandbox execution, key management, and identity derivation/verification. +- `crates/shadi_sandbox`: sandbox policy model and platform enforcement. +- `crates/agent_secrets`: keychain-backed secret storage + verification-gated access. +- `crates/shadi_memory`: SQLCipher memory library and CLI (`shadi-memory`). +- `crates/shadi_py`: Python extension module `shadi`. +- `crates/agent_transport_slim` + `crates/slim_mas`: secure transport and moderation helpers. +- `agents/secops`: SecOps agent, A2A server, and skill implementation. +- `docs`: architecture, security, CLI, demos, and integration docs. +- `scripts`: local launch helpers for SLIM + agent demos. + +## Architecture at a glance + +SHADI runtime flow: + +1. Ingest human identity material (`gpg` secret material or generic seed). +2. Derive deterministic Ed25519 local keys and `did:key` identities per agent. +3. Optionally bind agent identities to a stored human DID and verify provenance. +4. Apply sandbox policy (filesystem/network/command controls). +5. Gate secret access on verified sessions. +6. Persist agent memory encrypted at rest. + +For full details, see `docs/architecture.md` and `docs/security.md`. + +## Prerequisites + +- Rust toolchain (stable) with Cargo +- Python 3.12 (for `shadi_py` and Python demos) +- `just` (recommended task runner) +- Optional for docs: `mkdocs` (and related theme/plugins used by this repo) + +## Quick start + +Build all crates: + +```bash +cargo build --workspace +``` + +Run all tests: + +```bash +cargo test --workspace +``` + +If you use `just`, common tasks are available: + +```bash +just build +just test +just lint +``` + +## Core CLI workflows + +### 1) Print a baseline secure policy profile + +```bash +cargo run -p shadictl -- --profile balanced --print-policy +``` + +Available built-in profiles: + +- `strict`: local-only policy with network blocked +- `balanced`: practical local default with network blocked +- `connected`: balanced + network enabled + +### 2) Run a command in the sandbox + +```bash +cargo run -p shadictl -- \ + --allow . \ + --read / \ + --net-block \ + -- \ + /usr/bin/env echo "hello from sandbox" +``` + +### 3) Derive agent identities from a human source + +```bash +cargo run -p shadictl -- \ + derive-agent-identity \ + --source gpg \ + --human-secret human/gpg \ + --name secops-a \ + --name avatar-1 \ + --prefix agents \ + --out-dir ./agent-dids +``` + +### 4) Verify agent provenance + +```bash +cargo run -p shadictl -- \ + verify-agent-identity \ + --source gpg \ + --human-secret human/gpg \ + --name secops-a \ + --prefix agents +``` + +### 5) Use encrypted memory through `shadictl` + +```bash +cargo run -p shadictl -- -- memory init \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key +``` + +## Python module (`shadi_py`) + +Build the Python extension crate: + +```bash +cargo build -p shadi_py +``` + +The module exposes bindings for: + +- secret store operations and session verification hooks +- SQLCipher memory operations +- sandbox policy handles and sandboxed process execution + +## SecOps demo and launch scripts + +The repo includes runnable examples under `agents/secops` and helper scripts in `scripts/`. + +Common local flow: + +```bash +./scripts/launch_slim.sh +./scripts/import_secops_secrets.sh +./scripts/launch_secops_a2a.sh +./scripts/launch_avatar.sh +``` + +See `scripts/README.md` and `docs/demo.md` for full setup details. + +## Documentation + +Primary docs live in `docs/`: + +- `docs/index.md`: project overview +- `docs/architecture.md`: runtime and control planes +- `docs/security.md`: threat model and security notes +- `docs/cli.md`: complete CLI reference +- `docs/sandbox.md`: policy model and profile behavior +- `docs/demo.md`: demo walkthrough + +Build/serve docs locally: + +```bash +mkdocs build +mkdocs serve +``` + +## Development notes + +- Keep changes focused and platform-safe (macOS, Windows, Linux where applicable). +- Prefer policy/profile-based secure defaults instead of ad-hoc shell wrappers. +- Use deterministic identity derivation and `verify-agent-identity` for provenance checks. +- Run `cargo test --workspace` before opening a PR. + +## Contributing + +See: + +- `CONTRIBUTING.md` +- `CODE_OF_CONDUCT.md` +- `SECURITY.md` + +## License + +See `LICENSE.md`. diff --git a/agents/adk_demo/README.md b/agents/adk_demo/README.md new file mode 100644 index 0000000..b94f490 --- /dev/null +++ b/agents/adk_demo/README.md @@ -0,0 +1,36 @@ +# ADK Demo (SHADI) + +This demo shows how to access SHADI secrets from a Python agent process. +Use it as a starting point to integrate a Google ADK agent or a SLIM-enabled +agentic app. + +## Notes +- The session uses a local verified flag for demo only. +- Replace the session verification with DID/VC checks in production. +- Build the Python extension and import `shadi` before running the demo. +- The Tourist demo runs the sandbox via Python bindings (no `shadictl` CLI). + +## Tourist scheduling system demo + +To run the Tourist/Guide agents from agentic-apps with brokered secrets: + +```bash +export AGENTIC_APPS_PATH=/path/to/agentic-apps +export TOURIST_CMD="python your_entrypoint.py" +just demo-tourist +``` + +## SecOps autonomous agent + +This agent runs locally and monitors GitHub security issues, preparing +remediation PRs using credentials stored in SHADI. + +Configuration lives in the repo root at secops.toml. You can override the path +with SHADI_SECOPS_CONFIG. + +```bash +export GITHUB_TOKEN="$(gh auth token)" +export SHADI_OPERATOR_PRESENTATION="local-operator" +uv run agents/secops/import_secops_secrets.py +uv run agents/secops/secops.py +``` diff --git a/agents/adk_demo/agentic_app_notes.md b/agents/adk_demo/agentic_app_notes.md new file mode 100644 index 0000000..5493522 --- /dev/null +++ b/agents/adk_demo/agentic_app_notes.md @@ -0,0 +1,5 @@ +# Agentic Apps Notes + +The tourist scheduling system agents (Tourist or Guide) are good example +candidates for integration. This demo keeps the integration minimal and +focuses on secret access wiring. diff --git a/agents/adk_demo/demo.py b/agents/adk_demo/demo.py new file mode 100644 index 0000000..1e9703e --- /dev/null +++ b/agents/adk_demo/demo.py @@ -0,0 +1,103 @@ +import os +import time +from pathlib import Path + +from shadi import ShadiStore, PySessionContext + + +def run_demo(): + print("== SHADI Demo: Secure Agent Run ==") + print("Step 1: Load sandbox policy (JSON)") + policy_path = os.getenv("SHADI_POLICY_PATH", "sandbox.json") + print("Using policy:", policy_path) + + print("Step 2: Brokered secrets check") + brokered = os.getenv("SHADI_BROKER_SECRET") + if brokered is not None: + print("Brokered secret length:", len(brokered.encode("utf-8"))) + print("Step 3: Mock SLIM/MLS message") + mock_slim_message() + return + + print("Step 2b: Keystore-backed secret access") + store = ShadiStore() + session = PySessionContext("tourist_agent", "session-1") + + def verify_didvc(agent_id, session_id, presentation, claims): + return agent_id == "tourist_agent" and len(presentation) > 0 + + store.set_verifier(verify_didvc) + ok = store.verify_session(session, b"dummy-didvc") + if not ok: + raise RuntimeError("DID/VC verification failed") + + store.put(session, "tourist_api_key", b"secret-value") + secret = store.get(session, "tourist_api_key") + print("Keystore secret length:", len(secret)) + + print("Step 3: Mock SLIM/MLS message") + mock_slim_message() + + +def run_secops_demo(): + print("== SHADI Demo: SecOps Autonomous Agent ==") + print("Step 1: Load sandbox policy (JSON)") + policy_path = os.getenv("SHADI_POLICY_PATH", "sandbox.json") + print("Using policy:", policy_path) + + print("Step 2: Initialize secured session for SecOps") + store = ShadiStore() + session = PySessionContext("secops_agent", "secops-session-1") + + def verify_operator(agent_id, session_id, presentation, claims): + return agent_id == "secops_agent" and len(presentation) > 0 + + store.set_verifier(verify_operator) + ok = store.verify_session(session, b"dummy-operator-didvc") + if not ok: + raise RuntimeError("SecOps verification failed") + + print("Step 3: Load SecOps credentials from SHADI") + store.put(session, "secops/github_token", b"example-token") + store.put(session, "secops/ssh_key", b"example-ssh-key") + tmp_dir = os.getenv("SHADI_TMP_DIR", "./.tmp") + agent_id = os.getenv("SHADI_AGENT_ID") or os.getenv("SHADI_OPERATOR_AGENT_ID") + if agent_id: + tmp_dir = str(Path(tmp_dir) / agent_id) + workspace_dir = str(Path(tmp_dir) / "shadi-secops") + store.put(session, "secops/workspace_dir", workspace_dir.encode("utf-8")) + token_len = len(store.get(session, "secops/github_token")) + workspace = store.get(session, "secops/workspace_dir").decode("utf-8") + print("Github token length:", token_len) + print("Workspace dir:", workspace) + + print("Step 4: Monitor GitHub security advisories") + print("- Repo: agntcy/dir") + print("- Finding: multiple CVEs pending remediation") + + print("Step 5: Clone repo and prepare remediation plan") + print("- Clone into workspace") + print("- Propose dependency/container upgrades") + print("- Draft code patches and tests") + + print("Step 6: Create pull request with remediation") + print("- Open PR in agntcy/dir") + print("- Attach summary and risk notes") + + +def mock_slim_message(): + print("SLIM: preparing MLS session") + time.sleep(0.1) + print("SLIM: encrypting message") + time.sleep(0.1) + print("SLIM: sending to peer") + time.sleep(0.1) + print("SLIM: message delivered") + + +if __name__ == "__main__": + mode = os.getenv("SHADI_DEMO", "default") + if mode == "secops": + run_secops_demo() + else: + run_demo() diff --git a/agents/adk_demo/run_tourist_demo.py b/agents/adk_demo/run_tourist_demo.py new file mode 100644 index 0000000..d77388e --- /dev/null +++ b/agents/adk_demo/run_tourist_demo.py @@ -0,0 +1,66 @@ +import json +import os +import shlex +import sys +from pathlib import Path + +from shadi import SandboxPolicyHandle, run_sandboxed + + +def main() -> int: + apps_root = os.getenv("AGENTIC_APPS_PATH") + if not apps_root: + apps_root = str((Path(__file__).resolve().parents[2] / "agentic-apps")) + if not apps_root: + print("AGENTIC_APPS_PATH is required.") + return 2 + + app_dir = Path(apps_root) / "tourist_scheduling_system" + if not app_dir.exists(): + print("tourist_scheduling_system not found at:", app_dir) + return 2 + + tourist_cmd = os.getenv("TOURIST_CMD") + if not tourist_cmd: + print("TOURIST_CMD is required (command to run the Tourist/Guide agent).") + return 2 + + policy_path = os.getenv("SHADI_POLICY_PATH", "sandbox.json") + policy_file = Path(policy_path) + if not policy_file.exists(): + print("Policy file not found:", policy_file) + return 2 + + try: + policy_data = json.loads(policy_file.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print("Policy file is not valid JSON:", exc) + return 2 + + policy = SandboxPolicyHandle() + for path in policy_data.get("read", []) or []: + policy.allow_read_path(path) + for path in policy_data.get("write", []) or []: + policy.allow_write_path(path) + if policy_data.get("net_block") is not None: + policy.block_network(bool(policy_data.get("net_block"))) + for path in policy_data.get("allow", []) or []: + policy.allow_read_path(path) + policy.allow_write_path(path) + + command = shlex.split(tourist_cmd) + if not command: + print("TOURIST_CMD is empty.") + return 2 + + print("Running:", " ".join(command)) + return run_sandboxed( + command, + policy, + cwd=str(app_dir), + inject_keychain=["TouristScheduler=SHADI_BROKER_SECRET"], + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agents/avatar/AGENTS.md b/agents/avatar/AGENTS.md new file mode 100644 index 0000000..d15bf34 --- /dev/null +++ b/agents/avatar/AGENTS.md @@ -0,0 +1,35 @@ +# Avatar Agent Guide + +## Overview +The Avatar agent provides an interactive ADK interface and routes commands to +SecOps over SLIM A2A. + +## Key environment variables +- SHADI_OPERATOR_PRESENTATION: Required to access secrets in SHADI. +- SHADI_SECOPS_CONFIG: Path to a secops config TOML (default: secops.toml). +- SHADI_AVATAR_IDENTITY: Agent identity used for SLIM A2A. +- SHADI_TMP_DIR: Base directory for memory defaults (default: ./.tmp). +- SHADI_AGENT_ID: Optional agent-specific suffix for isolation. +- SHADI_ADK_MEMORY_DB: Optional override for ADK memory persistence. +- SLIM_TLS_CERT, SLIM_TLS_KEY, SLIM_TLS_CA: Client TLS material for SLIM. + +## Common workflows + +### Run the interactive Avatar agent (SHADI-backed memory) +```bash +export SHADI_OPERATOR_PRESENTATION="local-operator" +export SHADI_AGENT_ID="avatar-1" +export SHADI_TMP_DIR="./.tmp" +export SHADI_SECOPS_CONFIG=./.tmp/secops-a.toml +export SHADI_POLICY_PATH=./policies/demo/avatar.json +export SLIM_TLS_CERT=./.tmp/shadi-slim-mtls/client-avatar.crt +export SLIM_TLS_KEY=./.tmp/shadi-slim-mtls/client-avatar.key +export SLIM_TLS_CA=./.tmp/shadi-slim-mtls/ca.crt +./scripts/launch_avatar.sh +``` + +## Notes +- The Avatar agent expects a running SecOps A2A server on the configured + SLIM endpoint and shared secret stored in SHADI. +- Memory defaults to $SHADI_TMP_DIR/$SHADI_AGENT_ID/shadi-secops/secops_memory.db + unless overridden by SHADI_ADK_MEMORY_DB. diff --git a/agents/avatar/README.md b/agents/avatar/README.md new file mode 100644 index 0000000..c37cd8b --- /dev/null +++ b/agents/avatar/README.md @@ -0,0 +1,29 @@ +# Avatar agent + +Avatar is a human interface agent that uses an LLM to translate natural language +requests into SecOps commands and sends them over SLIM A2A. + +## Requirements +- SHADI Python extension available in the runtime. +- SLIM A2A dependencies: `slima2a`, `slimrpc`, `a2a`, `httpx`. +- SecOps A2A server running. +- SHADI secrets set for LLMs under `secops/llm/...`. +- SHADI shared secret for SLIM: `secops/slim_shared_secret` (or override in secops.toml). + +## Environment +- `SHADI_OPERATOR_PRESENTATION` (required) +- `SHADI_AVATAR_AGENT_ID` (optional, default `avatar_agent`) +- `SHADI_AVATAR_IDENTITY` (optional, default `agntcy/avatar/client`) + +## Run locally +```bash +uv run agents/avatar/adk_agent/run_local.py +``` + +Example prompt: +``` +scan dependabot for the allowlist +``` + +The agent will translate requests into JSON commands and send them to the SecOps +A2A server. diff --git a/agents/avatar/adk_agent/agent.py b/agents/avatar/adk_agent/agent.py new file mode 100644 index 0000000..22ac179 --- /dev/null +++ b/agents/avatar/adk_agent/agent.py @@ -0,0 +1,268 @@ +import asyncio +import json +import os +import sys +from pathlib import Path + +from google.adk.agents.llm_agent import Agent + +from shadi import ShadiStore, PySessionContext + +AVATAR_DIR = Path(__file__).resolve().parents[1] +SECOPS_DIR = Path(__file__).resolve().parents[2] / "secops" +sys.path.append(str(SECOPS_DIR)) + +from skills import get_llm_settings, load_secops_config + + +def load_agent_context() -> str: + path = AVATAR_DIR / "AGENTS.md" + if not path.exists(): + return "" + try: + return path.read_text(encoding="utf-8").strip() + except OSError: + return "" + + +def require_slima2a_packages(): + try: + import httpx + import slimrpc + from a2a.client import ClientFactory, minimal_agent_card + from a2a.types import Message, Part, Role, TextPart + from slima2a.client_transport import ClientConfig, SRPCTransport, slimrpc_channel_factory + except ImportError as exc: + raise RuntimeError( + "Missing SLIM A2A dependencies. Install: uv pip install slima2a slimrpc a2a httpx" + ) from exc + + return { + "httpx": httpx, + "slimrpc": slimrpc, + "ClientFactory": ClientFactory, + "minimal_agent_card": minimal_agent_card, + "Message": Message, + "Part": Part, + "Role": Role, + "TextPart": TextPart, + "ClientConfig": ClientConfig, + "SRPCTransport": SRPCTransport, + "slimrpc_channel_factory": slimrpc_channel_factory, + } + + +def create_avatar_session(): + store = ShadiStore() + agent_id = os.getenv("SHADI_AVATAR_AGENT_ID", "avatar_agent") + presentation = os.getenv("SHADI_OPERATOR_PRESENTATION", "").encode("utf-8") + if not presentation: + raise RuntimeError("SHADI_OPERATOR_PRESENTATION must be set") + session = PySessionContext(agent_id, "avatar-session-1") + + def verify_operator(verify_agent_id, session_id, presentation_bytes, claims): + return verify_agent_id == agent_id and len(presentation_bytes) > 0 + + store.set_verifier(verify_operator) + ok = store.verify_session(session, presentation) + if not ok: + raise RuntimeError("Avatar verification failed") + return store, session + + +def require_shadi_secret_value(store, session, key_name, label): + try: + value = store.get(session, key_name) + except Exception as exc: + raise RuntimeError(f"Missing {label} in SHADI at key '{key_name}'.") from exc + text = value.decode("utf-8").strip() + if not text: + raise RuntimeError(f"Missing {label} in SHADI at key '{key_name}'.") + return text + + +def resolve_slim_config(): + config_path, config = load_secops_config() + secops_config = config.get("secops", {}) + store, session = create_avatar_session() + secret_key = secops_config.get("slim_shared_secret_key", "secops/slim_shared_secret") + shared_secret = require_shadi_secret_value(store, session, secret_key, "SLIM shared secret") + tls_insecure = bool(secops_config.get("slim_tls_insecure", True)) + tls_cert = os.getenv("SLIM_TLS_CERT", "").strip() + tls_key = os.getenv("SLIM_TLS_KEY", "").strip() + tls_ca = os.getenv("SLIM_TLS_CA", "").strip() + if not tls_insecure and (not tls_cert or not tls_key or not tls_ca): + raise RuntimeError("SLIM mTLS requires SLIM_TLS_CERT, SLIM_TLS_KEY, and SLIM_TLS_CA") + return { + "endpoint": secops_config.get("slim_endpoint", "http://localhost:47357"), + "remote_identity": secops_config.get("slim_identity", "agntcy/secops/agent"), + "local_identity": os.getenv("SHADI_AVATAR_IDENTITY", "agntcy/avatar/client"), + "shared_secret": shared_secret, + "insecure": tls_insecure, + "tls_cert": tls_cert, + "tls_key": tls_key, + "tls_ca": tls_ca, + } + + +_CLIENT_CACHE = { + "config_key": None, + "client": None, + "httpx_client": None, +} + + +async def build_client(types, endpoint, local_identity, remote_identity, shared_secret, insecure): + httpx_client = types["httpx"].AsyncClient() + slimrpc = types["slimrpc"] + + tls_config = { + "insecure": bool(insecure), + } + if not insecure: + tls_config["source"] = { + "type": "file", + "cert": os.getenv("SLIM_TLS_CERT", "").strip(), + "key": os.getenv("SLIM_TLS_KEY", "").strip(), + } + tls_config["ca_source"] = { + "type": "file", + "path": os.getenv("SLIM_TLS_CA", "").strip(), + } + + slim_app = await slimrpc.common.create_local_app( + slimrpc.SLIMAppConfig( + identity=local_identity, + slim_client_config={ + "endpoint": endpoint, + "tls": tls_config, + }, + shared_secret=shared_secret, + ) + ) + client_config = types["ClientConfig"]( + supported_transports=["JSONRPC", "slimrpc"], + streaming=True, + httpx_client=httpx_client, + slimrpc_channel_factory=types["slimrpc_channel_factory"](slim_app), + ) + factory = types["ClientFactory"](client_config) + factory.register("slimrpc", types["SRPCTransport"].create) + agent_card = types["minimal_agent_card"](remote_identity, ["slimrpc"]) + client = factory.create(card=agent_card) + return client, httpx_client + + +async def get_cached_client(types, config): + config_key = ( + config["endpoint"], + config["local_identity"], + config["remote_identity"], + config["shared_secret"], + config["insecure"], + config.get("tls_cert"), + config.get("tls_key"), + config.get("tls_ca"), + ) + if _CLIENT_CACHE["client"] and _CLIENT_CACHE["config_key"] == config_key: + return _CLIENT_CACHE["client"], _CLIENT_CACHE["httpx_client"] + + if _CLIENT_CACHE["httpx_client"]: + await _CLIENT_CACHE["httpx_client"].aclose() + + client, httpx_client = await build_client( + types, + endpoint=config["endpoint"], + local_identity=config["local_identity"], + remote_identity=config["remote_identity"], + shared_secret=config["shared_secret"], + insecure=config["insecure"], + ) + _CLIENT_CACHE["config_key"] = config_key + _CLIENT_CACHE["client"] = client + _CLIENT_CACHE["httpx_client"] = httpx_client + return client, httpx_client + + +async def send_message(types, client, text): + request = types["Message"]( + role=types["Role"].user, + message_id="avatar-message", + parts=[types["Part"](root=types["TextPart"](text=text))], + ) + output = "" + async for response in client.send_message(request=request): + if isinstance(response, types["Message"]): + for part in response.parts: + if isinstance(part.root, types["TextPart"]): + output += part.root.text + else: + task, _ = response + if task.status.state == "completed" and task.artifacts: + for artifact in task.artifacts: + for part in artifact.parts: + if isinstance(part.root, types["TextPart"]): + output += part.root.text + return output + + +def normalize_secops_payload(payload): + if isinstance(payload, dict): + return json.dumps(payload) + if isinstance(payload, (bytes, bytearray)): + payload = payload.decode("utf-8", errors="ignore") + if isinstance(payload, str): + try: + data = json.loads(payload) + if isinstance(data, dict): + return json.dumps(data) + except json.JSONDecodeError: + pass + return json.dumps({"command": payload.strip()}) + return json.dumps({"command": str(payload).strip()}) + + +async def send_secops_command(payload): + types = require_slima2a_packages() + config = resolve_slim_config() + normalized = normalize_secops_payload(payload) + + client, _httpx_client = await get_cached_client(types, config) + return await send_message(types, client, normalized) + + +config_path, config = load_secops_config() +avatar_store, avatar_session = create_avatar_session() +llm_settings = get_llm_settings(config, store=avatar_store, session=avatar_session) + +if llm_settings["provider"] == "google" and not llm_settings.get("openai_proxy"): + os.environ["GOOGLE_API_KEY"] = llm_settings["api_key"] + os.environ["ADK_GOOGLE_API_KEY_SOURCE"] = "shadi" +else: + os.environ["OPENAI_API_KEY"] = llm_settings["api_key"] + if llm_settings.get("base_url"): + os.environ["OPENAI_BASE_URL"] = llm_settings["base_url"] + +model = os.getenv("ADK_MODEL") or llm_settings.get("adk_model") or llm_settings.get("model") +if not model: + model = config.get("adk", {}).get("model", "gemini-3-flash-preview") + +base_instruction = ( + "You are Avatar, a human interface agent. Convert the user request into a JSON command " + "for the SecOps agent and send it using the send_secops_command tool. The SecOps agent " + "accepts commands: scan, remediate, approve_prs, report, and help. Optional JSON fields: " + "provider, labels, report_name. Always send valid JSON. Reply with the SecOps response." +) +context = load_agent_context() +if context: + instruction = f"{base_instruction}\n\nContext:\n{context}" +else: + instruction = base_instruction + +root_agent = Agent( + model=model, + name="avatar_agent", + description="Human interface agent that routes commands to SecOps over SLIM A2A.", + instruction=instruction, + tools=[send_secops_command], +) diff --git a/agents/avatar/adk_agent/run_local.py b/agents/avatar/adk_agent/run_local.py new file mode 100644 index 0000000..798ab46 --- /dev/null +++ b/agents/avatar/adk_agent/run_local.py @@ -0,0 +1,52 @@ +import asyncio +import os + +from google.adk.memory import InMemoryMemoryService +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService +from google.genai import types + +from agent import root_agent + +APP_NAME = "shadi_avatar" +USER_ID = os.getenv("AVATAR_USER_ID", "local-user") +SESSION_ID = os.getenv("AVATAR_SESSION_ID", "avatar-session") + + +async def run_chat(): + session_service = InMemorySessionService() + memory_service = InMemoryMemoryService() + + await session_service.create_session( + app_name=APP_NAME, + user_id=USER_ID, + session_id=SESSION_ID, + ) + + runner = Runner( + agent=root_agent, + app_name=APP_NAME, + session_service=session_service, + memory_service=memory_service, + ) + + print("Avatar ready. Type 'exit' to quit.") + while True: + line = input("avatar> ").strip() + if not line: + continue + if line in ("exit", "quit", ":exit"): + break + + content = types.Content(role="user", parts=[types.Part(text=line)]) + async for event in runner.run_async( + user_id=USER_ID, + session_id=SESSION_ID, + new_message=content, + ): + if event.is_final_response() and event.content and event.content.parts: + print(event.content.parts[0].text.strip()) + + +if __name__ == "__main__": + asyncio.run(run_chat()) diff --git a/agents/avatar/adk_agent/run_shadi_memory.py b/agents/avatar/adk_agent/run_shadi_memory.py new file mode 100644 index 0000000..223251c --- /dev/null +++ b/agents/avatar/adk_agent/run_shadi_memory.py @@ -0,0 +1,84 @@ +import asyncio +import os +import sys +import warnings +from pathlib import Path + +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService +from google.genai import types + +from agent import root_agent + +ROOT_DIR = Path(__file__).resolve().parents[3] +sys.path.append(str(ROOT_DIR)) + +from agents.secops.skills import load_secops_config +from agents.shared.shadi_adk_memory import ShadiBackedMemoryService + +APP_NAME = "shadi_avatar" +USER_ID = os.getenv("AVATAR_USER_ID", "local-user") +SESSION_ID = os.getenv("AVATAR_SESSION_ID", "avatar-session") + + +async def run_interactive(): + warnings.filterwarnings( + "ignore", + message="Pydantic serializer warnings:.*", + category=UserWarning, + ) + session_service = InMemorySessionService() + config_path, config = load_secops_config() + secops_config = config.get("secops", {}) + tmp_dir = os.getenv("SHADI_TMP_DIR", "./.tmp") + agent_id = os.getenv("SHADI_AGENT_ID") or os.getenv("SHADI_AVATAR_AGENT_ID") + if agent_id: + tmp_dir = str(Path(tmp_dir) / agent_id) + default_db = str(Path(tmp_dir) / "shadi-secops" / "secops_memory.db") + memory_db = ( + os.getenv("SHADI_ADK_MEMORY_DB") + or secops_config.get("memory_db") + or default_db + ) + memory_key = secops_config.get("memory_key", "secops/memory_key") + memory_scope = secops_config.get("memory_scope", "secops") + memory_entry_key = f"adk_memory/{APP_NAME}/{USER_ID}" + memory_service = ShadiBackedMemoryService( + app_name=APP_NAME, + user_id=USER_ID, + db_path=memory_db, + key_name=memory_key, + scope=memory_scope, + entry_key=memory_entry_key, + ) + + await session_service.create_session( + app_name=APP_NAME, + user_id=USER_ID, + session_id=SESSION_ID, + ) + + runner = Runner( + agent=root_agent, + app_name=APP_NAME, + session_service=session_service, + memory_service=memory_service, + ) + + while True: + prompt = await asyncio.to_thread(input, "[user]: ") + if prompt.strip().lower() in ("exit", "quit"): + print("Exiting.") + return + content = types.Content(role="user", parts=[types.Part(text=prompt)]) + async for event in runner.run_async( + user_id=USER_ID, + session_id=SESSION_ID, + new_message=content, + ): + if event.is_final_response() and event.content and event.content.parts: + print("[avatar_agent]:", event.content.parts[0].text.strip()) + + +if __name__ == "__main__": + asyncio.run(run_interactive()) diff --git a/agents/secops/AGENTS.md b/agents/secops/AGENTS.md new file mode 100644 index 0000000..77ba8d0 --- /dev/null +++ b/agents/secops/AGENTS.md @@ -0,0 +1,55 @@ +# SecOps Agent Guide + +## Overview +The SecOps agent scans allowlisted repositories, generates reports, and can +operate as a standalone local agent or an A2A server behind SLIM. + +## Key environment variables +- SHADI_OPERATOR_PRESENTATION: Required to access secrets in SHADI. +- SHADI_SECOPS_CONFIG: Path to a secops config TOML (default: secops.toml). +- SHADI_TMP_DIR: Base directory for workspace and memory defaults (default: ./.tmp). +- SHADI_AGENT_ID: Optional agent-specific suffix for isolation. +- SHADI_SECOPS_MEMORY_DB: Optional override for the SQLCipher memory DB. +- SHADI_MEMORY_DB: Optional global memory DB override. +- SHADI_ADK_MEMORY_DB: Optional override for ADK memory persistence. +- SLIM_TLS_CERT, SLIM_TLS_KEY, SLIM_TLS_CA: Client TLS material for SLIM. + +## Common workflows + +### Load secrets (token, workspace, LLM settings) +```bash +export SHADI_OPERATOR_PRESENTATION="local-operator" +export GITHUB_TOKEN="$(gh auth token)" +uv run agents/secops/import_secops_secrets.py +``` + +### Run the local SecOps agent +```bash +export SHADI_OPERATOR_PRESENTATION="local-operator" +uv run agents/secops/secops.py +``` + +### Run the SecOps A2A server +```bash +export SHADI_OPERATOR_PRESENTATION="local-operator" +export SHADI_SECOPS_CONFIG=./.tmp/secops-a.toml +export SHADI_POLICY_PATH=./policies/demo/secops-a.json +export SLIM_TLS_CERT=./.tmp/shadi-slim-mtls/client-secops-a.crt +export SLIM_TLS_KEY=./.tmp/shadi-slim-mtls/client-secops-a.key +export SLIM_TLS_CA=./.tmp/shadi-slim-mtls/ca.crt +./scripts/launch_secops_a2a.sh +``` + +### Run the ADK agent with SHADI-backed memory +```bash +export SHADI_OPERATOR_PRESENTATION="local-operator" +export SHADI_AGENT_ID="secops-a" +export SHADI_TMP_DIR="./.tmp" +uv run agents/secops/adk_agent/run_local.py +``` + +## Notes +- Workspace defaults to $SHADI_TMP_DIR/$SHADI_AGENT_ID/shadi-secops unless + overridden in secops.toml or SHADI_SECOPS_CONFIG. +- Memory defaults to $SHADI_TMP_DIR/$SHADI_AGENT_ID/shadi-secops/secops_memory.db + unless overridden by SHADI_SECOPS_MEMORY_DB or SHADI_MEMORY_DB. diff --git a/agents/secops/SKILL.md b/agents/secops/SKILL.md new file mode 100644 index 0000000..76d1883 --- /dev/null +++ b/agents/secops/SKILL.md @@ -0,0 +1,234 @@ +--- +name: secops +description: Collects security alerts and issues for allowlisted GitHub repos, writes a report, and supports remediation planning. Use when you need a SecOps agent to monitor Dependabot alerts or security-labeled issues under SHADI sandbox constraints. +license: Apache-2.0 +compatibility: Requires git, internet access to api.github.com, SHADI Python extension, and a GitHub token stored in SHADI. +metadata: + framework: google-adk + version: "1.0" +--- + +# SecOps Autonomous Remediation (Google ADK) + +## Overview +This skill enables a SecOps agent to monitor GitHub security alerts for an +allowlist of repositories, generate remediation plans, and open pull requests +using credentials stored in SHADI. The agent is designed to run locally inside +SHADI sandbox constraints. + +## Scope +- Repo allowlist only (no org-wide access). +- Security signals: Dependabot alerts (open). +- Actions: report, triage, plan; optional PR creation for safe upgrades. +- Human-in-the-loop: required before merge. + +## Inputs +- SHADI secrets + - GitHub token: `secops/github_token` + - Workspace dir: `secops/workspace_dir` + - LLM provider: `secops/llm/provider` (prefer `openai`) + - LLM keys under `secops/llm/`: + - OpenAI (also used for Google proxy): `openai_api_key`, `openai_endpoint`, `openai_model` + - Google (proxy-native): `google_api_key`, `google_endpoint`, `google_model` + - Anthropic (proxy-native): `claude_api_key`, `claude_endpoint`, `claude_model` + - OpenAI/Azure: `azure_openai_api_key`, `azure_openai_endpoint`, `azure_openai_deployment_name`, `azure_openai_api_version` +- Config: secops.toml + - `secops.allowlist` + - `github.api_base` + - SLIM A2A: + - `secops.slim_identity` + - `secops.slim_endpoint` + - `secops.slim_shared_secret_key` + - `secops.slim_local_did_key` + - `secops.slim_remote_did_key` + - `secops.slim_tls_insecure` +- Environment + - `SHADI_OPERATOR_PRESENTATION` (required) + - `SHADI_HUMAN_GITHUB` (required for remediation PRs) + - Human DID stored in SHADI at `github//did` + +## Outputs +- `secops_security_report.md` in the workspace directory. +- Optional remediation PRs (if enabled), including Docker base image updates and `uv.lock` bumps when Dependabot provides patched versions. +- Optional remediation issue per repo (when PRs are created). +- Optional human DID metadata added to the report. +- Optional SLIM A2A interface for human-in-the-loop commands. + +## Preconditions +- SHADI Python extension installed in the runtime environment. +- GitHub token has minimal required scopes for the allowlisted repos. +- Operator presentation is set in `SHADI_OPERATOR_PRESENTATION`. + +## Permissions +- GitHub API: read Dependabot alerts. +- GitHub repo access: read contents, create issues, create PRs (when enabled). +- Fork access: create and update forks under `SHADI_HUMAN_GITHUB`. +- `gh` CLI installed for PR/issue creation; uses `GH_TOKEN` from SHADI (no interactive login required). + +## Safety & Policy +- Never operate on repos outside the allowlist. +- Do not merge PRs automatically. +- No secrets should be written to disk. +- All actions must be logged in the workspace report. + +## Workflow +1. Load config and SHADI secrets. +2. Fetch open Dependabot alerts for each allowlisted repo. +3. Write report Markdown in the workspace, including remediation plans per repo. +4. (Optional) Resolve human DID from `github//did` using `SHADI_HUMAN_GITHUB` and attach it to the report. +5. (Optional) For critical alerts, attempt dependency updates and open PRs; if no patch is available, document the block. +6. (Optional) If PRs are created: + - Create a remediation issue on the upstream repo. + - Ensure a fork exists under `SHADI_HUMAN_GITHUB` and sync it with upstream. + - Commit changes on the fork using Conventional Commits and `--signoff`. + - Open a PR from the fork and bind it to the issue using `Fixes #`. +7. (Optional) If human approval is required, store pending PR data in `secops_pending_prs.json` for later approval. + +## Long-running operation +For continuous monitoring, run the agent on a schedule and retain memory: + +### Short-term memory +- Keep recent alert summaries in process memory. +- Reset on restart to avoid stale state. + +### Long-term memory +- Persist summaries to the allowlisted workspace directory. +- Store remediation history in a local file or external store allowed by policy. +- The ADK agent uses `PreloadMemoryTool` and `load_memory` to recall prior runs. +- Sessions are saved to ADK memory automatically after each run. + +### Continuous runner +```python +import time + +POLL_SECONDS = 900 + +while True: + # invoke the skill or ADK agent + time.sleep(POLL_SECONDS) +``` + +Ensure the sandbox policy allows workspace read/write and network access to +GitHub and the ADK model endpoint. + +### OpenAI proxy for Google/Claude +If your provider is behind an OpenAI-compatible proxy, set the native provider +keys (`GOOGLE_*` or `CLAUDE_*`). The SecOps loader will mirror them into +`openai_*` secrets so ADK runs through the OpenAI client. For example: + +``` +GOOGLE_MODEL=vertex_ai/gemini-3-pro-preview +GOOGLE_ENDPOINT=https://litellm.prod.outshift.ai +GOOGLE_API_KEY=sk-... +``` + +To use persistent ADK memory (Vertex AI Memory Bank), run the ADK agent with a +memory service URI: + +```bash +adk run agents/secops/adk_agent --memory_service_uri "agentengine://YOUR_ENGINE_ID" +``` + +## Example run +```bash +export GITHUB_TOKEN="$(gh auth token)" +export SHADI_OPERATOR_PRESENTATION="local-operator" +uv run agents/secops/import_secops_secrets.py +uv run agents/secops/secops.py + +# Enable automated remediation + PRs +uv run agents/secops/secops.py --remediate + +# Approve pending PRs after human review +uv run agents/secops/secops.py --approve-prs +``` + +## SLIM A2A interface (human-in-the-loop) +Start the A2A server on a SLIM channel: + +```bash +./scripts/launch_secops_a2a.sh +``` + +Send commands over SLIM A2A using the common secure channel. Supported commands: +- `scan` +- `remediate` +- `approve_prs` +- `report` + +Each command can be a JSON payload with optional fields: `provider`, `labels`, `report_name`. + +## ADK agent usage +Install ADK: + +```bash +uv pip install google-adk +``` + +Run the agent: + +```bash +adk run agents/secops/adk_agent +``` + +For a local-only run that uses ADK's in-memory memory service explicitly: + +```bash +uv run agents/secops/adk_agent/run_local.py +``` + +## Encrypted local memory (SQLCipher) +The SecOps agent uses the Python bindings (`SqlCipherMemoryStore`) for the +encrypted store. Use the helper below to inspect or seed entries without +exporting keys. + +```bash +cargo run -p shadictl -- -- memory init --db "$SHADI_SECOPS_MEMORY_DB" +``` + +Set the database path: + +```bash +export SHADI_TMP_DIR="./.tmp" +export SHADI_SECOPS_MEMORY_DB="$SHADI_TMP_DIR/${SHADI_AGENT_ID:-secops_agent}/shadi-secops/secops_memory.db" +``` + +Always reuse the same full path when listing or searching; using a relative +path like `secops_memory.db` will point to a different database. + +Store a summary: + +```bash +cargo run -p shadictl -- -- memory put --db "$SHADI_SECOPS_MEMORY_DB" \ + --key-name secops/memory_key \ + --scope secops --entry-key security_report --payload '{"status":"ok"}' +``` + +List memory: + +```bash +cargo run -p shadictl -- -- memory list --db "$SHADI_SECOPS_MEMORY_DB" \ + --key-name secops/memory_key \ + --scope secops +``` + +## Example report +```markdown +## Executive summary +- No critical vulnerabilities detected across 4 repositories. +- 0 Dependabot alerts and 0 labeled security issues. + +## Critical vulnerabilities +- None. + +## Remediation plan +- No remediation required at this time. + +## Risk notes +- Continue monitoring Dependabot alerts and security-labeled issues. +``` + +## Notes for ADK integration +- Use this skill as a tool in an ADK agent plan. +- Bind the allowlist and token key names from secops.toml. +- Require operator confirmation before opening PRs. diff --git a/agents/secops/a2a_server.py b/agents/secops/a2a_server.py new file mode 100644 index 0000000..c60dde3 --- /dev/null +++ b/agents/secops/a2a_server.py @@ -0,0 +1,331 @@ +import argparse +import asyncio +import json +import logging +import os +from pathlib import Path + +from skills import ( + approve_pending_prs, + create_secops_session, + get_secops_credentials, + load_secops_config, + require_shadi_secret_value, + resolve_tmp_dir, + skill_collect_security_issues, +) + + +def require_slima2a_packages(): + try: + import slimrpc + from a2a.server.agent_execution import AgentExecutor, RequestContext + from a2a.server.events import EventQueue + from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.tasks import InMemoryTaskStore + from a2a.types import AgentCapabilities, AgentCard, AgentSkill + from a2a.types import TaskArtifactUpdateEvent, TaskState, TaskStatus, TaskStatusUpdateEvent + from a2a.utils import new_text_artifact + from slima2a.handler import SRPCHandler + from slima2a.types.a2a_pb2_slimrpc import add_A2AServiceServicer_to_server + except ImportError as exc: + raise RuntimeError( + "Missing SLIM A2A dependencies. Install: uv pip install slima2a slimrpc a2a" + ) from exc + + return { + "slimrpc": slimrpc, + "AgentExecutor": AgentExecutor, + "RequestContext": RequestContext, + "EventQueue": EventQueue, + "DefaultRequestHandler": DefaultRequestHandler, + "InMemoryTaskStore": InMemoryTaskStore, + "AgentCapabilities": AgentCapabilities, + "AgentCard": AgentCard, + "AgentSkill": AgentSkill, + "TaskArtifactUpdateEvent": TaskArtifactUpdateEvent, + "TaskState": TaskState, + "TaskStatus": TaskStatus, + "TaskStatusUpdateEvent": TaskStatusUpdateEvent, + "new_text_artifact": new_text_artifact, + "SRPCHandler": SRPCHandler, + "add_A2AServiceServicer_to_server": add_A2AServiceServicer_to_server, + } + + +def parse_command(raw): + if not raw: + return {"command": "scan"} + raw = raw.strip() + try: + data = json.loads(raw) + if isinstance(data, dict): + return data + except json.JSONDecodeError: + pass + return {"command": raw.lower()} + + +async def emit_text(event_queue, context, text, types): + message = types["TaskArtifactUpdateEvent"]( + context_id=context.context_id, + task_id=context.task_id, + artifact=types["new_text_artifact"](name="secops_update", text=text), + ) + await event_queue.enqueue_event(message) + + +async def emit_status(event_queue, context, state, types, final=False): + status = types["TaskStatusUpdateEvent"]( + context_id=context.context_id, + task_id=context.task_id, + status=types["TaskStatus"](state=state), + final=final, + ) + await event_queue.enqueue_event(status) + + +async def run_scan(command): + provider = command.get("provider") + labels = command.get("labels", "security,cve,vulnerability") + report_name = command.get("report_name", "secops_security_report.md") + return await asyncio.to_thread( + skill_collect_security_issues, + labels=labels, + report_name=report_name, + provider=provider, + remediate=False, + create_prs=False, + ) + + +async def run_remediate(command): + provider = command.get("provider") + labels = command.get("labels", "security,cve,vulnerability") + report_name = command.get("report_name", "secops_security_report.md") + return await asyncio.to_thread( + skill_collect_security_issues, + labels=labels, + report_name=report_name, + provider=provider, + remediate=True, + create_prs=False, + ) + + +async def run_approve_prs(): + config_path, config = load_secops_config() + store, session = create_secops_session() + github_token, workspace, _, _ = get_secops_credentials(config, store, session) + return await asyncio.to_thread(approve_pending_prs, config, github_token, workspace) + + +def resolve_workspace_dir(secops_config): + tmp_dir = resolve_tmp_dir(("SHADI_OPERATOR_AGENT_ID", "SHADI_SECOPS_AGENT_ID")) + return secops_config.get("workspace_dir", str(Path(tmp_dir) / "shadi-secops")) + + +async def run_get_report(command): + config_path, config = load_secops_config() + workspace = resolve_workspace_dir(config.get("secops", {})) + report_name = command.get("report_name", "secops_security_report.md") + report_path = Path(workspace) / report_name + if not report_path.exists(): + return {"status": "missing_report", "path": str(report_path)} + text = report_path.read_text(encoding="utf-8") + return {"status": "ok", "path": str(report_path), "report": text} + + +async def run_status(): + config_path, config = load_secops_config() + secops_config = config.get("secops", {}) + return { + "status": "ok", + "config": str(config_path), + "slim_endpoint": secops_config.get("slim_endpoint", "http://localhost:47357"), + "slim_identity": secops_config.get("slim_identity", "agntcy/secops/agent"), + "slim_tls_insecure": bool(secops_config.get("slim_tls_insecure", True)), + "workspace_dir": resolve_workspace_dir(secops_config), + "allowlist": secops_config.get("allowlist", []), + } + + +async def run_allowlist(): + config_path, config = load_secops_config() + allowlist = config.get("secops", {}).get("allowlist", []) + return {"status": "ok", "allowlist": allowlist} + + +def build_agent_card(types): + skill = types["AgentSkill"]( + id="secops", + name="secops", + description="SecOps agent with scan/remediate/approve/report/status commands.", + tags=["secops", "security", "remediation"], + examples=["help", "scan", "remediate", "approve_prs", "report", "status"], + ) + return types["AgentCard"]( + name="secops_agent", + description="SecOps autonomous remediation agent", + url="http://localhost:10001/", + version="1.0.0", + default_input_modes=["text"], + default_output_modes=["text"], + capabilities=types["AgentCapabilities"](streaming=True), + skills=[skill], + ) + + +def resolve_slim_config(): + config_path, config = load_secops_config() + secops_config = config.get("secops", {}) + store, session = create_secops_session() + secret_key = secops_config.get("slim_shared_secret_key", "secops/slim_shared_secret") + shared_secret = require_shadi_secret_value( + store, + session, + secret_key, + "SLIM shared secret", + ) + tls_insecure = bool(secops_config.get("slim_tls_insecure", True)) + tls_cert = os.getenv("SLIM_TLS_CERT", "").strip() + tls_key = os.getenv("SLIM_TLS_KEY", "").strip() + tls_ca = os.getenv("SLIM_TLS_CA", "").strip() + if not tls_insecure and (not tls_cert or not tls_key or not tls_ca): + raise RuntimeError("SLIM mTLS requires SLIM_TLS_CERT, SLIM_TLS_KEY, and SLIM_TLS_CA") + return { + "identity": secops_config.get("slim_identity", "agntcy/secops/agent"), + "endpoint": secops_config.get("slim_endpoint", "http://localhost:47357"), + "shared_secret": shared_secret, + "insecure": tls_insecure, + "tls_cert": tls_cert, + "tls_key": tls_key, + "tls_ca": tls_ca, + } + + +def create_executor(types): + AgentExecutor = types["AgentExecutor"] + + class SecopsExecutor(AgentExecutor): + async def execute(self, context, event_queue): + command = parse_command(context.get_user_input()) + await emit_status(event_queue, context, types["TaskState"].working, types) + await emit_text(event_queue, context, f"Command: {command}", types) + try: + if command.get("command") in ("scan", "run_scan"): + result = await run_scan(command) + elif command.get("command") in ("remediate", "run_remediate"): + result = await run_remediate(command) + elif command.get("command") in ("approve_prs", "approve"): + result = await run_approve_prs() + elif command.get("command") in ("report", "get_report"): + result = await run_get_report(command) + elif command.get("command") in ("status", "info"): + result = await run_status() + elif command.get("command") in ("allowlist", "repos"): + result = await run_allowlist() + elif command.get("command") in ("help", "commands"): + result = { + "commands": { + "scan": { + "description": "Collect Dependabot alerts and security issues.", + "payload": { + "command": "scan", + "labels": "security,cve,vulnerability", + "provider": "(optional: override LLM provider)", + "report_name": "secops_security_report.md", + }, + }, + "remediate": { + "description": "Run remediation planning without opening PRs.", + "payload": { + "command": "remediate", + "labels": "security,cve,vulnerability", + "provider": "(optional: override LLM provider)", + "report_name": "secops_security_report.md", + }, + }, + "approve_prs": { + "description": "Approve and finalize pending remediation PRs.", + "payload": {"command": "approve_prs"}, + }, + "report": { + "description": "Return the latest report content.", + "payload": { + "command": "report", + "report_name": "secops_security_report.md", + }, + }, + "status": { + "description": "Show SLIM + workspace configuration and allowlist.", + "payload": {"command": "status"}, + }, + "allowlist": { + "description": "List allowlisted repositories.", + "payload": {"command": "allowlist"}, + }, + }, + "notes": "Payloads can be plain text commands or JSON objects.", + } + else: + result = {"status": "unknown_command", "command": command} + await emit_text(event_queue, context, json.dumps(result, indent=2), types) + await emit_status(event_queue, context, types["TaskState"].completed, types, final=True) + except Exception as exc: + await emit_text(event_queue, context, f"error: {exc}", types) + await emit_status(event_queue, context, types["TaskState"].failed, types, final=True) + + async def cancel(self, context, event_queue): + await emit_text(event_queue, context, "cancel not supported", types) + await emit_status(event_queue, context, types["TaskState"].failed, types, final=True) + + return SecopsExecutor() + + +async def main(): + logging.getLogger("a2a.utils.telemetry").setLevel(logging.ERROR) + logging.getLogger("asyncio").setLevel(logging.ERROR) + + types = require_slima2a_packages() + slimrpc = types["slimrpc"] + + slim_config = resolve_slim_config() + agent_card = build_agent_card(types) + + request_handler = types["DefaultRequestHandler"]( + agent_executor=create_executor(types), + task_store=types["InMemoryTaskStore"](), + ) + + servicer = types["SRPCHandler"](agent_card, request_handler) + tls_config = { + "insecure": bool(slim_config["insecure"]), + } + if not slim_config["insecure"]: + tls_config["source"] = { + "type": "file", + "cert": slim_config["tls_cert"], + "key": slim_config["tls_key"], + } + tls_config["ca_source"] = { + "type": "file", + "path": slim_config["tls_ca"], + } + + server = await slimrpc.Server.from_slim_app_config( + slim_app_config=slimrpc.SLIMAppConfig( + identity=slim_config["identity"], + slim_client_config={ + "endpoint": slim_config["endpoint"], + "tls": tls_config, + }, + shared_secret=slim_config["shared_secret"], + ) + ) + types["add_A2AServiceServicer_to_server"](servicer, server) + await server.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/secops/adk_agent/__init__.py b/agents/secops/adk_agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/secops/adk_agent/agent.py b/agents/secops/adk_agent/agent.py new file mode 100644 index 0000000..f84ff23 --- /dev/null +++ b/agents/secops/adk_agent/agent.py @@ -0,0 +1,72 @@ +import os +import sys +from pathlib import Path + +from google.adk.agents.llm_agent import Agent +from google.adk.tools import load_memory +from google.adk.tools.preload_memory_tool import PreloadMemoryTool + +SECOPS_DIR = Path(__file__).resolve().parents[1] +sys.path.append(str(SECOPS_DIR)) + +from skills import get_llm_settings, load_secops_config, skill_collect_security_issues + + +def load_agent_context() -> str: + parts = [] + for filename in ("AGENTS.md", "SKILL.md"): + path = SECOPS_DIR / filename + if not path.exists(): + continue + try: + content = path.read_text(encoding="utf-8").strip() + except OSError: + continue + if content: + parts.append(content) + return "\n\n".join(parts) + + +async def auto_save_session_to_memory(callback_context): + invocation = callback_context._invocation_context + memory_service = invocation.memory_service + session = invocation.session + if memory_service is None or session is None: + return + await memory_service.add_session_to_memory(session) + + +config_path, config = load_secops_config() +llm_settings = get_llm_settings(config) + +if llm_settings["provider"] == "google" and not llm_settings.get("openai_proxy"): + os.environ["GOOGLE_API_KEY"] = llm_settings["api_key"] + os.environ["ADK_GOOGLE_API_KEY_SOURCE"] = "shadi" +else: + os.environ["OPENAI_API_KEY"] = llm_settings["api_key"] + if llm_settings.get("base_url"): + os.environ["OPENAI_BASE_URL"] = llm_settings["base_url"] + +model = os.getenv("ADK_MODEL") or llm_settings.get("adk_model") or llm_settings.get("model") +if not model: + model = config.get("adk", {}).get("model", "gemini-3-flash-preview") + +base_instruction = ( + "You are a SecOps agent. Use the collect_security_issues tool to gather open security " + "issues and Dependabot alerts for the allowlisted repos. Use memory tools to recall " + "prior summaries if they are relevant. Summarize counts and provide the report path." +) +context = load_agent_context() +if context: + instruction = f"{base_instruction}\n\nContext:\n{context}" +else: + instruction = base_instruction + +root_agent = Agent( + model=model, + name="secops_agent", + description="Collects security issues and Dependabot alerts for allowlisted repos.", + instruction=instruction, + tools=[PreloadMemoryTool(), load_memory, skill_collect_security_issues], + after_agent_callback=auto_save_session_to_memory, +) diff --git a/agents/secops/adk_agent/run_local.py b/agents/secops/adk_agent/run_local.py new file mode 100644 index 0000000..846583c --- /dev/null +++ b/agents/secops/adk_agent/run_local.py @@ -0,0 +1,85 @@ +import asyncio +import os +import sys +from pathlib import Path + +from google.adk.memory import InMemoryMemoryService +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService +from google.genai import types + +from agent import root_agent + +ROOT_DIR = Path(__file__).resolve().parents[3] +sys.path.append(str(ROOT_DIR)) + +from agents.secops.skills import load_secops_config +from agents.shared.shadi_adk_memory import ShadiBackedMemoryService + +APP_NAME = "shadi_secops" +USER_ID = os.getenv("SECOPS_USER_ID", "local-user") +SESSION_ID = os.getenv("SECOPS_SESSION_ID", "secops-session") +DEFAULT_QUERY = "Collect security issues for the allowlisted repos." + + +def build_query(): + if len(sys.argv) > 1: + return " ".join(sys.argv[1:]) + return DEFAULT_QUERY + + +async def run_local(query): + session_service = InMemorySessionService() + config_path, config = load_secops_config() + secops_config = config.get("secops", {}) + tmp_dir = os.getenv("SHADI_TMP_DIR", "./.tmp") + agent_id = ( + os.getenv("SHADI_AGENT_ID") + or os.getenv("SHADI_OPERATOR_AGENT_ID") + or os.getenv("SHADI_SECOPS_AGENT_ID") + ) + if agent_id: + tmp_dir = str(Path(tmp_dir) / agent_id) + default_db = str(Path(tmp_dir) / "shadi-secops" / "secops_memory.db") + memory_db = ( + os.getenv("SHADI_ADK_MEMORY_DB") + or secops_config.get("memory_db") + or default_db + ) + memory_key = secops_config.get("memory_key", "secops/memory_key") + memory_scope = secops_config.get("memory_scope", "secops") + memory_entry_key = f"adk_memory/{APP_NAME}/{USER_ID}" + memory_service = ShadiBackedMemoryService( + app_name=APP_NAME, + user_id=USER_ID, + db_path=memory_db, + key_name=memory_key, + scope=memory_scope, + entry_key=memory_entry_key, + ) + + await session_service.create_session( + app_name=APP_NAME, + user_id=USER_ID, + session_id=SESSION_ID, + ) + + runner = Runner( + agent=root_agent, + app_name=APP_NAME, + session_service=session_service, + memory_service=memory_service, + ) + + content = types.Content(role="user", parts=[types.Part(text=query)]) + async for event in runner.run_async( + user_id=USER_ID, + session_id=SESSION_ID, + new_message=content, + ): + if event.is_final_response() and event.content and event.content.parts: + print(event.content.parts[0].text.strip()) + + +if __name__ == "__main__": + asyncio.run(run_local(build_query())) diff --git a/agents/secops/import_secops_secrets.py b/agents/secops/import_secops_secrets.py new file mode 100644 index 0000000..629963d --- /dev/null +++ b/agents/secops/import_secops_secrets.py @@ -0,0 +1,149 @@ +import os +import secrets +import tomllib +from pathlib import Path + +from shadi import ShadiStore, PySessionContext + + +def load_secops_config(): + config_path = Path(os.getenv("SHADI_SECOPS_CONFIG", "secops.toml")) + if not config_path.exists(): + return config_path, {} + with config_path.open("rb") as handle: + return config_path, tomllib.load(handle) + + +def main(): + config_path, config = load_secops_config() + secops_config = config.get("secops", {}) + token_key = secops_config.get("token_key", "secops/github_token") + workspace_key = secops_config.get("workspace_key", "secops/workspace_dir") + tmp_dir = os.getenv("SHADI_TMP_DIR", "./.tmp") + agent_id = ( + os.getenv("SHADI_AGENT_ID") + or os.getenv("SHADI_OPERATOR_AGENT_ID") + or os.getenv("SHADI_SECOPS_AGENT_ID") + ) + if agent_id: + tmp_dir = str(Path(tmp_dir) / agent_id) + workspace_dir = secops_config.get("workspace_dir", str(Path(tmp_dir) / "shadi-secops")) + llm_key_prefix = secops_config.get("llm_key_prefix", "secops/llm") + llm_provider = secops_config.get("llm_provider", "google") + memory_key_name = secops_config.get("memory_key", "secops/memory_key") + slim_shared_secret_key = secops_config.get("slim_shared_secret_key", "secops/slim_shared_secret") + slim_local_did_key = secops_config.get("slim_local_did_key", "secops/slim_local_did") + slim_remote_did_key = secops_config.get("slim_remote_did_key", "secops/slim_remote_did") + + github_token = os.getenv("GITHUB_TOKEN", "").strip() + if not github_token: + raise RuntimeError( + "GITHUB_TOKEN is required. Set it with: export GITHUB_TOKEN=\"$(gh auth token)\"" + ) + + agent_id = os.getenv("SHADI_OPERATOR_AGENT_ID", "secops_agent") + presentation = os.getenv("SHADI_OPERATOR_PRESENTATION", "").encode("utf-8") + if not presentation: + raise RuntimeError("SHADI_OPERATOR_PRESENTATION must be set") + + store = ShadiStore() + session = PySessionContext(agent_id, "secops-bootstrap-1") + + def verify_operator(verify_agent_id, session_id, presentation_bytes, claims): + return verify_agent_id == agent_id and len(presentation_bytes) > 0 + + store.set_verifier(verify_operator) + ok = store.verify_session(session, presentation) + if not ok: + raise RuntimeError("SecOps verification failed") + + store.put(session, token_key, github_token.encode("utf-8")) + store.put(session, workspace_key, workspace_dir.encode("utf-8")) + + llm_env_map = { + "AZURE_OPENAI_API_KEY": "azure_openai_api_key", + "AZURE_OPENAI_API_VERSION": "azure_openai_api_version", + "AZURE_OPENAI_ENDPOINT": "azure_openai_endpoint", + "AZURE_OPENAI_DEPLOYMENT_NAME": "azure_openai_deployment_name", + "CLAUDE_API_KEY": "claude_api_key", + "CLAUDE_ENDPOINT": "claude_endpoint", + "CLAUDE_MODEL": "claude_model", + "OPENAI_API_KEY": "openai_api_key", + "OPENAI_ENDPOINT": "openai_endpoint", + "OPENAI_MODEL": "openai_model", + "GOOGLE_API_KEY": "google_api_key", + "GOOGLE_ENDPOINT": "google_endpoint", + "GOOGLE_MODEL": "google_model", + } + if llm_provider in ("anthropic", "claude"): + openai_fallbacks = { + "OPENAI_API_KEY": "CLAUDE_API_KEY", + "OPENAI_ENDPOINT": "CLAUDE_ENDPOINT", + "OPENAI_MODEL": "CLAUDE_MODEL", + } + elif llm_provider == "google": + openai_fallbacks = { + "OPENAI_API_KEY": "GOOGLE_API_KEY", + "OPENAI_ENDPOINT": "GOOGLE_ENDPOINT", + "OPENAI_MODEL": "GOOGLE_MODEL", + } + else: + openai_fallbacks = {} + missing = [] + for env_key, suffix in llm_env_map.items(): + value = os.getenv(env_key, "").strip() + source_env = env_key + if not value and env_key in openai_fallbacks: + fallback_key = openai_fallbacks[env_key] + value = os.getenv(fallback_key, "").strip() + if value: + source_env = fallback_key + if value: + store.put(session, f"{llm_key_prefix}/{suffix}", value.encode("utf-8")) + if source_env == env_key: + print("Stored LLM secret in SHADI:", f"{llm_key_prefix}/{suffix}") + else: + print( + "Stored LLM secret in SHADI:", + f"{llm_key_prefix}/{suffix}", + f"(from {source_env})", + ) + else: + if env_key.startswith("OPENAI_") and llm_provider not in ("openai", "azure", "azure_openai"): + continue + missing.append(env_key) + + store.put(session, f"{llm_key_prefix}/provider", llm_provider.encode("utf-8")) + print("Stored LLM provider in SHADI:", f"{llm_key_prefix}/provider") + + memory_key = os.getenv("SECOPS_MEMORY_KEY", "").strip() + if not memory_key: + memory_key = secrets.token_urlsafe(32) + store.put(session, memory_key_name, memory_key.encode("utf-8")) + print("Stored memory key in SHADI:", memory_key_name) + + slim_shared_secret = os.getenv("SLIM_SHARED_SECRET", "").strip() + if slim_shared_secret: + store.put(session, slim_shared_secret_key, slim_shared_secret.encode("utf-8")) + print("Stored SLIM shared secret in SHADI:", slim_shared_secret_key) + + slim_local_did = os.getenv("SLIM_LOCAL_DID", "").strip() + if slim_local_did: + store.put(session, slim_local_did_key, slim_local_did.encode("utf-8")) + print("Stored SLIM local DID in SHADI:", slim_local_did_key) + + slim_remote_did = os.getenv("SLIM_REMOTE_DID", "").strip() + if slim_remote_did: + store.put(session, slim_remote_did_key, slim_remote_did.encode("utf-8")) + print("Stored SLIM remote DID in SHADI:", slim_remote_did_key) + + print("Stored GitHub token in SHADI:", token_key) + print("Stored workspace dir in SHADI:", workspace_key) + print("Using config:", config_path) + if missing: + print("Missing LLM env vars:", ", ".join(missing)) + print("Hint: source ~/.env-phoenix before running this script") + + +if __name__ == "__main__": + main() diff --git a/agents/secops/pyproject.toml b/agents/secops/pyproject.toml new file mode 100644 index 0000000..e5ba4eb --- /dev/null +++ b/agents/secops/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "shadi-secops" +version = "0.1.0" +description = "SecOps tooling for SHADI" +requires-python = ">=3.12" +dependencies = [ + "openai", + "maturin", + "slima2a", + "slimrpc", + "a2a", +] + +[build-system] +requires = ["maturin>=1.0"] +build-backend = "maturin" diff --git a/agents/secops/secops.py b/agents/secops/secops.py new file mode 100644 index 0000000..ceff835 --- /dev/null +++ b/agents/secops/secops.py @@ -0,0 +1,72 @@ +import argparse +import os + +from skills import skill_collect_security_issues + + +def parse_args(): + parser = argparse.ArgumentParser(description="Run the SHADI SecOps agent") + parser.add_argument( + "--provider", + choices=["google", "azure", "anthropic"], + help="Override the LLM provider from SHADI (google, azure, anthropic)", + ) + parser.add_argument( + "--labels", + default="security,cve,vulnerability", + help="Comma-separated GitHub issue labels", + ) + parser.add_argument( + "--report-name", + default="secops_security_report.md", + help="Report filename in the workspace directory", + ) + parser.add_argument( + "--remediate", + action="store_true", + help="Attempt to patch critical vulnerabilities and open PRs", + ) + parser.add_argument( + "--approve-prs", + action="store_true", + help="Create PRs from pending remediation approvals", + ) + return parser.parse_args() + + +def run_secops_agent(): + print("== SHADI SecOps Autonomous Agent ==") + args = parse_args() + human_github = os.getenv("SHADI_HUMAN_GITHUB", "").strip() + if args.approve_prs: + from skills import approve_pending_prs, create_secops_session, get_secops_credentials, load_secops_config + + config_path, config = load_secops_config() + store, session = create_secops_session() + github_token, workspace, _, _ = get_secops_credentials(config, store, session) + result = approve_pending_prs(config, github_token, workspace) + print("Status: approved") + print("Result:", result) + return + + result = skill_collect_security_issues( + labels=args.labels, + report_name=args.report_name, + provider=args.provider, + remediate=args.remediate, + create_prs=False, + human_github_handle=human_github or None, + ) + print("Status:", result.get("status")) + print("Report:", result.get("report_path")) + print("Dependabot alerts:", result.get("dependabot_alerts")) + print("Labeled issues:", result.get("labeled_issues")) + print("Repos:", ", ".join(result.get("repos", []))) + if result.get("memory") is not None: + print("Memory:", result.get("memory")) + if result.get("human_did"): + print("Human DID:", result.get("human_did")) + + +if __name__ == "__main__": + run_secops_agent() diff --git a/agents/secops/skills.py b/agents/secops/skills.py new file mode 100644 index 0000000..ef0d1fb --- /dev/null +++ b/agents/secops/skills.py @@ -0,0 +1,989 @@ +import base64 +import json +import os +import re +import shutil +import subprocess +import tomllib +import time +from datetime import datetime, timezone +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from shadi import ShadiStore, PySessionContext, SqlCipherMemoryStore + + +def load_secops_config(): + config_path = Path(os.getenv("SHADI_SECOPS_CONFIG", "secops.toml")) + if not config_path.exists(): + return config_path, {} + with config_path.open("rb") as handle: + return config_path, tomllib.load(handle) + + +def require_shadi_secret(store, session, key_name, label): + try: + return store.get(session, key_name) + except Exception as exc: + message = ( + f"Missing {label} in SHADI at key '{key_name}'. " + "Run: uv run agents/secops/import_secops_secrets.py" + ) + raise RuntimeError(message) from exc + + +def create_secops_session(): + store = ShadiStore() + agent_id = os.getenv("SHADI_OPERATOR_AGENT_ID", "secops_agent") + presentation = os.getenv("SHADI_OPERATOR_PRESENTATION", "").encode("utf-8") + if not presentation: + raise RuntimeError("SHADI_OPERATOR_PRESENTATION must be set") + session = PySessionContext(agent_id, "secops-session-1") + + def verify_operator(verify_agent_id, session_id, presentation_bytes, claims): + return verify_agent_id == agent_id and len(presentation_bytes) > 0 + + store.set_verifier(verify_operator) + ok = store.verify_session(session, presentation) + if not ok: + raise RuntimeError("SecOps verification failed") + return store, session + + +def get_secops_credentials(config, store, session): + secops_config = config.get("secops", {}) + token_key = secops_config.get("token_key", "secops/github_token") + workspace_key = secops_config.get("workspace_key", "secops/workspace_dir") + github_token = require_shadi_secret(store, session, token_key, "GitHub token").decode( + "utf-8" + ) + workspace = require_shadi_secret(store, session, workspace_key, "workspace dir").decode( + "utf-8" + ) + return github_token, workspace, token_key, workspace_key + + +def require_shadi_secret_value(store, session, key_name, label): + value = require_shadi_secret(store, session, key_name, label).decode("utf-8").strip() + if not value: + raise RuntimeError(f"Missing {label} in SHADI at key '{key_name}'.") + return value + + +def get_optional_shadi_secret_value(store, session, key_name): + try: + value = store.get(session, key_name).decode("utf-8").strip() + except Exception: + return "" + return value + + +def get_human_did(store, session, github_handle): + key_name = f"github/{github_handle}/did" + return require_shadi_secret_value(store, session, key_name, "human DID") + + +def get_llm_settings(config, store=None, session=None, provider_override=None): + if store is None or session is None: + store, session = create_secops_session() + secops_config = config.get("secops", {}) + llm_prefix = secops_config.get("llm_key_prefix", "secops/llm") + if provider_override: + provider = provider_override.strip().lower() + else: + provider_key = f"{llm_prefix}/provider" + provider = require_shadi_secret_value(store, session, provider_key, "LLM provider").lower() + + use_openai_proxy = False + openai_api_key_key = f"{llm_prefix}/openai_api_key" + if provider in ("google", "anthropic", "claude"): + if get_optional_shadi_secret_value(store, session, openai_api_key_key): + model_key = f"{llm_prefix}/openai_model" + endpoint_key = f"{llm_prefix}/openai_endpoint" + api_key_key = openai_api_key_key + api_version_key = None + use_openai_proxy = True + elif provider == "google": + model_key = f"{llm_prefix}/google_model" + endpoint_key = f"{llm_prefix}/google_endpoint" + api_key_key = f"{llm_prefix}/google_api_key" + api_version_key = None + else: + model_key = f"{llm_prefix}/claude_model" + endpoint_key = f"{llm_prefix}/claude_endpoint" + api_key_key = f"{llm_prefix}/claude_api_key" + api_version_key = None + elif provider == "openai": + model_key = f"{llm_prefix}/openai_model" + endpoint_key = f"{llm_prefix}/openai_endpoint" + api_key_key = f"{llm_prefix}/openai_api_key" + api_version_key = None + elif provider in ("azure", "azure_openai"): + model_key = f"{llm_prefix}/azure_openai_deployment_name" + endpoint_key = f"{llm_prefix}/azure_openai_endpoint" + api_key_key = f"{llm_prefix}/azure_openai_api_key" + api_version_key = f"{llm_prefix}/azure_openai_api_version" + else: + raise RuntimeError(f"Unsupported LLM provider in SHADI: '{provider}'") + + model_name = require_shadi_secret_value(store, session, model_key, "LLM model") + if provider == "openai": + base_url = get_optional_shadi_secret_value(store, session, endpoint_key) + else: + base_url = require_shadi_secret_value(store, session, endpoint_key, "LLM endpoint") + api_key = require_shadi_secret_value(store, session, api_key_key, "LLM API key") + api_version = "" + if api_version_key: + api_version = get_optional_shadi_secret_value(store, session, api_version_key) + + adk_model = model_name + if (provider == "openai" or use_openai_proxy) and "/" in model_name: + if not model_name.startswith("openai/"): + adk_model = f"openai/{model_name}" + + return { + "provider": provider, + "model": model_name, + "adk_model": adk_model, + "base_url": base_url, + "api_key": api_key, + "api_version": api_version, + "openai_proxy": use_openai_proxy, + } + + +def get_alert_severity(alert): + advisory = alert.get("security_advisory") or {} + vulnerability = alert.get("security_vulnerability") or {} + return (advisory.get("severity") or vulnerability.get("severity") or "").lower() + + +def is_actionable_alert(alert): + severity = get_alert_severity(alert) + return severity in ("critical", "high") + + +def get_patched_version(alert): + vulnerability = alert.get("security_vulnerability") or {} + first_patched = vulnerability.get("first_patched_version") or {} + if isinstance(first_patched, dict): + identifier = first_patched.get("identifier") + if identifier: + return identifier + return "" + + +def build_git_auth_header(token): + encoded = base64.b64encode(f"x-access-token:{token}".encode("utf-8")).decode("utf-8") + return f"AUTHORIZATION: basic {encoded}" + + +def run_git(args, cwd, token): + command = ["git"] + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + if token: + command.extend(["-c", f"http.extraheader={build_git_auth_header(token)}"]) + command.extend(args) + return subprocess.run( + command, + cwd=cwd, + check=True, + capture_output=True, + text=True, + env=env, + ) + + +def clone_or_update_repo(workspace_path, repo, token): + repo_dir = workspace_path / repo.replace("/", "__") + repo_url = f"https://github.com/{repo}.git" + if repo_dir.exists(): + status = run_git(["status", "--porcelain"], repo_dir, token) + if status.stdout.strip(): + return repo_dir, "dirty" + run_git(["fetch", "origin"], repo_dir, token) + base_ref = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], repo_dir, token) + base_branch = base_ref.stdout.strip().rsplit("/", 1)[-1] + run_git(["checkout", base_branch], repo_dir, token) + run_git(["reset", "--hard", f"origin/{base_branch}"], repo_dir, token) + return repo_dir, "updated" + run_git(["clone", repo_url, str(repo_dir)], workspace_path, token) + return repo_dir, "cloned" + + +def update_cargo_manifest(path, package_name, version): + if not path.exists(): + return False + updated = False + lines = path.read_text(encoding="utf-8").splitlines() + new_lines = [] + pattern_simple = re.compile(rf"^\s*{re.escape(package_name)}\s*=\s*\"[^\"]+\"\s*$") + pattern_table = re.compile( + rf"^\s*{re.escape(package_name)}\s*=\s*\{{[^}}]*version\s*=\s*\"[^\"]+\"[^}}]*\}}\s*$" + ) + for line in lines: + if pattern_simple.match(line): + new_lines.append(f"{package_name} = \"{version}\"") + updated = True + continue + if pattern_table.match(line): + new_line = re.sub( + r"version\s*=\s*\"[^\"]+\"", + f"version = \"{version}\"", + line, + ) + new_lines.append(new_line) + updated = True + continue + new_lines.append(line) + if updated: + path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + return updated + + +def update_package_json(path, package_name, version): + if not path.exists(): + return False + data = json.loads(path.read_text(encoding="utf-8")) + updated = False + for section in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"): + deps = data.get(section) + if not isinstance(deps, dict): + continue + if package_name not in deps: + continue + current = deps[package_name] + prefix = "" + if isinstance(current, str) and current[:1] in ("^", "~"): + prefix = current[:1] + deps[package_name] = f"{prefix}{version}" + updated = True + if updated: + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return updated + + +def update_dockerfile_base_image(path, image, version): + if not path.exists(): + return False + updated = False + lines = path.read_text(encoding="utf-8").splitlines() + new_lines = [] + pattern = re.compile( + rf"^\s*FROM\s+{re.escape(image)}(?::[^\s]+)?(?P\s+AS\s+\S+)?\s*$", + re.IGNORECASE, + ) + for line in lines: + match = pattern.match(line) + if match: + suffix = match.group("suffix") or "" + new_lines.append(f"FROM {image}:{version}{suffix}") + updated = True + else: + new_lines.append(line) + if updated: + path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + return updated + + +def is_conventional_commit(message): + pattern = re.compile(r"^(feat|fix|chore|docs|refactor|perf|test|build|ci|style|revert)(\([^)]+\))?: .+") + return bool(pattern.match(message)) + + +def apply_dependency_patch(repo_path, alert): + dependency = alert.get("dependency") or {} + package = dependency.get("package") or {} + ecosystem = (package.get("ecosystem") or "").lower() + name = package.get("name") or "" + manifest_path = dependency.get("manifest_path") or "" + patched_version = get_patched_version(alert) + if not patched_version: + return {"status": "no_patch", "dependency": name, "ecosystem": ecosystem} + + if ecosystem == "cargo": + manifest = repo_path / (manifest_path or "Cargo.toml") + updated = update_cargo_manifest(manifest, name, patched_version) + if not updated: + return {"status": "not_updated", "dependency": name, "ecosystem": ecosystem} + try: + run_git(["add", str(manifest)], repo_path, token=None) + cargo_args = [ + "cargo", + "update", + "-p", + name, + "--precise", + patched_version, + "--manifest-path", + str(manifest), + ] + subprocess.run(cargo_args, cwd=repo_path, check=True, capture_output=True, text=True) + run_git(["add", "Cargo.lock"], repo_path, token=None) + except subprocess.CalledProcessError as exc: + return { + "status": "update_failed", + "dependency": name, + "ecosystem": ecosystem, + "error": exc.stderr.strip(), + } + return {"status": "updated", "dependency": name, "ecosystem": ecosystem, "version": patched_version} + + if ecosystem == "npm": + manifest = repo_path / (manifest_path or "package.json") + updated = update_package_json(manifest, name, patched_version) + if not updated: + return {"status": "not_updated", "dependency": name, "ecosystem": ecosystem} + try: + run_git(["add", str(manifest)], repo_path, token=None) + npm_cwd = manifest.parent + subprocess.run( + ["npm", "install", "--package-lock-only"], + cwd=npm_cwd, + check=True, + capture_output=True, + text=True, + ) + lockfile = npm_cwd / "package-lock.json" + if lockfile.exists(): + run_git(["add", str(lockfile)], repo_path, token=None) + except subprocess.CalledProcessError as exc: + return { + "status": "update_failed", + "dependency": name, + "ecosystem": ecosystem, + "error": exc.stderr.strip(), + } + return {"status": "updated", "dependency": name, "ecosystem": ecosystem, "version": patched_version} + + if ecosystem in ("pip", "pip-compile", "python"): + if not manifest_path.endswith("uv.lock"): + return { + "status": "unsupported_manifest", + "dependency": name, + "ecosystem": ecosystem, + "manifest": manifest_path, + } + lock_path = repo_path / manifest_path + if not lock_path.exists(): + return { + "status": "missing_lockfile", + "dependency": name, + "ecosystem": ecosystem, + "manifest": manifest_path, + } + if not shutil.which("uv"): + return { + "status": "missing_uv", + "dependency": name, + "ecosystem": ecosystem, + } + try: + subprocess.run( + ["uv", "lock", "--upgrade-package", f"{name}=={patched_version}"], + cwd=lock_path.parent, + check=True, + capture_output=True, + text=True, + ) + run_git(["add", str(lock_path)], repo_path, token=None) + except subprocess.CalledProcessError as exc: + return { + "status": "update_failed", + "dependency": name, + "ecosystem": ecosystem, + "error": exc.stderr.strip(), + } + return {"status": "updated", "dependency": name, "ecosystem": ecosystem, "version": patched_version} + + if ecosystem in ("docker", "dockerfile"): + manifest = repo_path / (manifest_path or "Dockerfile") + updated = update_dockerfile_base_image(manifest, name, patched_version) + if not updated: + return { + "status": "not_updated", + "dependency": name, + "ecosystem": ecosystem, + "manifest": manifest_path, + } + run_git(["add", str(manifest)], repo_path, token=None) + return {"status": "updated", "dependency": name, "ecosystem": ecosystem, "version": patched_version} + + return {"status": "unsupported", "dependency": name, "ecosystem": ecosystem} + + +def run_gh(args, cwd, token): + if not shutil.which("gh"): + raise RuntimeError("gh CLI is required for PR creation") + env = os.environ.copy() + env["GH_TOKEN"] = token + return subprocess.run( + ["gh"] + args, + cwd=cwd, + check=True, + capture_output=True, + text=True, + env=env, + ) + + +def ensure_upstream_remote(repo_path, upstream_repo, token): + upstream_url = f"https://github.com/{upstream_repo}.git" + try: + current = run_git(["remote", "get-url", "upstream"], repo_path, token) + if current.stdout.strip() != upstream_url: + run_git(["remote", "set-url", "upstream", upstream_url], repo_path, token) + except subprocess.CalledProcessError: + run_git(["remote", "add", "upstream", upstream_url], repo_path, token) + + +def ensure_fork(upstream_repo, fork_owner, token, cwd): + _, repo_name = upstream_repo.split("/", 1) + fork_repo = f"{fork_owner}/{repo_name}" + try: + run_gh(["api", f"repos/{fork_repo}"], cwd, token) + return fork_repo + except subprocess.CalledProcessError: + pass + + try: + run_gh(["api", f"repos/{upstream_repo}/forks", "-f", f"owner={fork_owner}"], cwd, token) + except subprocess.CalledProcessError as exc: + if "already exists" not in exc.stderr.lower(): + raise + + for _ in range(10): + try: + run_gh(["api", f"repos/{fork_repo}"], cwd, token) + return fork_repo + except subprocess.CalledProcessError: + time.sleep(1) + + raise RuntimeError(f"fork not ready for {fork_repo}") + + +def clone_or_update_fork(workspace_path, upstream_repo, fork_owner, token): + fork_repo = ensure_fork(upstream_repo, fork_owner, token, workspace_path) + repo_dir = workspace_path / fork_repo.replace("/", "__") + repo_url = f"https://github.com/{fork_repo}.git" + + if repo_dir.exists(): + status = run_git(["status", "--porcelain"], repo_dir, token) + if status.stdout.strip(): + return repo_dir, "dirty", fork_repo, "" + run_git(["fetch", "origin"], repo_dir, token) + else: + run_git(["clone", repo_url, str(repo_dir)], workspace_path, token) + + ensure_upstream_remote(repo_dir, upstream_repo, token) + run_git(["fetch", "upstream"], repo_dir, token) + base_ref = run_git(["symbolic-ref", "refs/remotes/upstream/HEAD"], repo_dir, token) + base_branch = base_ref.stdout.strip().rsplit("/", 1)[-1] + run_git(["checkout", "-B", base_branch], repo_dir, token) + run_git(["reset", "--hard", f"upstream/{base_branch}"], repo_dir, token) + run_git(["push", "origin", base_branch, "--force"], repo_dir, token) + + return repo_dir, "updated", fork_repo, base_branch + + +def create_remediation_issue(repo, updates, token, cwd): + issue_title = "SecOps: remediate critical vulnerabilities" + issue_body_lines = [ + "Automated remediation for critical Dependabot alerts.", + "", + "Updates:", + ] + for update in updates: + dep = update.get("dependency") or "unknown" + version = update.get("version") + status_line = update.get("status") + if version: + issue_body_lines.append(f"- {dep}: {version} ({status_line})") + else: + issue_body_lines.append(f"- {dep}: {status_line}") + issue_body_lines.append("") + issue_body_lines.append("Generated by SHADI SecOps.") + + issue_body = "\n".join(issue_body_lines) + response = run_gh( + [ + "api", + f"repos/{repo}/issues", + "-f", + f"title={issue_title}", + "-f", + f"body={issue_body}", + ], + cwd, + token, + ) + payload = json.loads(response.stdout) + return payload.get("number"), payload.get("html_url") + + +def pending_prs_path(workspace_dir): + return Path(workspace_dir) / "secops_pending_prs.json" + + +def write_pending_prs(workspace_dir, pending): + path = pending_prs_path(workspace_dir) + path.write_text(json.dumps(pending, indent=2) + "\n", encoding="utf-8") + return path + + +def load_pending_prs(workspace_dir): + path = pending_prs_path(workspace_dir) + if not path.exists(): + return path, {"repos": {}} + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + data = {"repos": {}} + data.setdefault("repos", {}) + return path, data + + +def remediate_repos(config, github_token, report, workspace_dir, create_prs=True): + workspace_path = Path(workspace_dir) + remediation = {} + pending = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "repos": {}, + } + fork_owner = os.getenv("SHADI_HUMAN_GITHUB", "").strip() + if not fork_owner: + raise RuntimeError("SHADI_HUMAN_GITHUB must be set to create forks and PRs") + + for repo, repo_data in report.get("repos", {}).items(): + alerts = repo_data.get("data", {}).get("dependabot", []) + actionable_alerts = [alert for alert in alerts if is_actionable_alert(alert)] + if not actionable_alerts: + remediation[repo] = {"status": "no_actionable_dependabot_alerts"} + continue + + repo_path, clone_status, fork_repo, base_branch = clone_or_update_fork( + workspace_path, repo, fork_owner, github_token + ) + if clone_status == "dirty": + remediation[repo] = {"status": "skipped_dirty_repo"} + continue + + if not base_branch: + remediation[repo] = {"status": "fork_sync_failed"} + continue + branch_name = f"secops/remediate-{datetime.now(timezone.utc).strftime('%Y%m%d')}" + try: + run_git(["checkout", "-b", branch_name], repo_path, github_token) + except subprocess.CalledProcessError: + run_git(["checkout", branch_name], repo_path, github_token) + run_git(["config", "user.email", "lumuscar@cisco.com"], repo_path, github_token) + run_git(["config", "user.name", "Luca Muscariello"], repo_path, github_token) + + updates = [] + for alert in actionable_alerts: + update = apply_dependency_patch(repo_path, alert) + update["severity"] = get_alert_severity(alert) + updates.append(update) + + status = run_git(["status", "--porcelain"], repo_path, github_token) + if not status.stdout.strip(): + remediation[repo] = { + "status": "no_changes", + "updates": updates, + } + run_git(["checkout", base_branch], repo_path, github_token) + continue + + commit_message = "chore(secops): remediate critical vulnerabilities" + if not is_conventional_commit(commit_message): + raise RuntimeError(f"commit message is not conventional: '{commit_message}'") + run_git(["add", "-A"], repo_path, github_token) + run_git(["commit", "-m", commit_message, "--signoff"], repo_path, github_token) + run_git(["push", "origin", branch_name], repo_path, github_token) + + pr_body_lines = [ + "Automated remediation for critical Dependabot alerts.", + "", + "Updates:", + ] + for update in updates: + dep = update.get("dependency") or "unknown" + version = update.get("version") + status_line = update.get("status") + if version: + pr_body_lines.append(f"- {dep}: {version} ({status_line})") + else: + pr_body_lines.append(f"- {dep}: {status_line}") + + issue_number, issue_url = create_remediation_issue(repo, updates, github_token, repo_path) + if issue_number: + pr_body_lines.append("") + pr_body_lines.append(f"Fixes #{issue_number}") + pr_body = "\n".join(pr_body_lines) + if not create_prs: + pending["repos"][repo] = { + "title": commit_message, + "head": f"{fork_owner}:{branch_name}", + "base": base_branch, + "body": pr_body, + "updates": updates, + "issue_number": issue_number, + "issue_url": issue_url, + "fork_owner": fork_owner, + } + remediation[repo] = { + "status": "pending_pr_approval", + "updates": updates, + "branch": branch_name, + "fork": fork_repo, + "issue_url": issue_url, + } + else: + try: + pr_response = run_gh( + [ + "api", + f"repos/{repo}/pulls", + "-f", + f"title={commit_message}", + "-f", + f"head={fork_owner}:{branch_name}", + "-f", + f"base={base_branch}", + "-f", + f"body={pr_body}", + ], + repo_path, + github_token, + ) + pr = json.loads(pr_response.stdout) + remediation[repo] = { + "status": "pr_created", + "updates": updates, + "pr_url": pr.get("html_url"), + "branch": branch_name, + "fork": fork_repo, + "issue_url": issue_url, + } + except (RuntimeError, subprocess.CalledProcessError, ValueError) as exc: + remediation[repo] = { + "status": "pr_failed", + "updates": updates, + "error": str(exc), + "branch": branch_name, + "fork": fork_repo, + "issue_url": issue_url, + } + + run_git(["checkout", base_branch], repo_path, github_token) + + if pending["repos"]: + write_pending_prs(workspace_dir, pending) + return remediation + + +def approve_pending_prs(config, github_token, workspace_dir): + path, pending = load_pending_prs(workspace_dir) + results = {} + remaining = {"generated_at": pending.get("generated_at"), "repos": {}} + + for repo, pr in pending.get("repos", {}).items(): + try: + pr_response = run_gh( + [ + "api", + f"repos/{repo}/pulls", + "-f", + f"title={pr['title']}", + "-f", + f"head={pr['head']}", + "-f", + f"base={pr['base']}", + "-f", + f"body={pr['body']}", + ], + Path(workspace_dir), + github_token, + ) + pr_payload = json.loads(pr_response.stdout) + results[repo] = { + "status": "pr_created", + "pr_url": pr_payload.get("html_url"), + "branch": pr.get("head"), + "issue_url": pr.get("issue_url"), + } + except (RuntimeError, subprocess.CalledProcessError, ValueError) as exc: + results[repo] = { + "status": "pr_failed", + "error": str(exc), + "branch": pr.get("head"), + "issue_url": pr.get("issue_url"), + } + remaining["repos"][repo] = pr + + if remaining["repos"]: + path.write_text(json.dumps(remaining, indent=2) + "\n", encoding="utf-8") + else: + if path.exists(): + path.unlink() + return results + + +def github_get_json(api_base, token, path, query=""): + url = f"{api_base.rstrip('/')}{path}{query}" + request = Request(url) + request.add_header("Accept", "application/vnd.github+json") + request.add_header("Authorization", f"Bearer {token}") + request.add_header("User-Agent", "shadi-secops-agent") + request.add_header("X-GitHub-Api-Version", "2022-11-28") + with urlopen(request, timeout=30) as response: + payload = response.read() + return json.loads(payload.decode("utf-8")) + + +def fetch_dependabot_alerts(api_base, token, repo): + owner, name = repo.split("/", 1) + path = f"/repos/{owner}/{name}/dependabot/alerts" + return github_get_json(api_base, token, path, "?state=open") + + +def fetch_security_issues(api_base, token, repo, label): + owner, name = repo.split("/", 1) + path = f"/repos/{owner}/{name}/issues" + query = f"?state=open&labels={label}" + return github_get_json(api_base, token, path, query) + + +def collect_security_report(config, github_token, allowlisted_repos, labels): + api_base = config.get("github", {}).get("api_base", "https://api.github.com") + report = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "repos": {}, + "skill_trace": [ + { + "skill": "collect_security_issues", + "timestamp": datetime.now(timezone.utc).isoformat(), + } + ], + } + total_alerts = 0 + total_issues = 0 + + for repo in allowlisted_repos: + repo_entry = {"dependabot": [], "issues": {}} + try: + alerts = fetch_dependabot_alerts(api_base, github_token, repo) + repo_entry["dependabot"] = alerts + total_alerts += len(alerts) + except (HTTPError, URLError, ValueError) as exc: + repo_entry["dependabot_error"] = str(exc) + + for label in labels: + try: + issues = fetch_security_issues(api_base, github_token, repo, label) + repo_entry["issues"][label] = issues + total_issues += len(issues) + except (HTTPError, URLError, ValueError) as exc: + repo_entry["issues"][label] = {"error": str(exc), "items": []} + + report["repos"][repo] = { + "dependabot_count": len(repo_entry.get("dependabot", [])), + "issue_counts": { + label: len(items) if isinstance(items, list) else 0 + for label, items in repo_entry["issues"].items() + }, + "data": repo_entry, + } + + return report, total_alerts, total_issues + + +def generate_llm_markdown(report, total_alerts, total_issues, llm_settings): + try: + from openai import AzureOpenAI, OpenAI + except ImportError as exc: + raise RuntimeError("openai is required for LLM reports") from exc + + prompt = ( + "You are a SecOps assistant. Generate a Markdown report with these sections:\n" + "1) Executive summary (2-4 bullets).\n" + "2) Critical vulnerabilities only (Dependabot or issues).\n" + "3) Remediation plan per repo (actionable steps).\n" + " - If a patch is available, propose code updates and a PR summary.\n" + " - If a patch is not available, state that remediation is blocked and list next steps.\n" + "4) Risk notes if no critical findings.\n\n" + "Use concise language and include repository names.\n\n" + "Input JSON follows.\n" + ) + payload = { + "generated_at": report.get("generated_at"), + "total_dependabot_alerts": total_alerts, + "total_labeled_issues": total_issues, + "repos": report.get("repos", {}), + } + prompt = f"{prompt}{json.dumps(payload, indent=2)}" + + provider = llm_settings["provider"] + api_key = llm_settings["api_key"] + base_url = llm_settings["base_url"] + model_name = llm_settings["model"] + api_version = llm_settings.get("api_version") + + if provider in ("azure", "azure_openai", "openai") and api_version: + client = AzureOpenAI( + api_key=api_key, + azure_endpoint=base_url, + api_version=api_version, + ) + else: + client = OpenAI( + api_key=api_key, + base_url=base_url, + ) + timeout_seconds = float(os.getenv("SHADI_LLM_TIMEOUT", "60")) + try: + response = client.chat.completions.create( + model=model_name, + messages=[{"role": "user", "content": prompt}], + timeout=timeout_seconds, + ) + except Exception as exc: + raise RuntimeError( + f"LLM report generation failed for provider '{provider}' and model '{model_name}'. " + "Verify the secops/llm secrets for provider, endpoint, model, and API key." + ) from exc + choices = getattr(response, "choices", None) or [] + if choices: + message = getattr(choices[0], "message", None) + content = getattr(message, "content", None) + if content: + return content.strip() + raise RuntimeError("LLM response did not contain text") + + +def write_report(report, workspace_dir, filename, total_alerts, total_issues, llm_settings): + workspace_path = Path(workspace_dir) + workspace_path.mkdir(parents=True, exist_ok=True) + report_path = workspace_path / filename + markdown = generate_llm_markdown(report, total_alerts, total_issues, llm_settings) + report_path.write_text(markdown, encoding="utf-8") + return report_path + + +def resolve_tmp_dir(agent_id_envs=None): + base = os.getenv("SHADI_TMP_DIR", "./.tmp") + agent_id = os.getenv("SHADI_AGENT_ID") + if not agent_id and agent_id_envs: + for env_name in agent_id_envs: + value = os.getenv(env_name) + if value: + agent_id = value + break + if agent_id: + return os.path.join(base, agent_id) + return base + + +def record_secops_memory(config, summary): + secops_config = config.get("secops", {}) + tmp_dir = resolve_tmp_dir(("SHADI_OPERATOR_AGENT_ID", "SHADI_SECOPS_AGENT_ID")) + default_db = os.path.join(tmp_dir, "shadi-secops", "secops_memory.db") + db_path = ( + os.getenv("SHADI_SECOPS_MEMORY_DB") + or os.getenv("SHADI_MEMORY_DB") + or secops_config.get("memory_db") + or default_db + ) + + memory_key = secops_config.get("memory_key", "secops/memory_key") + scope = secops_config.get("memory_scope", "secops") + + payload = json.dumps(summary, indent=2) + report_day = summary.get("report_day") or datetime.now(timezone.utc).strftime("%Y-%m-%d") + entry_keys = [ + "security_report", + f"security_report_{report_day}", + ] + try: + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + store = SqlCipherMemoryStore(db_path, None, memory_key) + results = [] + for entry_key in entry_keys: + record_id = store.put(scope, entry_key, payload) + results.append(str(record_id)) + except Exception as exc: + return { + "status": "error", + "stderr": str(exc), + } + + results = [item for item in results if item] + if results: + return {"status": "saved", "result": results} + return {"status": "saved"} + + +def skill_collect_security_issues( + labels="security,cve,vulnerability", + report_name="secops_security_report.md", + provider=None, + remediate=False, + create_prs=False, + human_github_handle=None, +): + config_path, config = load_secops_config() + secops_config = config.get("secops", {}) + allowlisted_repos = secops_config.get("allowlist", []) + store, session = create_secops_session() + github_token, workspace, _, _ = get_secops_credentials(config, store, session) + llm_settings = get_llm_settings(config, store, session, provider_override=provider) + + human_did = "" + if human_github_handle: + human_did = get_human_did(store, session, human_github_handle) + + label_list = [item.strip() for item in labels.split(",") if item.strip()] + report, total_alerts, total_issues = collect_security_report( + config, github_token, allowlisted_repos, label_list + ) + if human_did: + report["human"] = { + "github_handle": human_github_handle, + "did": human_did, + } + remediation = None + if remediate or secops_config.get("auto_remediate"): + allow_prs = create_prs or secops_config.get("auto_pr", False) + remediation = remediate_repos(config, github_token, report, workspace, create_prs=allow_prs) + report["remediation"] = remediation + + report_path = write_report(report, workspace, report_name, total_alerts, total_issues, llm_settings) + + summary = { + "generated_at": report["generated_at"], + "report_day": datetime.now(timezone.utc).strftime("%Y-%m-%d"), + "dependabot_alerts": total_alerts, + "labeled_issues": total_issues, + "repos": allowlisted_repos, + "report_path": str(report_path), + "model": llm_settings["model"], + "provider": llm_settings["provider"], + "remediation": remediation, + "human_github_handle": human_github_handle, + "human_did": human_did, + } + memory_status = record_secops_memory(config, summary) + + return { + "status": "success", + "config": str(config_path), + "report_path": str(report_path), + "dependabot_alerts": total_alerts, + "labeled_issues": total_issues, + "repos": allowlisted_repos, + "memory": memory_status, + "remediation": remediation, + "human_github_handle": human_github_handle, + "human_did": human_did, + } diff --git a/agents/secops/uv.lock b/agents/secops/uv.lock new file mode 100644 index 0000000..dcc7ce8 --- /dev/null +++ b/agents/secops/uv.lock @@ -0,0 +1,349 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "maturin" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/21/85e8189ca40f97885abc5154950490b821e590fd2aebea0c9bfb92b1c353/maturin-1.12.0.tar.gz", hash = "sha256:170e695ead35d33fa537078deea2a91dead31ee909fac454079a5df006786e01", size = 252430, upload-time = "2026-02-14T08:49:41.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/c8/acbcea1290b0b335b6457df80cc946cf81d521261b1a6a3885548fcc7120/maturin-1.12.0-py3-none-linux_armv6l.whl", hash = "sha256:f16d0db5000b37fb9e3ed252b7ffbc021274be34fad9abeaf1b98b723c233e4a", size = 9632654, upload-time = "2026-02-14T08:49:42.879Z" }, + { url = "https://files.pythonhosted.org/packages/6e/61/6bd544ca1e3c58300dbbdc5449b3de99e7638044039eeae1af5bbc814390/maturin-1.12.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d1724aee0fecc39cc74f8cff53936f0a6c34d1878caa5e407d568ad8f56d674b", size = 18839071, upload-time = "2026-02-14T08:49:19.859Z" }, + { url = "https://files.pythonhosted.org/packages/74/63/e48f5057248b597aa8528a64aaef4828e87a23ad9b924387d54060a030bb/maturin-1.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c0b054ee615f2ba7e2219ead94831f8fa7abeef34b09aa3f4fbdca3553c74dc1", size = 9737439, upload-time = "2026-02-14T08:49:28.605Z" }, + { url = "https://files.pythonhosted.org/packages/20/f3/39c47bcda041d71aee597147e4237197b878164bfe30533b3ea30f82c796/maturin-1.12.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:e1046cad6d7bde0d6fa592857f805b4a5101db39a720407af01aa7ff439650b9", size = 9684568, upload-time = "2026-02-14T08:49:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b5/105d230350fe011f885d8e6fcecdb326999924d343137a1c14c756324710/maturin-1.12.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:6df159ac1520621cc9750a0d37ef09c0444b5983c7adc012bb0029e3d5a2a095", size = 10183157, upload-time = "2026-02-14T08:49:30.884Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/fc170394256aafeabf97622b1ef286f521984940b5483b018f473dbaea32/maturin-1.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:978249d5dcf26eaad9440f9d87d691b0b55dbef0e45abf85cae2072b80fb0074", size = 9558768, upload-time = "2026-02-14T08:49:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/46/4a/7bdd7ccbb0bb71da2122d0d5992efff5a55ff3e5ac60eba1fa16f28867eb/maturin-1.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:b12a5daf91d16db44381f5338a31f308dfcf85524321f72cad2428a5e0a806c6", size = 9467723, upload-time = "2026-02-14T08:49:45.178Z" }, + { url = "https://files.pythonhosted.org/packages/ff/93/8a73b927c7f710bc1aafae7a195ee44da6ad0e42ba73db79f3258569deab/maturin-1.12.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:d5fa15c769b56f98e2f8fded6dfe5a2c19680a340213e3fb28c8dd507bec85cb", size = 12557142, upload-time = "2026-02-14T08:49:47.438Z" }, + { url = "https://files.pythonhosted.org/packages/37/42/32f78aa187babf815ffee86f2d9dfeb66d596aa3e47a8163017cdb34cc2f/maturin-1.12.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d9d9cf7d4a3e043336ae2ad01c5e9e6a7062be43c8f09b0cd28dc5f6dce742b", size = 10301378, upload-time = "2026-02-14T08:49:33.126Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5b/be0ff96087f245cacaf360ec94e6bea4c76055d87e230f9a16f8bf209449/maturin-1.12.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:45a5078c3c2f02b2885e38a2735eb9dd0c6e9dd06ba70c05845d5cd5ad7128db", size = 10008035, upload-time = "2026-02-14T08:49:26.236Z" }, + { url = "https://files.pythonhosted.org/packages/10/6e/cb255296223a38c3b48243dacc93343ee6473d37b5d938359f6bf858fcb1/maturin-1.12.0-py3-none-win32.whl", hash = "sha256:1eeed88f3021d15c426490af49198e8da82a34ace1a15d41dfc8fffa5e4c2967", size = 8468757, upload-time = "2026-02-14T08:49:24.542Z" }, + { url = "https://files.pythonhosted.org/packages/96/77/697dd0d6ca69728c31e59ef217a08128fa63656d003adfcd82d4e3d7ffd0/maturin-1.12.0-py3-none-win_amd64.whl", hash = "sha256:976519fd01354025da4b494d4ee9ca697ef296e7add8e0b5f2eb199da9275ee7", size = 9813092, upload-time = "2026-02-14T08:49:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/c6/79/780b0af1780080780f618d2e095ce2192047bd6ae631a382e86a8d632d98/maturin-1.12.0-py3-none-win_arm64.whl", hash = "sha256:38764453c5a77100bd174c467cc1549530cc63f5acfa93abc9ef1c0253489839", size = 8524150, upload-time = "2026-02-14T08:49:22.377Z" }, +] + +[[package]] +name = "openai" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "shadi-secops" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "maturin" }, + { name = "openai" }, +] + +[package.metadata] +requires-dist = [ + { name = "maturin" }, + { name = "openai" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/agents/shared/shadi_adk_memory.py b/agents/shared/shadi_adk_memory.py new file mode 100644 index 0000000..06e273e --- /dev/null +++ b/agents/shared/shadi_adk_memory.py @@ -0,0 +1,154 @@ +import json +import os +import re +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Mapping, Sequence + +from google.adk.events.event import Event +from google.adk.memory.base_memory_service import BaseMemoryService, SearchMemoryResponse +from google.adk.memory.memory_entry import MemoryEntry +from shadi import SqlCipherMemoryStore + + +_UNKNOWN_SESSION_ID = "__unknown_session_id__" + + +def _extract_words_lower(text: str) -> set[str]: + return set(re.findall(r"[A-Za-z]+", text.lower())) + + +class ShadiBackedMemoryService(BaseMemoryService): + def __init__( + self, + *, + app_name: str, + user_id: str, + db_path: str, + key_name: str, + scope: str = "adk", + entry_key: str | None = None, + memory_bin: str | None = None, + ) -> None: + self._lock = threading.Lock() + self._app_name = app_name + self._user_id = user_id + self._db_path = db_path + self._key_name = key_name + self._scope = scope + self._entry_key = entry_key or f"adk_memory/{app_name}/{user_id}" + self._session_events: dict[str, list[Event]] = {} + self._store = self._open_store() + self._load_from_shadi() + + def _open_store(self) -> SqlCipherMemoryStore | None: + try: + Path(self._db_path).parent.mkdir(parents=True, exist_ok=True) + return SqlCipherMemoryStore(self._db_path, None, self._key_name) + except Exception: + return None + + def _load_from_shadi(self) -> None: + if not self._store: + return + entry = self._store.get_latest(self._scope, self._entry_key) + if not entry: + return + try: + data = json.loads(entry.payload) + except json.JSONDecodeError: + return + sessions = data.get("sessions", {}) if isinstance(data, dict) else {} + for session_id, events_data in sessions.items(): + if not isinstance(events_data, list): + continue + loaded_events = [] + for event_data in events_data: + try: + loaded_events.append(Event.model_validate(event_data)) + except Exception: + continue + if loaded_events: + self._session_events[session_id] = loaded_events + + def _persist(self) -> None: + if not self._store: + return + payload = { + "app_name": self._app_name, + "user_id": self._user_id, + "updated_at": datetime.now(timezone.utc).isoformat(), + "sessions": { + session_id: [ + event.model_dump(mode="json", by_alias=True, exclude_none=True) + for event in events + ] + for session_id, events in self._session_events.items() + }, + } + self._store.put( + self._scope, + self._entry_key, + json.dumps(payload, separators=(",", ":")), + ) + + async def add_session_to_memory(self, session) -> None: + user_key = session.id or _UNKNOWN_SESSION_ID + with self._lock: + self._session_events[user_key] = [ + event + for event in session.events + if event.content and event.content.parts + ] + self._persist() + + async def add_events_to_memory( + self, + *, + app_name: str, + user_id: str, + events: Sequence[Event], + session_id: str | None = None, + custom_metadata: Mapping[str, object] | None = None, + ) -> None: + _ = custom_metadata + scoped_session_id = session_id or _UNKNOWN_SESSION_ID + events_to_add = [ + event for event in events if event.content and event.content.parts + ] + with self._lock: + existing_events = self._session_events.get(scoped_session_id, []) + existing_ids = {event.id for event in existing_events} + for event in events_to_add: + if event.id not in existing_ids: + existing_events.append(event) + existing_ids.add(event.id) + self._session_events[scoped_session_id] = existing_events + self._persist() + + async def search_memory( + self, *, app_name: str, user_id: str, query: str + ) -> SearchMemoryResponse: + response = SearchMemoryResponse() + words_in_query = _extract_words_lower(query) + with self._lock: + session_event_lists = list(self._session_events.values()) + for session_events in session_event_lists: + for event in session_events: + if not event.content or not event.content.parts: + continue + words_in_event = _extract_words_lower( + " ".join([part.text for part in event.content.parts if part.text]) + ) + if not words_in_event: + continue + if any(query_word in words_in_event for query_word in words_in_query): + response.memories.append( + MemoryEntry( + content=event.content, + author=event.author, + timestamp=datetime.fromtimestamp(event.timestamp, tz=timezone.utc).isoformat(), + ) + ) + return response diff --git a/crates/agent_secrets/Cargo.toml b/crates/agent_secrets/Cargo.toml new file mode 100644 index 0000000..a55c59c --- /dev/null +++ b/crates/agent_secrets/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "agent_secrets" +version = "0.1.0" +edition = "2021" + +[features] +default = ["std"] +std = [] +windows = [] +macos = [] +ios = [] +android = [] +linux = [] +coverage = [] + +[dependencies] +zeroize = "1.7" + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "2.10" +security-framework-sys = "2.15" diff --git a/crates/agent_secrets/README.md b/crates/agent_secrets/README.md new file mode 100644 index 0000000..c94ed59 --- /dev/null +++ b/crates/agent_secrets/README.md @@ -0,0 +1,12 @@ +# agent_secrets + +Secure secret storage and access primitives for autonomous agents. + +## Goals +- Provide a stable Rust API to store and retrieve secrets. +- Support Windows, macOS, iOS, Android, and Linux using OS keystores. +- Minimize in-memory exposure through explicit secret wrappers. + +## Status +This crate provides a public API scaffold. Platform backends are stubbed and +return `NotSupported` until implemented. diff --git a/crates/agent_secrets/src/agent.rs b/crates/agent_secrets/src/agent.rs new file mode 100644 index 0000000..ccb46ef --- /dev/null +++ b/crates/agent_secrets/src/agent.rs @@ -0,0 +1,66 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use crate::{AgentVerifier, SecretError, SecretResult, SecretStore}; +use crate::memory::SecretBytes; +use crate::policy::SecretPolicy; +use crate::session::SessionContext; + +pub struct AgentSecretAccess<'a> { + store: &'a dyn SecretStore, + verifier: &'a dyn AgentVerifier, +} + +impl<'a> AgentSecretAccess<'a> { + pub fn new(store: &'a dyn SecretStore, verifier: &'a dyn AgentVerifier) -> Self { + Self { store, verifier } + } + + pub fn put_for_session( + &self, + session: &SessionContext, + key: &str, + secret: &[u8], + policy: SecretPolicy, + ) -> SecretResult<()> { + self.verifier.verify(session)?; + self.store.put(key, secret, policy) + } + + pub fn get_for_session(&self, session: &SessionContext, key: &str) -> SecretResult { + self.verifier.verify(session)?; + self.store.get(key) + } + + pub fn delete_for_session(&self, session: &SessionContext, key: &str) -> SecretResult<()> { + self.verifier.verify(session)?; + self.store.delete(key) + } + + pub fn require_verified(session: &SessionContext) -> SecretResult<()> { + if session.verified { + Ok(()) + } else { + Err(SecretError::NotAuthorized) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn require_verified_accepts_verified_session() { + let mut session = SessionContext::new("agent", "session"); + session.verified = true; + AgentSecretAccess::require_verified(&session).unwrap(); + } + + #[test] + fn require_verified_rejects_unverified_session() { + let session = SessionContext::new("agent", "session"); + let err = AgentSecretAccess::require_verified(&session).unwrap_err(); + assert!(matches!(err, SecretError::NotAuthorized)); + } +} diff --git a/crates/agent_secrets/src/auth.rs b/crates/agent_secrets/src/auth.rs new file mode 100644 index 0000000..80d71cc --- /dev/null +++ b/crates/agent_secrets/src/auth.rs @@ -0,0 +1,30 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use crate::{SecretError, SecretResult}; +use crate::session::SessionContext; + +pub trait AgentVerifier: Send + Sync { + fn verify(&self, session: &SessionContext) -> SecretResult<()>; +} + +pub struct NoopVerifier; + +impl AgentVerifier for NoopVerifier { + fn verify(&self, _session: &SessionContext) -> SecretResult<()> { + Err(SecretError::NotAuthorized) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noop_verifier_denies() { + let verifier = NoopVerifier; + let session = SessionContext::new("agent", "session"); + let err = verifier.verify(&session).unwrap_err(); + assert!(matches!(err, SecretError::NotAuthorized)); + } +} diff --git a/crates/agent_secrets/src/lib.rs b/crates/agent_secrets/src/lib.rs new file mode 100644 index 0000000..130fe6a --- /dev/null +++ b/crates/agent_secrets/src/lib.rs @@ -0,0 +1,157 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +pub mod agent; +pub mod auth; +pub mod memory; +pub mod platform; +pub mod policy; +pub mod session; + +use std::fmt; + +pub use agent::AgentSecretAccess; +pub use auth::AgentVerifier; +pub use memory::SecretBytes; +pub use policy::SecretPolicy; +pub use session::SessionContext; + +#[derive(Debug)] +pub enum SecretError { + NotSupported, + NotAuthorized, + InvalidInput, + StorageFailure, +} + +impl fmt::Display for SecretError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let msg = match self { + SecretError::NotSupported => "operation not supported", + SecretError::NotAuthorized => "not authorized", + SecretError::InvalidInput => "invalid input", + SecretError::StorageFailure => "storage failure", + }; + f.write_str(msg) + } +} + +impl std::error::Error for SecretError {} + +pub type SecretResult = Result; + +pub trait SecretStore: Send + Sync { + fn put(&self, key: &str, secret: &[u8], policy: SecretPolicy) -> SecretResult<()>; + fn get(&self, key: &str) -> SecretResult; + fn delete(&self, key: &str) -> SecretResult<()>; + fn list_keys(&self) -> SecretResult>; +} + +pub fn default_store() -> Box { + platform::default_store() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + + use super::*; + use crate::agent::AgentSecretAccess; + use crate::auth::AgentVerifier; + + struct AllowVerifier; + + impl AgentVerifier for AllowVerifier { + fn verify(&self, _session: &SessionContext) -> SecretResult<()> { + Ok(()) + } + } + + struct DenyVerifier; + + impl AgentVerifier for DenyVerifier { + fn verify(&self, _session: &SessionContext) -> SecretResult<()> { + Err(SecretError::NotAuthorized) + } + } + + struct MemoryStore { + entries: Mutex>>, + } + + impl MemoryStore { + fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + } + } + } + + impl SecretStore for MemoryStore { + fn put(&self, key: &str, secret: &[u8], _policy: SecretPolicy) -> SecretResult<()> { + let mut guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + guard.insert(key.to_string(), secret.to_vec()); + Ok(()) + } + + fn get(&self, key: &str) -> SecretResult { + let guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + let value = guard + .get(key) + .ok_or(SecretError::InvalidInput)? + .clone(); + Ok(SecretBytes::new(value)) + } + + fn delete(&self, key: &str) -> SecretResult<()> { + let mut guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + guard.remove(key); + Ok(()) + } + + fn list_keys(&self) -> SecretResult> { + let guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + Ok(guard.keys().cloned().collect()) + } + } + + #[test] + fn agent_access_allows_put_get_delete_when_verified() { + let store = MemoryStore::new(); + let verifier = AllowVerifier; + let access = AgentSecretAccess::new(&store, &verifier); + let session = SessionContext::new("agent", "session"); + + access + .put_for_session(&session, "key", b"value", SecretPolicy::default()) + .unwrap(); + + let secret = access.get_for_session(&session, "key").unwrap(); + let got = secret.expose(|bytes| bytes.to_vec()); + assert_eq!(got, b"value"); + + access.delete_for_session(&session, "key").unwrap(); + } + + #[test] + fn agent_access_denies_when_verifier_rejects() { + let store = MemoryStore::new(); + let verifier = DenyVerifier; + let access = AgentSecretAccess::new(&store, &verifier); + let session = SessionContext::new("agent", "session"); + + let err = access + .put_for_session(&session, "key", b"value", SecretPolicy::default()) + .unwrap_err(); + assert!(matches!(err, SecretError::NotAuthorized)); + } + + #[test] + fn secret_error_display_formats_messages() { + assert_eq!(SecretError::NotSupported.to_string(), "operation not supported"); + assert_eq!(SecretError::NotAuthorized.to_string(), "not authorized"); + assert_eq!(SecretError::InvalidInput.to_string(), "invalid input"); + assert_eq!(SecretError::StorageFailure.to_string(), "storage failure"); + } +} diff --git a/crates/agent_secrets/src/memory.rs b/crates/agent_secrets/src/memory.rs new file mode 100644 index 0000000..b6aca7a --- /dev/null +++ b/crates/agent_secrets/src/memory.rs @@ -0,0 +1,49 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use zeroize::Zeroize; + +pub struct SecretBytes { + bytes: Vec, +} + +impl SecretBytes { + pub fn new(bytes: Vec) -> Self { + Self { bytes } + } + + pub fn expose(&self, f: impl FnOnce(&[u8]) -> R) -> R { + f(&self.bytes) + } + + pub fn into_vec(mut self) -> Vec { + let mut out = Vec::new(); + std::mem::swap(&mut out, &mut self.bytes); + out + } +} + +impl Drop for SecretBytes { + fn drop(&mut self) { + self.bytes.zeroize(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expose_returns_expected_bytes() { + let secret = SecretBytes::new(b"value".to_vec()); + let got = secret.expose(|bytes| bytes.to_vec()); + assert_eq!(got, b"value"); + } + + #[test] + fn into_vec_returns_owned_bytes() { + let secret = SecretBytes::new(b"value".to_vec()); + let out = secret.into_vec(); + assert_eq!(out, b"value"); + } +} diff --git a/crates/agent_secrets/src/platform/macos.rs b/crates/agent_secrets/src/platform/macos.rs new file mode 100644 index 0000000..aeb858e --- /dev/null +++ b/crates/agent_secrets/src/platform/macos.rs @@ -0,0 +1,275 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use security_framework::passwords::{ + delete_generic_password, get_generic_password, set_generic_password, +}; +use security_framework_sys::base::errSecItemNotFound; + +use crate::{SecretError, SecretResult, SecretStore}; +use crate::memory::SecretBytes; +use crate::policy::SecretPolicy; + +pub struct MacosKeychainStore { + service: String, +} + +const REGISTRY_ACCOUNT: &str = "__shadi_registry__"; + +impl MacosKeychainStore { + pub fn new(service: impl Into) -> Self { + Self { + service: service.into(), + } + } + + fn load_registry(&self) -> SecretResult> { + #[cfg(any(test, feature = "coverage"))] + if self.service == "__force_error__" { + return Err(SecretError::StorageFailure); + } + match get_generic_password(&self.service, REGISTRY_ACCOUNT) { + Ok(value) => { + let text = String::from_utf8(value).map_err(|_| SecretError::StorageFailure)?; + let keys = text + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(String::from) + .collect::>(); + Ok(keys) + } + Err(err) if err.code() == errSecItemNotFound => Ok(Vec::new()), + Err(err) => { + eprintln!("keychain registry read failed: {}", err); + Err(SecretError::StorageFailure) + } + } + } + + fn store_registry(&self, keys: &[String]) -> SecretResult<()> { + let mut unique = keys.to_vec(); + unique.sort(); + unique.dedup(); + let payload = unique.join("\n"); + set_generic_password(&self.service, REGISTRY_ACCOUNT, payload.as_bytes()).map_err(|err| { + eprintln!("keychain registry write failed: {}", err); + SecretError::StorageFailure + }) + } + + fn update_registry_on_put(&self, key: &str) -> SecretResult<()> { + if key == REGISTRY_ACCOUNT { + return Ok(()); + } + let mut keys = self.load_registry()?; + if !keys.iter().any(|existing| existing == key) { + keys.push(key.to_string()); + self.store_registry(&keys)?; + } + Ok(()) + } + + fn update_registry_on_delete(&self, key: &str) -> SecretResult<()> { + if key == REGISTRY_ACCOUNT { + return Ok(()); + } + let mut keys = self.load_registry()?; + let before = keys.len(); + keys.retain(|existing| existing != key); + if keys.len() != before { + self.store_registry(&keys)?; + } + Ok(()) + } +} + +impl SecretStore for MacosKeychainStore { + fn put(&self, key: &str, secret: &[u8], _policy: SecretPolicy) -> SecretResult<()> { + set_generic_password(&self.service, key, secret).map_err(|err| { + eprintln!("keychain put failed: {}", err); + SecretError::StorageFailure + })?; + self.update_registry_on_put(key) + } + + fn get(&self, key: &str) -> SecretResult { + let value = get_generic_password(&self.service, key) + .map_err(|err| { + eprintln!("keychain get failed: {}", err); + SecretError::StorageFailure + })?; + Ok(SecretBytes::new(value)) + } + + fn delete(&self, key: &str) -> SecretResult<()> { + delete_generic_password(&self.service, key).map_err(|err| { + eprintln!("keychain delete failed: {}", err); + SecretError::StorageFailure + })?; + self.update_registry_on_delete(key) + } + + fn list_keys(&self) -> SecretResult> { + let keys = self.load_registry()?; + Ok(keys.into_iter().filter(|key| key != REGISTRY_ACCOUNT).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_key(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos(); + format!("{}-{}-{}", prefix, std::process::id(), nanos) + } + + fn unique_service() -> String { + unique_key("shadi_tests") + } + + #[test] + fn keychain_roundtrip_put_get_delete() { + if std::env::var("SHADI_KEYCHAIN_TESTS").as_deref() != Ok("1") { + return; + } + let store = MacosKeychainStore::new(unique_service()); + let key = unique_key("shadi-key"); + let secret = b"secret-value"; + + store.put(&key, secret, SecretPolicy::default()).unwrap(); + let got = store.get(&key).unwrap(); + let value = got.expose(|bytes| bytes.to_vec()); + assert_eq!(value, secret); + store.delete(&key).unwrap(); + } + + #[test] + fn list_keys_tracks_registry_updates() { + if std::env::var("SHADI_KEYCHAIN_TESTS").as_deref() != Ok("1") { + return; + } + let store = MacosKeychainStore::new(unique_service()); + let key_one = unique_key("shadi-key-a"); + let key_two = unique_key("shadi-key-b"); + + store.put(&key_one, b"value-a", SecretPolicy::default()).unwrap(); + store.put(&key_two, b"value-b", SecretPolicy::default()).unwrap(); + + let keys = store.list_keys().unwrap(); + assert!(keys.iter().any(|item| item == &key_one)); + assert!(keys.iter().any(|item| item == &key_two)); + + store.delete(&key_one).unwrap(); + let keys = store.list_keys().unwrap(); + assert!(!keys.iter().any(|item| item == &key_one)); + + store.delete(&key_two).unwrap(); + } + + #[test] + fn list_keys_excludes_registry_account() { + if std::env::var("SHADI_KEYCHAIN_TESTS").as_deref() != Ok("1") { + return; + } + let store = MacosKeychainStore::new(unique_service()); + store + .put(REGISTRY_ACCOUNT, b"value", SecretPolicy::default()) + .unwrap(); + let keys = store.list_keys().unwrap(); + assert!(!keys.iter().any(|key| key == REGISTRY_ACCOUNT)); + store.delete(REGISTRY_ACCOUNT).unwrap(); + } + + #[test] + fn list_keys_dedups_registry_entries() { + if std::env::var("SHADI_KEYCHAIN_TESTS").as_deref() != Ok("1") { + return; + } + let store = MacosKeychainStore::new(unique_service()); + let key = unique_key("shadi-key"); + + store.put(&key, b"value", SecretPolicy::default()).unwrap(); + store.put(&key, b"value", SecretPolicy::default()).unwrap(); + + let keys = store.list_keys().unwrap(); + let count = keys.iter().filter(|item| *item == &key).count(); + assert_eq!(count, 1); + store.delete(&key).unwrap(); + } + + #[test] + fn list_keys_empty_when_registry_missing() { + if std::env::var("SHADI_KEYCHAIN_TESTS").as_deref() != Ok("1") { + return; + } + let store = MacosKeychainStore::new(unique_service()); + let keys = store.list_keys().unwrap(); + assert!(keys.is_empty()); + } + + #[test] + fn list_keys_reports_forced_error() { + let store = MacosKeychainStore::new("__force_error__"); + let err = store.list_keys().unwrap_err(); + assert!(matches!(err, SecretError::StorageFailure)); + } + + #[test] + fn list_keys_trims_registry_entries() { + if std::env::var("SHADI_KEYCHAIN_TESTS").as_deref() != Ok("1") { + return; + } + let service = unique_service(); + let store = MacosKeychainStore::new(service.clone()); + let payload = b"key-a\n\n key-b \n"; + set_generic_password(&service, REGISTRY_ACCOUNT, payload).unwrap(); + + let keys = store.list_keys().unwrap(); + assert!(keys.contains(&"key-a".to_string())); + assert!(keys.contains(&"key-b".to_string())); + + store.delete(REGISTRY_ACCOUNT).unwrap(); + } + + #[test] + fn list_keys_errors_on_invalid_utf8_registry() { + if std::env::var("SHADI_KEYCHAIN_TESTS").as_deref() != Ok("1") { + return; + } + let service = unique_service(); + let store = MacosKeychainStore::new(service.clone()); + let payload = vec![0xff, 0xfe, 0xfd]; + set_generic_password(&service, REGISTRY_ACCOUNT, &payload).unwrap(); + + let err = store.list_keys().unwrap_err(); + assert!(matches!(err, SecretError::StorageFailure)); + + store.delete(REGISTRY_ACCOUNT).unwrap(); + } + + #[test] + fn get_missing_key_returns_error() { + if std::env::var("SHADI_KEYCHAIN_TESTS").as_deref() != Ok("1") { + return; + } + let store = MacosKeychainStore::new(unique_service()); + let err = store.get("missing-key").err().expect("error"); + assert!(matches!(err, SecretError::StorageFailure)); + } + + #[test] + fn delete_missing_key_returns_error() { + if std::env::var("SHADI_KEYCHAIN_TESTS").as_deref() != Ok("1") { + return; + } + let store = MacosKeychainStore::new(unique_service()); + let err = store.delete("missing-key").unwrap_err(); + assert!(matches!(err, SecretError::StorageFailure)); + } +} diff --git a/crates/agent_secrets/src/platform/mod.rs b/crates/agent_secrets/src/platform/mod.rs new file mode 100644 index 0000000..b3947ef --- /dev/null +++ b/crates/agent_secrets/src/platform/mod.rs @@ -0,0 +1,19 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(not(target_os = "macos"))] +mod noop; + +use crate::SecretStore; + +#[cfg(target_os = "macos")] +pub fn default_store() -> Box { + Box::new(macos::MacosKeychainStore::new("agent_secrets")) +} + +#[cfg(not(target_os = "macos"))] +pub fn default_store() -> Box { + Box::new(noop::NoopSecretStore::new()) +} diff --git a/crates/agent_secrets/src/platform/noop.rs b/crates/agent_secrets/src/platform/noop.rs new file mode 100644 index 0000000..059d956 --- /dev/null +++ b/crates/agent_secrets/src/platform/noop.rs @@ -0,0 +1,32 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use crate::{SecretError, SecretResult, SecretStore}; +use crate::policy::SecretPolicy; +use crate::memory::SecretBytes; + +pub struct NoopSecretStore; + +impl NoopSecretStore { + pub fn new() -> Self { + Self + } +} + +impl SecretStore for NoopSecretStore { + fn put(&self, _key: &str, _secret: &[u8], _policy: SecretPolicy) -> SecretResult<()> { + Err(SecretError::NotSupported) + } + + fn get(&self, _key: &str) -> SecretResult { + Err(SecretError::NotSupported) + } + + fn delete(&self, _key: &str) -> SecretResult<()> { + Err(SecretError::NotSupported) + } + + fn list_keys(&self) -> SecretResult> { + Err(SecretError::NotSupported) + } +} diff --git a/crates/agent_secrets/src/policy.rs b/crates/agent_secrets/src/policy.rs new file mode 100644 index 0000000..f13dd26 --- /dev/null +++ b/crates/agent_secrets/src/policy.rs @@ -0,0 +1,9 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +#[derive(Debug, Clone, Copy, Default)] +pub struct SecretPolicy { + pub allow_export: bool, + pub max_uses: Option, + pub ttl_seconds: Option, +} diff --git a/crates/agent_secrets/src/session.rs b/crates/agent_secrets/src/session.rs new file mode 100644 index 0000000..d749c5e --- /dev/null +++ b/crates/agent_secrets/src/session.rs @@ -0,0 +1,21 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +#[derive(Debug, Clone)] +pub struct SessionContext { + pub agent_id: String, + pub session_id: String, + pub verified: bool, + pub claims: Vec, +} + +impl SessionContext { + pub fn new(agent_id: impl Into, session_id: impl Into) -> Self { + Self { + agent_id: agent_id.into(), + session_id: session_id.into(), + verified: false, + claims: Vec::new(), + } + } +} diff --git a/crates/agent_transport_slim/Cargo.toml b/crates/agent_transport_slim/Cargo.toml new file mode 100644 index 0000000..7031aed --- /dev/null +++ b/crates/agent_transport_slim/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "agent_transport_slim" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +slim = [] + +[dependencies] +agent_secrets = { path = "../agent_secrets" } diff --git a/crates/agent_transport_slim/src/lib.rs b/crates/agent_transport_slim/src/lib.rs new file mode 100644 index 0000000..ee92120 --- /dev/null +++ b/crates/agent_transport_slim/src/lib.rs @@ -0,0 +1,205 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use agent_secrets::{AgentVerifier, SecretResult, SecretStore, SessionContext}; + +pub trait SlimSession: Send + Sync { + fn send(&self, message: &[u8]) -> SecretResult<()>; + fn recv(&self) -> SecretResult>; +} + +pub struct SecureAgentChannel<'a> { + session: &'a dyn SlimSession, + verifier: &'a dyn AgentVerifier, + store: &'a dyn SecretStore, +} + +impl<'a> SecureAgentChannel<'a> { + pub fn new( + session: &'a dyn SlimSession, + verifier: &'a dyn AgentVerifier, + store: &'a dyn SecretStore, + ) -> Self { + Self { + session, + verifier, + store, + } + } + + pub fn send(&self, ctx: &SessionContext, message: &[u8]) -> SecretResult<()> { + self.verifier.verify(ctx)?; + let _ = self.store; + self.session.send(message) + } + + pub fn recv(&self, ctx: &SessionContext) -> SecretResult> { + self.verifier.verify(ctx)?; + let _ = self.store; + self.session.recv() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_secrets::{SecretError, SecretStore}; + use agent_secrets::policy::SecretPolicy; + use agent_secrets::memory::SecretBytes; + use std::sync::Mutex; + + struct AllowVerifier; + + impl AgentVerifier for AllowVerifier { + fn verify(&self, _session: &SessionContext) -> SecretResult<()> { + Ok(()) + } + } + + struct DenyVerifier; + + impl AgentVerifier for DenyVerifier { + fn verify(&self, _session: &SessionContext) -> SecretResult<()> { + Err(SecretError::NotAuthorized) + } + } + + struct MemoryStore; + + impl SecretStore for MemoryStore { + fn put(&self, _key: &str, _secret: &[u8], _policy: SecretPolicy) -> SecretResult<()> { + Ok(()) + } + + fn get(&self, _key: &str) -> SecretResult { + Err(SecretError::InvalidInput) + } + + fn delete(&self, _key: &str) -> SecretResult<()> { + Ok(()) + } + + fn list_keys(&self) -> SecretResult> { + Ok(Vec::new()) + } + } + + struct TestSession { + sent: Mutex>, + recv_data: Vec, + } + + impl TestSession { + fn new() -> Self { + Self { + sent: Mutex::new(Vec::new()), + recv_data: b"reply".to_vec(), + } + } + } + + impl SlimSession for TestSession { + fn send(&self, message: &[u8]) -> SecretResult<()> { + let mut guard = self.sent.lock().map_err(|_| SecretError::StorageFailure)?; + guard.extend_from_slice(message); + Ok(()) + } + + fn recv(&self) -> SecretResult> { + Ok(self.recv_data.clone()) + } + } + + struct FailingSession; + + impl SlimSession for FailingSession { + fn send(&self, _message: &[u8]) -> SecretResult<()> { + Err(SecretError::StorageFailure) + } + + fn recv(&self) -> SecretResult> { + Err(SecretError::StorageFailure) + } + } + + #[test] + fn send_requires_verifier_success() { + let session = TestSession::new(); + let store = MemoryStore; + let allow = AllowVerifier; + let channel = SecureAgentChannel::new(&session, &allow, &store); + let ctx = SessionContext::new("agent", "session"); + + channel.send(&ctx, b"hello").unwrap(); + let sent = session.sent.lock().unwrap().clone(); + assert_eq!(sent, b"hello".to_vec()); + } + + #[test] + fn recv_denied_when_verifier_fails() { + let session = TestSession::new(); + let store = MemoryStore; + let deny = DenyVerifier; + let channel = SecureAgentChannel::new(&session, &deny, &store); + let ctx = SessionContext::new("agent", "session"); + + let err = channel.recv(&ctx).unwrap_err(); + assert!(matches!(err, SecretError::NotAuthorized)); + } + + #[test] + fn recv_returns_payload_when_allowed() { + let session = TestSession::new(); + let store = MemoryStore; + let allow = AllowVerifier; + let channel = SecureAgentChannel::new(&session, &allow, &store); + let ctx = SessionContext::new("agent", "session"); + + let payload = channel.recv(&ctx).unwrap(); + assert_eq!(payload, b"reply".to_vec()); + } + + #[test] + fn send_denied_when_verifier_fails() { + let session = TestSession::new(); + let store = MemoryStore; + let deny = DenyVerifier; + let channel = SecureAgentChannel::new(&session, &deny, &store); + let ctx = SessionContext::new("agent", "session"); + + let err = channel.send(&ctx, b"hello").unwrap_err(); + assert!(matches!(err, SecretError::NotAuthorized)); + } + + #[test] + fn send_propagates_session_error() { + let session = FailingSession; + let store = MemoryStore; + let allow = AllowVerifier; + let channel = SecureAgentChannel::new(&session, &allow, &store); + let ctx = SessionContext::new("agent", "session"); + + let err = channel.send(&ctx, b"hello").unwrap_err(); + assert!(matches!(err, SecretError::StorageFailure)); + } + + #[test] + fn recv_propagates_session_error() { + let session = FailingSession; + let store = MemoryStore; + let allow = AllowVerifier; + let channel = SecureAgentChannel::new(&session, &allow, &store); + let ctx = SessionContext::new("agent", "session"); + + let err = channel.recv(&ctx).unwrap_err(); + assert!(matches!(err, SecretError::StorageFailure)); + } + + #[test] + fn memory_store_methods_return_ok() { + let store = MemoryStore; + store.put("key", b"value", SecretPolicy::default()).unwrap(); + assert!(store.list_keys().unwrap().is_empty()); + store.delete("key").unwrap(); + } +} diff --git a/crates/shadi_memory/Cargo.toml b/crates/shadi_memory/Cargo.toml new file mode 100644 index 0000000..1f25fbd --- /dev/null +++ b/crates/shadi_memory/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "shadi_memory" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "shadi-memory" +path = "src/main.rs" + +[dependencies] +agent_secrets = { path = "../agent_secrets" } +clap = { version = "4.5", features = ["derive", "env"] } +rusqlite = { version = "0.31", features = ["bundled-sqlcipher"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +time = { version = "0.3", features = ["formatting"] } + +[dev-dependencies] +tempfile = "3.10" diff --git a/crates/shadi_memory/src/lib.rs b/crates/shadi_memory/src/lib.rs new file mode 100644 index 0000000..f41230e --- /dev/null +++ b/crates/shadi_memory/src/lib.rs @@ -0,0 +1,318 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use std::path::Path; + +use rusqlite::{params, Connection, OpenFlags}; +use thiserror::Error; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; + +#[derive(Debug, Error)] +pub enum MemoryError { + #[error("database error: {0}")] + Database(#[from] rusqlite::Error), + #[error("time formatting error: {0}")] + Time(#[from] time::error::Format), +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MemoryEntry { + pub id: i64, + pub scope: String, + pub entry_key: String, + pub payload: String, + pub created_at: String, +} + +pub struct SqlCipherStore { + conn: Connection, +} + +impl SqlCipherStore { + pub fn open(path: &Path, key: &str) -> Result { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + conn.pragma_update(None, "key", &key)?; + conn.pragma_update(None, "cipher_compatibility", &4)?; + conn.pragma_update(None, "foreign_keys", &"ON")?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS memory_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL, + entry_key TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memory_scope_key + ON memory_entries(scope, entry_key); + CREATE INDEX IF NOT EXISTS idx_memory_created_at + ON memory_entries(created_at); + ", + )?; + Ok(Self { conn }) + } + + pub fn put(&self, scope: &str, entry_key: &str, payload: &str) -> Result { + let created_at = OffsetDateTime::now_utc().format(&Rfc3339)?; + self.conn.execute( + "INSERT INTO memory_entries (scope, entry_key, payload, created_at) + VALUES (?1, ?2, ?3, ?4)", + params![scope, entry_key, payload, created_at], + )?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn get_latest( + &self, + scope: &str, + entry_key: &str, + ) -> Result, MemoryError> { + let mut stmt = self.conn.prepare( + "SELECT id, scope, entry_key, payload, created_at + FROM memory_entries + WHERE scope = ?1 AND entry_key = ?2 + ORDER BY created_at DESC + LIMIT 1", + )?; + let mut rows = stmt.query(params![scope, entry_key])?; + if let Some(row) = rows.next()? { + Ok(Some(MemoryEntry { + id: row.get(0)?, + scope: row.get(1)?, + entry_key: row.get(2)?, + payload: row.get(3)?, + created_at: row.get(4)?, + })) + } else { + Ok(None) + } + } + + pub fn search( + &self, + scope: Option<&str>, + query: &str, + limit: usize, + ) -> Result, MemoryError> { + let pattern = format!("%{}%", query); + let mut entries = Vec::new(); + + if let Some(scope) = scope { + let mut stmt = self.conn.prepare( + "SELECT id, scope, entry_key, payload, created_at + FROM memory_entries + WHERE scope = ?1 AND (entry_key LIKE ?2 OR payload LIKE ?2) + ORDER BY created_at DESC + LIMIT ?3", + )?; + let rows = stmt.query_map(params![scope, pattern, limit as i64], |row| { + Ok(MemoryEntry { + id: row.get(0)?, + scope: row.get(1)?, + entry_key: row.get(2)?, + payload: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + for row in rows { + entries.push(row?); + } + return Ok(entries); + } + + let mut stmt = self.conn.prepare( + "SELECT id, scope, entry_key, payload, created_at + FROM memory_entries + WHERE entry_key LIKE ?1 OR payload LIKE ?1 + ORDER BY created_at DESC + LIMIT ?2", + )?; + let rows = stmt.query_map(params![pattern, limit as i64], |row| { + Ok(MemoryEntry { + id: row.get(0)?, + scope: row.get(1)?, + entry_key: row.get(2)?, + payload: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + for row in rows { + entries.push(row?); + } + Ok(entries) + } + + pub fn list(&self, scope: Option<&str>, limit: usize) -> Result, MemoryError> { + let mut entries = Vec::new(); + if let Some(scope) = scope { + let mut stmt = self.conn.prepare( + "SELECT id, scope, entry_key, payload, created_at + FROM memory_entries + WHERE scope = ?1 + ORDER BY created_at DESC + LIMIT ?2", + )?; + let rows = stmt.query_map(params![scope, limit as i64], |row| { + Ok(MemoryEntry { + id: row.get(0)?, + scope: row.get(1)?, + entry_key: row.get(2)?, + payload: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + for row in rows { + entries.push(row?); + } + return Ok(entries); + } + + let mut stmt = self.conn.prepare( + "SELECT id, scope, entry_key, payload, created_at + FROM memory_entries + ORDER BY created_at DESC + LIMIT ?1", + )?; + let rows = stmt.query_map(params![limit as i64], |row| { + Ok(MemoryEntry { + id: row.get(0)?, + scope: row.get(1)?, + entry_key: row.get(2)?, + payload: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + for row in rows { + entries.push(row?); + } + Ok(entries) + } + + pub fn delete(&self, scope: &str, entry_key: &str) -> Result { + let affected = self.conn.execute( + "DELETE FROM memory_entries WHERE scope = ?1 AND entry_key = ?2", + params![scope, entry_key], + )?; + Ok(affected) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + fn open_store() -> SqlCipherStore { + let file = NamedTempFile::new().expect("tempfile"); + let path = file.path().to_path_buf(); + std::mem::forget(file); + SqlCipherStore::open(&path, "test-key").expect("open store") + } + + #[test] + fn put_get_latest_round_trip() { + let store = open_store(); + let id = store + .put("secops", "security_report", "payload-1") + .expect("put"); + assert!(id > 0); + + let entry = store + .get_latest("secops", "security_report") + .expect("get") + .expect("entry"); + assert_eq!(entry.payload, "payload-1"); + assert_eq!(entry.scope, "secops"); + assert_eq!(entry.entry_key, "security_report"); + } + + #[test] + fn search_filters_by_scope_and_query() { + let store = open_store(); + store + .put("secops", "security_report", "dependabot alert") + .expect("put"); + store + .put("secops", "notes", "weekly summary") + .expect("put"); + store + .put("other", "notes", "dependabot triage") + .expect("put"); + + let entries = store + .search(Some("secops"), "dependabot", 10) + .expect("search"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].scope, "secops"); + assert_eq!(entries[0].entry_key, "security_report"); + } + + #[test] + fn list_returns_latest_first() { + let store = open_store(); + store + .put("secops", "k1", "payload-1") + .expect("put"); + store + .put("secops", "k2", "payload-2") + .expect("put"); + + let entries = store.list(Some("secops"), 10).expect("list"); + assert_eq!(entries.len(), 2); + assert!(entries[0].created_at >= entries[1].created_at); + } + + #[test] + fn list_without_scope_returns_entries() { + let store = open_store(); + store + .put("secops", "k1", "payload-1") + .expect("put"); + let entries = store.list(None, 10).expect("list"); + assert_eq!(entries.len(), 1); + } + + #[test] + fn search_without_scope_matches_payload() { + let store = open_store(); + store + .put("secops", "k1", "dependabot") + .expect("put"); + let entries = store.search(None, "dependabot", 10).expect("search"); + assert_eq!(entries.len(), 1); + } + + #[test] + fn delete_removes_entries() { + let store = open_store(); + store + .put("secops", "k1", "payload-1") + .expect("put"); + let removed = store.delete("secops", "k1").expect("delete"); + assert_eq!(removed, 1); + + let entry = store + .get_latest("secops", "k1") + .expect("get"); + assert!(entry.is_none()); + } + + #[test] + fn get_latest_returns_none_when_missing() { + let store = open_store(); + let entry = store.get_latest("secops", "missing").expect("get"); + assert!(entry.is_none()); + } + + #[test] + fn delete_returns_zero_when_missing() { + let store = open_store(); + let removed = store.delete("secops", "missing").expect("delete"); + assert_eq!(removed, 0); + } +} diff --git a/crates/shadi_memory/src/main.rs b/crates/shadi_memory/src/main.rs new file mode 100644 index 0000000..fd726a7 --- /dev/null +++ b/crates/shadi_memory/src/main.rs @@ -0,0 +1,482 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use shadi_memory::{MemoryEntry, SqlCipherStore}; + +#[cfg(test)] +use std::collections::HashMap; +#[cfg(test)] +use std::sync::{Mutex, OnceLock}; + +#[derive(Parser, Debug)] +#[command(name = "shadi-memory")] +#[command(about = "Encrypted local memory store using SQLCipher")] +struct Cli { + #[arg(long, env = "SHADI_MEMORY_DB", value_name = "PATH")] + db: PathBuf, + + #[arg(long, env = "SHADI_MEMORY_KEY")] + key: Option, + + #[arg(long = "key-name", env = "SHADI_MEMORY_KEY_NAME", default_value = "shadi/memory/sqlcipher_key")] + key_name: String, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + Init, + Put { + #[arg(long)] + scope: String, + #[arg(long = "entry-key")] + entry_key: String, + #[arg(long)] + payload: Option, + #[arg(long = "payload-file")] + payload_file: Option, + }, + Get { + #[arg(long)] + scope: String, + #[arg(long = "entry-key")] + entry_key: String, + }, + Search { + #[arg(long)] + scope: Option, + #[arg(long)] + query: String, + #[arg(long, default_value = "10")] + limit: usize, + }, + List { + #[arg(long)] + scope: Option, + #[arg(long, default_value = "50")] + limit: usize, + }, + Delete { + #[arg(long)] + scope: String, + #[arg(long = "entry-key")] + entry_key: String, + }, +} + +#[cfg(test)] +static TEST_SECRET_STORE: OnceLock>>> = OnceLock::new(); + +#[cfg(test)] +fn test_secret_store_map() -> &'static Mutex>> { + TEST_SECRET_STORE.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +struct TestSecretStore; + +#[cfg(test)] +impl agent_secrets::SecretStore for TestSecretStore { + fn put( + &self, + key: &str, + secret: &[u8], + _policy: agent_secrets::SecretPolicy, + ) -> agent_secrets::SecretResult<()> { + let mut guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + guard.insert(key.to_string(), secret.to_vec()); + Ok(()) + } + + fn get(&self, key: &str) -> agent_secrets::SecretResult { + let guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + let value = guard + .get(key) + .ok_or(agent_secrets::SecretError::InvalidInput)? + .clone(); + Ok(agent_secrets::memory::SecretBytes::new(value)) + } + + fn delete(&self, key: &str) -> agent_secrets::SecretResult<()> { + let mut guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + guard.remove(key); + Ok(()) + } + + fn list_keys(&self) -> agent_secrets::SecretResult> { + let guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + Ok(guard.keys().cloned().collect()) + } +} + +#[cfg(test)] +fn default_secret_store() -> Box { + Box::new(TestSecretStore) +} + +#[cfg(not(test))] +fn default_secret_store() -> Box { + agent_secrets::default_store() +} + +#[cfg(test)] +fn test_store_put(key: &str, value: &[u8]) { + let mut guard = test_secret_store_map().lock().expect("test store lock"); + guard.insert(key.to_string(), value.to_vec()); +} + +fn main() -> Result<(), String> { + let cli = Cli::parse(); + let key = resolve_key(&cli)?; + let store = SqlCipherStore::open(&cli.db, &key).map_err(|err| err.to_string())?; + let output = handle_command(&cli, &store)?; + println!("{}", output); + Ok(()) +} + +fn handle_command(cli: &Cli, store: &SqlCipherStore) -> Result { + match &cli.command { + Command::Init => Ok("ok".to_string()), + Command::Put { + scope, + entry_key, + payload, + payload_file, + } => { + let payload = read_payload(payload.clone(), payload_file.clone())?; + let id = store + .put(scope, entry_key, &payload) + .map_err(|err| err.to_string())?; + Ok(serde_json::json!({"status": "saved", "id": id}).to_string()) + } + Command::Get { scope, entry_key } => { + let entry = store + .get_latest(scope, entry_key) + .map_err(|err| err.to_string())?; + match entry { + Some(entry) => serde_json::to_string_pretty(&entry).map_err(|err| err.to_string()), + None => Ok(serde_json::json!({"found": false}).to_string()), + } + } + Command::Search { + scope, + query, + limit, + } => { + let entries = store + .search(scope.as_deref(), query, *limit) + .map_err(|err| err.to_string())?; + format_entries(entries) + } + Command::List { scope, limit } => { + let entries = store + .list(scope.as_deref(), *limit) + .map_err(|err| err.to_string())?; + format_entries(entries) + } + Command::Delete { scope, entry_key } => { + let affected = store + .delete(scope, entry_key) + .map_err(|err| err.to_string())?; + Ok(serde_json::json!({"deleted": affected}).to_string()) + } + } +} + +fn resolve_key(cli: &Cli) -> Result { + if let Some(key) = cli.key.as_ref() { + if key.is_empty() { + return Err("SHADI_MEMORY_KEY is empty".to_string()); + } + return Ok(key.to_string()); + } + + let store = default_secret_store(); + let secret = store + .get(&cli.key_name) + .map_err(|_| format!("missing SHADI key: {}", cli.key_name))?; + let raw = secret.expose(|bytes| bytes.to_vec()); + String::from_utf8(raw).map_err(|_| "SHADI memory key is not utf-8".to_string()) +} + +fn read_payload(payload: Option, payload_file: Option) -> Result { + match (payload, payload_file) { + (Some(text), None) => Ok(text), + (None, Some(path)) => std::fs::read_to_string(&path) + .map_err(|err| format!("failed to read payload file: {}", err)), + (None, None) => Err("payload or payload-file must be provided".to_string()), + (Some(_), Some(_)) => Err("use either payload or payload-file".to_string()), + } +} + +fn format_entries(entries: Vec) -> Result { + serde_json::to_string_pretty(&entries).map_err(|err| err.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + use agent_secrets::SecretPolicy; + + fn unique_key(prefix: &str) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos(); + format!("{}-{}-{}", prefix, std::process::id(), nanos) + } + + fn temp_store() -> SqlCipherStore { + let file = NamedTempFile::new().expect("tempfile"); + let path = file.path().to_path_buf(); + std::mem::forget(file); + SqlCipherStore::open(&path, "test-key").expect("open store") + } + + fn tmp_dir() -> PathBuf { + PathBuf::from(std::env::var("SHADI_TMP_DIR").unwrap_or_else(|_| "./.tmp".to_string())) + } + + #[test] + fn resolve_key_prefers_cli_key() { + let cli = Cli { + db: tmp_dir().join("test.db"), + key: Some("secret".to_string()), + key_name: "unused".to_string(), + command: Command::Init, + }; + + let key = resolve_key(&cli).expect("resolve"); + assert_eq!(key, "secret"); + } + + #[test] + fn resolve_key_rejects_empty_string() { + let cli = Cli { + db: tmp_dir().join("test.db"), + key: Some("".to_string()), + key_name: "unused".to_string(), + command: Command::Init, + }; + + let err = resolve_key(&cli).unwrap_err(); + assert!(err.contains("SHADI_MEMORY_KEY is empty")); + } + + #[test] + fn resolve_key_reads_from_secret_store() { + let key_name = unique_key("shadi/memory/key"); + test_store_put(&key_name, b"memory-secret"); + + let cli = Cli { + db: tmp_dir().join("test.db"), + key: None, + key_name: key_name.clone(), + command: Command::Init, + }; + + let key = resolve_key(&cli).expect("resolve"); + assert_eq!(key, "memory-secret"); + } + + #[test] + fn resolve_key_errors_when_missing_secret() { + let key_name = unique_key("shadi/memory/missing"); + let cli = Cli { + db: tmp_dir().join("test.db"), + key: None, + key_name, + command: Command::Init, + }; + + let err = resolve_key(&cli).unwrap_err(); + assert!(err.contains("missing SHADI key")); + } + + #[test] + fn resolve_key_errors_on_non_utf8_secret() { + let key_name = unique_key("shadi/memory/bad"); + test_store_put(&key_name, &[0xff, 0xfe, 0xfd]); + + let cli = Cli { + db: tmp_dir().join("test.db"), + key: None, + key_name, + command: Command::Init, + }; + + let err = resolve_key(&cli).unwrap_err(); + assert!(err.contains("not utf-8")); + } + + #[test] + fn test_secret_store_roundtrip() { + let store = default_secret_store(); + let key = unique_key("shadi/memory/store"); + + store + .put(&key, b"value", SecretPolicy::default()) + .expect("put"); + + let keys = store.list_keys().expect("list"); + assert!(keys.iter().any(|item| item == &key)); + + store.delete(&key).expect("delete"); + } + + #[test] + fn read_payload_from_file() { + let file = NamedTempFile::new().expect("tempfile"); + std::fs::write(file.path(), "payload").expect("write"); + let payload = read_payload(None, Some(file.path().to_path_buf())).expect("read"); + assert_eq!(payload, "payload"); + } + + #[test] + fn read_payload_errors_on_missing_inputs() { + let err = read_payload(None, None).unwrap_err(); + assert!(err.contains("payload or payload-file")); + } + + #[test] + fn read_payload_errors_when_both_inputs_provided() { + let err = read_payload(Some("one".to_string()), Some(tmp_dir())).unwrap_err(); + assert!(err.contains("either payload or payload-file")); + } + + #[test] + fn read_payload_errors_when_file_missing() { + let err = read_payload(None, Some(tmp_dir().join("does-not-exist"))).unwrap_err(); + assert!(err.contains("failed to read payload file")); + } + + #[test] + fn handle_command_put_get_delete_roundtrip() { + let store = temp_store(); + let cli = Cli { + db: tmp_dir().join("test.db"), + key: Some("secret".to_string()), + key_name: "unused".to_string(), + command: Command::Put { + scope: "secops".to_string(), + entry_key: "report".to_string(), + payload: Some("payload".to_string()), + payload_file: None, + }, + }; + + let output = handle_command(&cli, &store).expect("put"); + assert!(output.contains("saved")); + + let get_cli = Cli { + command: Command::Get { + scope: "secops".to_string(), + entry_key: "report".to_string(), + }, + ..cli + }; + let output = handle_command(&get_cli, &store).expect("get"); + assert!(output.contains("payload")); + + let del_cli = Cli { + command: Command::Delete { + scope: "secops".to_string(), + entry_key: "report".to_string(), + }, + ..get_cli + }; + let output = handle_command(&del_cli, &store).expect("delete"); + assert!(output.contains("deleted")); + } + + #[test] + fn handle_command_init_returns_ok() { + let store = temp_store(); + let cli = Cli { + db: tmp_dir().join("test.db"), + key: Some("secret".to_string()), + key_name: "unused".to_string(), + command: Command::Init, + }; + let output = handle_command(&cli, &store).expect("init"); + assert_eq!(output, "ok"); + } + + #[test] + fn handle_command_get_missing_returns_false() { + let store = temp_store(); + let cli = Cli { + db: tmp_dir().join("test.db"), + key: Some("secret".to_string()), + key_name: "unused".to_string(), + command: Command::Get { + scope: "secops".to_string(), + entry_key: "missing".to_string(), + }, + }; + let output = handle_command(&cli, &store).expect("get"); + assert!(output.contains("\"found\":false")); + } + + #[test] + fn handle_command_list_and_search() { + let store = temp_store(); + store + .put("secops", "alert", "dependabot") + .expect("put"); + + let list_cli = Cli { + db: tmp_dir().join("test.db"), + key: Some("secret".to_string()), + key_name: "unused".to_string(), + command: Command::List { + scope: Some("secops".to_string()), + limit: 10, + }, + }; + let output = handle_command(&list_cli, &store).expect("list"); + assert!(output.contains("dependabot")); + + let search_cli = Cli { + command: Command::Search { + scope: Some("secops".to_string()), + query: "dependabot".to_string(), + limit: 10, + }, + ..list_cli + }; + let output = handle_command(&search_cli, &store).expect("search"); + assert!(output.contains("dependabot")); + } + + #[test] + fn format_entries_serializes_json() { + let entries = vec![MemoryEntry { + id: 1, + scope: "secops".to_string(), + entry_key: "report".to_string(), + payload: "ok".to_string(), + created_at: "2026-02-14T00:00:00Z".to_string(), + }]; + + let output = format_entries(entries).expect("format"); + assert!(output.contains("\"secops\"")); + assert!(output.contains("\"report\"")); + } +} diff --git a/crates/shadi_py/Cargo.toml b/crates/shadi_py/Cargo.toml new file mode 100644 index 0000000..0d4fc7c --- /dev/null +++ b/crates/shadi_py/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "shadi_py" +version = "0.1.0" +edition = "2021" + +[lib] +name = "shadi" +crate-type = ["cdylib"] +test = false +doctest = false + +[dependencies] +agent_secrets = { path = "../agent_secrets" } +shadi_memory = { path = "../shadi_memory" } +shadi_sandbox = { path = "../shadi_sandbox" } + +[dependencies.pyo3] +version = "0.21" +features = ["extension-module"] + +[build-dependencies] +pyo3-build-config = "0.21" diff --git a/crates/shadi_py/build.rs b/crates/shadi_py/build.rs new file mode 100644 index 0000000..dace4a9 --- /dev/null +++ b/crates/shadi_py/build.rs @@ -0,0 +1,3 @@ +fn main() { + pyo3_build_config::add_extension_module_link_args(); +} diff --git a/crates/shadi_py/src/lib.rs b/crates/shadi_py/src/lib.rs new file mode 100644 index 0000000..c75a010 --- /dev/null +++ b/crates/shadi_py/src/lib.rs @@ -0,0 +1,493 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; +use std::process::Command; +use std::sync::Mutex; + +use agent_secrets::{ + AgentSecretAccess, AgentVerifier, SecretError, SecretPolicy, SecretResult, SecretStore, + SessionContext, +}; +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyModule}; +use shadi_memory::{MemoryEntry as ShadiMemoryEntry, SqlCipherStore}; +use shadi_sandbox::{spawn_sandboxed, SandboxError, SandboxPolicy}; + +struct SessionFlagVerifier; + +impl AgentVerifier for SessionFlagVerifier { + fn verify(&self, session: &SessionContext) -> SecretResult<()> { + if session.verified { + Ok(()) + } else { + Err(SecretError::NotAuthorized) + } + } +} + +#[pyclass] +pub struct ShadiStore { + store: Mutex>, + verifier: SessionFlagVerifier, + didvc_verifier: Mutex>>, +} + +#[pyclass] +pub struct SqlCipherMemoryStore { + store: SqlCipherStore, +} + +#[pyclass] +#[derive(Clone)] +pub struct MemoryEntry { + #[pyo3(get)] + id: i64, + #[pyo3(get)] + scope: String, + #[pyo3(get)] + entry_key: String, + #[pyo3(get)] + payload: String, + #[pyo3(get)] + created_at: String, +} + +impl MemoryEntry { + fn from_native(entry: ShadiMemoryEntry) -> Self { + Self { + id: entry.id, + scope: entry.scope, + entry_key: entry.entry_key, + payload: entry.payload, + created_at: entry.created_at, + } + } +} + +#[pyclass] +pub struct SandboxPolicyHandle { + policy: SandboxPolicy, +} + +#[pymethods] +impl ShadiStore { + #[new] + fn new() -> Self { + Self { + store: Mutex::new(agent_secrets::default_store()), + verifier: SessionFlagVerifier, + didvc_verifier: Mutex::new(None), + } + } + + fn set_verifier(&self, verifier: PyObject) -> PyResult<()> { + let mut guard = self + .didvc_verifier + .lock() + .map_err(|_| PyRuntimeError::new_err("lock poisoned"))?; + *guard = Some(verifier); + Ok(()) + } + + fn verify_session( + &self, + py: Python<'_>, + session: &Bound<'_, PySessionContext>, + presentation: &[u8], + ) -> PyResult { + let verifier = { + let guard = self + .didvc_verifier + .lock() + .map_err(|_| PyRuntimeError::new_err("lock poisoned"))?; + guard.clone().ok_or_else(|| PyRuntimeError::new_err("verifier not configured"))? + }; + + let (agent_id, session_id, claims) = { + let session_ref = session.borrow(); + ( + session_ref.agent_id.clone(), + session_ref.session_id.clone(), + session_ref.claims.clone(), + ) + }; + + let payload = PyBytes::new_bound(py, presentation); + let result = verifier.call1(py, (agent_id, session_id, payload, claims))?; + let is_valid = result.is_truthy(py)?; + + if is_valid { + let mut session_ref = session.borrow_mut(); + session_ref.verified = true; + } + + Ok(is_valid) + } + + fn put(&self, session: &PySessionContext, key: &str, secret: &[u8]) -> PyResult<()> { + let ctx = session.to_context(); + let guard = self.store.lock().map_err(|_| PyRuntimeError::new_err("lock poisoned"))?; + let access = AgentSecretAccess::new(guard.as_ref(), &self.verifier); + access + .put_for_session(&ctx, key, secret, SecretPolicy::default()) + .map_err(map_secret_error) + } + + fn get<'py>( + &self, + py: Python<'py>, + session: &PySessionContext, + key: &str, + ) -> PyResult> { + let ctx = session.to_context(); + let guard = self.store.lock().map_err(|_| PyRuntimeError::new_err("lock poisoned"))?; + let access = AgentSecretAccess::new(guard.as_ref(), &self.verifier); + let secret = access.get_for_session(&ctx, key).map_err(map_secret_error)?; + let bytes = secret.expose(|data| data.to_vec()); + Ok(PyBytes::new_bound(py, &bytes)) + } + + fn delete(&self, session: &PySessionContext, key: &str) -> PyResult<()> { + let ctx = session.to_context(); + let guard = self.store.lock().map_err(|_| PyRuntimeError::new_err("lock poisoned"))?; + let access = AgentSecretAccess::new(guard.as_ref(), &self.verifier); + access + .delete_for_session(&ctx, key) + .map_err(map_secret_error) + } + + fn list_keys(&self, session: &PySessionContext) -> PyResult> { + let ctx = session.to_context(); + AgentSecretAccess::require_verified(&ctx).map_err(map_secret_error)?; + let guard = self.store.lock().map_err(|_| PyRuntimeError::new_err("lock poisoned"))?; + guard.list_keys().map_err(map_secret_error) + } +} + +#[pymethods] +impl SqlCipherMemoryStore { + #[new] + #[pyo3(signature = (db_path, key=None, key_name=None))] + fn new(db_path: String, key: Option, key_name: Option) -> PyResult { + let key = resolve_memory_key(key, key_name.as_deref())?; + let store = SqlCipherStore::open(db_path.as_ref(), &key) + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + Ok(Self { store }) + } + + fn put(&self, scope: &str, entry_key: &str, payload: &str) -> PyResult { + self.store + .put(scope, entry_key, payload) + .map_err(|err| PyRuntimeError::new_err(err.to_string())) + } + + fn get_latest(&self, scope: &str, entry_key: &str) -> PyResult> { + let entry = self + .store + .get_latest(scope, entry_key) + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + Ok(entry.map(MemoryEntry::from_native)) + } + + #[pyo3(signature = (query, scope=None, limit=10))] + fn search(&self, query: &str, scope: Option, limit: usize) -> PyResult> { + let entries = self + .store + .search(scope.as_deref(), query, limit) + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + Ok(entries + .into_iter() + .map(MemoryEntry::from_native) + .collect()) + } + + #[pyo3(signature = (scope=None, limit=50))] + fn list(&self, scope: Option, limit: usize) -> PyResult> { + let entries = self + .store + .list(scope.as_deref(), limit) + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + Ok(entries + .into_iter() + .map(MemoryEntry::from_native) + .collect()) + } + + fn delete(&self, scope: &str, entry_key: &str) -> PyResult { + self.store + .delete(scope, entry_key) + .map_err(|err| PyRuntimeError::new_err(err.to_string())) + } +} + +#[pymethods] +impl SandboxPolicyHandle { + #[new] + fn new() -> Self { + Self { + policy: SandboxPolicy::new(), + } + } + + fn allow_read_path(&mut self, path: &str) { + self.policy = self.policy.clone().allow_read_path(path); + } + + fn allow_write_path(&mut self, path: &str) { + self.policy = self.policy.clone().allow_write_path(path); + } + + fn block_network(&mut self, value: bool) { + self.policy = self.policy.clone().block_network(value); + } +} + +#[pyclass] +pub struct PySessionContext { + agent_id: String, + session_id: String, + verified: bool, + claims: Vec, +} + +#[pymethods] +impl PySessionContext { + #[new] + fn new(agent_id: String, session_id: String) -> Self { + Self { + agent_id, + session_id, + verified: false, + claims: Vec::new(), + } + } + + fn set_verified(&mut self, value: bool) { + self.verified = value; + } + + fn add_claim(&mut self, claim: String) { + self.claims.push(claim); + } +} + +impl PySessionContext { + fn to_context(&self) -> SessionContext { + SessionContext { + agent_id: self.agent_id.clone(), + session_id: self.session_id.clone(), + verified: self.verified, + claims: self.claims.clone(), + } + } +} + +#[pymodule] +fn shadi(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(run_sandboxed, m)?)?; + Ok(()) +} + +fn map_secret_error(err: SecretError) -> PyErr { + PyRuntimeError::new_err(err.to_string()) +} + +fn map_sandbox_error(err: SandboxError) -> PyErr { + PyRuntimeError::new_err(err.to_string()) +} + +fn resolve_memory_key(key: Option, key_name: Option<&str>) -> PyResult { + if let Some(key) = key { + if key.is_empty() { + return Err(PyRuntimeError::new_err("SHADI_MEMORY_KEY is empty")); + } + return Ok(key); + } + + let name = key_name.unwrap_or("shadi/memory/sqlcipher_key"); + let store = agent_secrets::default_store(); + let secret = store + .get(name) + .map_err(|_| PyRuntimeError::new_err(format!("missing SHADI key: {}", name)))?; + let raw = secret.expose(|bytes| bytes.to_vec()); + String::from_utf8(raw).map_err(|_| PyRuntimeError::new_err("SHADI memory key is not utf-8")) +} + +fn inject_keychain_with_store( + store: &dyn SecretStore, + command: &mut Command, + mappings: &[String], +) -> Result<(), String> { + for mapping in mappings { + let (key, env) = parse_key_env(mapping)?; + let secret = store + .get(key) + .map_err(|_| format!("keychain lookup failed for {}", key))?; + let value = secret.expose(|bytes| bytes.to_vec()); + let value = String::from_utf8(value).map_err(|_| "secret is not utf-8".to_string())?; + command.env(env, value); + } + + Ok(()) +} + +fn parse_key_env(value: &str) -> Result<(&str, &str), String> { + let mut parts = value.splitn(2, '='); + let key = parts.next().unwrap_or(""); + let env = parts.next().unwrap_or(""); + if key.is_empty() || env.is_empty() { + return Err("inject-keychain must be in KEY=ENV format".to_string()); + } + Ok((key, env)) +} + +#[pyfunction] +#[pyo3(signature = (command, policy, cwd=None, env=None, inject_keychain=None))] +fn run_sandboxed( + command: Vec, + policy: &SandboxPolicyHandle, + cwd: Option, + env: Option>, + inject_keychain: Option>, +) -> PyResult { + if command.is_empty() { + return Err(PyRuntimeError::new_err("command must not be empty")); + } + + let mut cmd = Command::new(&command[0]); + if command.len() > 1 { + cmd.args(&command[1..]); + } + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } + if let Some(env_map) = env { + cmd.envs(env_map); + } + if let Some(mappings) = inject_keychain { + let store = agent_secrets::default_store(); + inject_keychain_with_store(store.as_ref(), &mut cmd, &mappings) + .map_err(PyRuntimeError::new_err)?; + } + + let mut child = spawn_sandboxed(&mut cmd, &policy.policy).map_err(map_sandbox_error)?; + let status = child + .wait() + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + Ok(status.code().unwrap_or(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Once; + use std::time::{SystemTime, UNIX_EPOCH}; + + static PY_INIT: Once = Once::new(); + + fn ensure_python() { + PY_INIT.call_once(|| { + pyo3::prepare_freethreaded_python(); + }); + } + + fn unique_key(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos(); + format!("{}-{}-{}", prefix, std::process::id(), nanos) + } + + #[cfg(target_os = "macos")] + #[test] + fn verify_session_sets_verified_flag() { + ensure_python(); + Python::with_gil(|py| { + let store = ShadiStore::new(); + let module = PyModule::from_code_bound( + py, + "def verify(agent_id, session_id, presentation, claims):\n return True\n", + "verifier.py", + "verifier", + ) + .unwrap(); + let verifier = module.getattr("verify").unwrap(); + store.set_verifier(verifier.into_py(py)).unwrap(); + + let mut base_session = PySessionContext::new("agent".to_string(), "session".to_string()); + base_session.add_claim("did:example:agent".to_string()); + let session = Py::new(py, base_session).unwrap(); + let session_bound = session.bind(py); + + let ok = store.verify_session(py, session_bound, b"presentation").unwrap(); + assert!(ok); + assert!(session_bound.borrow().verified); + }); + } + + #[cfg(target_os = "macos")] + #[test] + fn verify_session_requires_verifier() { + ensure_python(); + Python::with_gil(|py| { + let store = ShadiStore::new(); + let session = Py::new(py, PySessionContext::new("agent".to_string(), "session".to_string())).unwrap(); + let session_bound = session.bind(py); + + let err = store + .verify_session(py, session_bound, b"presentation") + .unwrap_err(); + assert!(err.is_instance_of::(py)); + }); + } + + #[cfg(target_os = "macos")] + #[test] + fn put_get_delete_roundtrip_requires_verified() { + ensure_python(); + Python::with_gil(|py| { + let store = ShadiStore::new(); + let mut session = PySessionContext::new("agent".to_string(), "session".to_string()); + session.add_claim("role:tourist".to_string()); + + let err = store.put(&session, "key", b"value").unwrap_err(); + assert!(err.is_instance_of::(py)); + + session.set_verified(true); + let key = unique_key("shadi-py"); + let secret = b"secret-value"; + + store.put(&session, &key, secret).unwrap(); + let bytes = store.get(py, &session, &key).unwrap(); + assert_eq!(bytes.as_bytes(), secret); + store.delete(&session, &key).unwrap(); + }); + } + + #[cfg(target_os = "macos")] + #[test] + fn list_keys_requires_verified() { + ensure_python(); + Python::with_gil(|_py| { + let store = ShadiStore::new(); + let mut session = PySessionContext::new("agent".to_string(), "session".to_string()); + session.add_claim("role:secops".to_string()); + session.set_verified(true); + + let key = unique_key("shadi-py-list"); + store.put(&session, &key, b"value").unwrap(); + let keys = store.list_keys(&session).unwrap(); + assert!(keys.iter().any(|item| item == &key)); + + store.delete(&session, &key).unwrap(); + }); + } +} diff --git a/crates/shadi_sandbox/Cargo.toml b/crates/shadi_sandbox/Cargo.toml new file mode 100644 index 0000000..d8b4a1d --- /dev/null +++ b/crates/shadi_sandbox/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "shadi_sandbox" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +coverage = [] + +[dependencies] +thiserror = "1.0" + +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_JobObjects", + "Win32_System_Threading", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Isolation", + "Win32_Storage_FileSystem", + "Win32_System_Memory", + "Win32_System_WindowsProgramming" +] } diff --git a/crates/shadi_sandbox/src/lib.rs b/crates/shadi_sandbox/src/lib.rs new file mode 100644 index 0000000..e55f614 --- /dev/null +++ b/crates/shadi_sandbox/src/lib.rs @@ -0,0 +1,246 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +pub mod policy; +mod platform; + +pub use policy::SandboxPolicy; +use std::process::{Command, ExitStatus}; +use std::io; + +pub fn spawn_sandboxed(command: &mut Command, policy: &SandboxPolicy) -> Result { + platform::spawn_sandboxed(command, policy) +} + +pub struct SandboxedChild { + inner: SandboxedChildInner, +} + +enum SandboxedChildInner { + Std(std::process::Child), + #[cfg(target_os = "windows")] + Windows(WindowsChild), +} + +impl SandboxedChild { + pub fn from_std(child: std::process::Child) -> Self { + Self { + inner: SandboxedChildInner::Std(child), + } + } + + #[cfg(target_os = "windows")] + pub fn from_windows(child: WindowsChild) -> Self { + Self { + inner: SandboxedChildInner::Windows(child), + } + } + + pub fn wait(&mut self) -> io::Result { + match &mut self.inner { + SandboxedChildInner::Std(child) => child.wait(), + #[cfg(target_os = "windows")] + SandboxedChildInner::Windows(child) => child.wait(), + } + } + + pub fn kill(&mut self) -> io::Result<()> { + match &mut self.inner { + SandboxedChildInner::Std(child) => child.kill(), + #[cfg(target_os = "windows")] + SandboxedChildInner::Windows(child) => child.kill(), + } + } + + pub fn id(&self) -> u32 { + match &self.inner { + SandboxedChildInner::Std(child) => child.id(), + #[cfg(target_os = "windows")] + SandboxedChildInner::Windows(child) => child.id(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SandboxError { + #[error("sandbox not supported on this platform")] + NotSupported, + #[error("invalid sandbox configuration")] + InvalidConfig, + #[error("sandbox apply failed: {0}")] + ApplyFailed(String), + #[error("spawn failed: {0}")] + SpawnFailed(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sandbox_error_display_message() { + let err = SandboxError::InvalidConfig; + assert!(format!("{}", err).contains("invalid sandbox configuration")); + } + + #[test] + fn sandbox_error_apply_failed_message() { + let err = SandboxError::ApplyFailed("boom".to_string()); + assert!(format!("{}", err).contains("sandbox apply failed")); + } + + #[cfg(target_os = "macos")] + #[test] + fn spawn_sandboxed_runs_command() { + let mut command = Command::new("/usr/bin/true"); + let policy = SandboxPolicy::new().allow_read_path("/usr/bin"); + let mut child = spawn_sandboxed(&mut command, &policy).expect("spawn"); + let _ = child.wait().expect("wait"); + } + + #[cfg(unix)] + #[test] + fn sandboxed_child_wraps_std_process() { + let child = Command::new("/usr/bin/true").spawn().expect("spawn"); + let mut wrapped = SandboxedChild::from_std(child); + assert!(wrapped.id() > 0); + let status = wrapped.wait().expect("wait"); + assert!(status.success()); + } + + #[cfg(unix)] + #[test] + fn sandboxed_child_kill_stops_process() { + let child = Command::new("/bin/sleep") + .arg("5") + .spawn() + .expect("spawn"); + let mut wrapped = SandboxedChild::from_std(child); + wrapped.kill().expect("kill"); + let _ = wrapped.wait().expect("wait"); + } +} + +#[cfg(target_os = "windows")] +pub struct WindowsAclRollback { + path: Vec, + dacl: *mut core::ffi::c_void, + security_descriptor: *mut core::ffi::c_void, +} + +#[cfg(target_os = "windows")] +pub struct WindowsChild { + process: windows_sys::Win32::Foundation::HANDLE, + thread: windows_sys::Win32::Foundation::HANDLE, + pid: u32, + rollbacks: Vec, + cleaned: bool, +} + +#[cfg(target_os = "windows")] +impl WindowsChild { + pub fn new( + process: windows_sys::Win32::Foundation::HANDLE, + thread: windows_sys::Win32::Foundation::HANDLE, + pid: u32, + rollbacks: Vec, + ) -> Self { + Self { + process, + thread, + pid, + rollbacks, + cleaned: false, + } + } + + pub fn wait(&mut self) -> io::Result { + use windows_sys::Win32::System::Threading::{GetExitCodeProcess, WaitForSingleObject, INFINITE}; + use std::os::windows::process::ExitStatusExt; + + unsafe { + let wait = WaitForSingleObject(self.process, INFINITE); + if wait == u32::MAX { + return Err(io::Error::last_os_error()); + } + let mut code: u32 = 1; + if GetExitCodeProcess(self.process, &mut code) == 0 { + return Err(io::Error::last_os_error()); + } + let _ = self.cleanup(); + Ok(ExitStatus::from_raw(code)) + } + } + + pub fn kill(&mut self) -> io::Result<()> { + use windows_sys::Win32::System::Threading::TerminateProcess; + unsafe { + if TerminateProcess(self.process, 1) == 0 { + return Err(io::Error::last_os_error()); + } + let _ = self.cleanup(); + Ok(()) + } + } + + pub fn id(&self) -> u32 { + self.pid + } + + fn cleanup(&mut self) -> io::Result<()> { + use windows_sys::Win32::Foundation::CloseHandle; + + if self.cleaned { + return Ok(()); + } + + self.cleaned = true; + restore_acl_rollbacks(&mut self.rollbacks); + + unsafe { + if !self.thread.is_null() { + CloseHandle(self.thread); + self.thread = std::ptr::null_mut(); + } + if !self.process.is_null() { + CloseHandle(self.process); + self.process = std::ptr::null_mut(); + } + } + + Ok(()) + } +} + +#[cfg(target_os = "windows")] +impl Drop for WindowsChild { + fn drop(&mut self) { + let _ = self.cleanup(); + } +} + +#[cfg(target_os = "windows")] +fn restore_acl_rollbacks(rollbacks: &mut Vec) { + use windows_sys::Win32::Security::Authorization::SetNamedSecurityInfoW; + use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; + use windows_sys::Win32::Security::Authorization::SE_FILE_OBJECT; + use windows_sys::Win32::Foundation::LocalFree; + + for rollback in rollbacks.drain(..) { + unsafe { + let _ = SetNamedSecurityInfoW( + rollback.path.as_ptr() as *mut u16, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + rollback.dacl as *mut _, + std::ptr::null_mut(), + ); + + if !rollback.security_descriptor.is_null() { + LocalFree(rollback.security_descriptor); + } + } + } +} diff --git a/crates/shadi_sandbox/src/platform/macos.rs b/crates/shadi_sandbox/src/platform/macos.rs new file mode 100644 index 0000000..5620722 --- /dev/null +++ b/crates/shadi_sandbox/src/platform/macos.rs @@ -0,0 +1,273 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::{CStr, CString}; +#[cfg(not(any(test, feature = "coverage")))] +use std::os::unix::process::CommandExt; +use std::process::Command; + +use crate::{SandboxError, SandboxPolicy, SandboxedChild}; + +const DEFAULT_READ_PATHS: &[&str] = &[ + "/System", + "/usr/lib", + "/usr/libexec", + "/Library", + "/etc", + "/private/var", +]; + +#[cfg(not(any(test, feature = "coverage")))] +pub fn spawn_sandboxed(command: &mut Command, policy: &SandboxPolicy) -> Result { + let profile = build_profile(policy)?; + let profile_cstr = CString::new(profile).map_err(|_| SandboxError::InvalidConfig)?; + + unsafe { + command.pre_exec(move || { + apply_profile(&profile_cstr) + .map_err(std::io::Error::other) + }); + } + + let child = command.spawn().map_err(|err| SandboxError::SpawnFailed(err.to_string()))?; + Ok(SandboxedChild::from_std(child)) +} + +#[cfg(any(test, feature = "coverage"))] +pub fn spawn_sandboxed(command: &mut Command, policy: &SandboxPolicy) -> Result { + let profile = build_profile(policy)?; + let profile_cstr = CString::new(profile).map_err(|_| SandboxError::InvalidConfig)?; + apply_profile(profile_cstr.as_c_str())?; + let child = command.spawn().map_err(|err| SandboxError::SpawnFailed(err.to_string()))?; + Ok(SandboxedChild::from_std(child)) +} + +fn build_profile(policy: &SandboxPolicy) -> Result { + let mut rules = Vec::new(); + + rules.push("(version 1)".to_string()); + rules.push("(deny default)".to_string()); + rules.push("(allow process*)".to_string()); + rules.push("(allow process-exec)".to_string()); + rules.push("(allow mach-lookup (global-name \"com.apple.securityd\"))".to_string()); + rules.push("(allow mach-lookup (global-name \"com.apple.trustd\"))".to_string()); + rules.push("(allow mach-lookup (global-name \"com.apple.trustd.agent\"))".to_string()); + rules.push("(allow mach-lookup (global-name \"com.apple.secd\"))".to_string()); + + for path in DEFAULT_READ_PATHS { + rules.push(format!( + "(allow file-read* file-map-executable (subpath \"{}\"))", + path + )); + } + + rules.push("(allow file-read* file-write* (subpath \"/private/var\"))".to_string()); + + rules.push("(allow file-read* file-write* (subpath \"/Library/Keychains\"))".to_string()); + rules.push("(allow file-read* file-write* (subpath \"/private/var/db/Keychains\"))".to_string()); + rules.push("(allow file-read* file-write* (subpath \"/private/var/db/SystemKey\"))".to_string()); + if let Ok(home) = std::env::var("HOME") { + rules.push(format!( + "(allow file-read* file-write* (subpath \"{}/Library/Keychains\"))", + home + )); + rules.push(format!( + "(allow file-read* file-write* (subpath \"{}/Library\"))", + home + )); + } + + for path in policy.allow_read() { + if let Some(path) = path.to_str() { + rules.push(format!( + "(allow file-read* file-map-executable (subpath \"{}\"))", + path + )); + } else { + return Err(SandboxError::InvalidConfig); + } + } + + for path in policy.allow_write() { + if let Some(path) = path.to_str() { + rules.push(format!( + "(allow file-write* file-map-executable (subpath \"{}\"))", + path + )); + } else { + return Err(SandboxError::InvalidConfig); + } + } + + if !policy.net_blocked() { + rules.push("(allow network*)".to_string()); + } + + Ok(rules.join("\n")) +} + +#[cfg(not(any(test, feature = "coverage")))] +fn apply_profile(profile: &CStr) -> Result<(), SandboxError> { + let mut error_ptr: *mut libc::c_char = std::ptr::null_mut(); + let rc = unsafe { sandbox_init(profile.as_ptr(), 0, &mut error_ptr) }; + + if rc != 0 { + let message = unsafe { + if error_ptr.is_null() { + "sandbox_init failed".to_string() + } else { + let err = CStr::from_ptr(error_ptr).to_string_lossy().into_owned(); + sandbox_free_error(error_ptr); + err + } + }; + return Err(SandboxError::ApplyFailed(message)); + } + + Ok(()) +} + +#[cfg(any(test, feature = "coverage"))] +fn apply_profile(_profile: &CStr) -> Result<(), SandboxError> { + Ok(()) +} + +#[cfg(not(any(test, feature = "coverage")))] +#[link(name = "sandbox")] +extern "C" { + fn sandbox_init(profile: *const libc::c_char, flags: u64, errorbuf: *mut *mut libc::c_char) -> libc::c_int; + fn sandbox_free_error(errorbuf: *mut libc::c_char); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SandboxPolicy; + use std::path::PathBuf; + use std::sync::Mutex; + + static HOME_LOCK: Mutex<()> = Mutex::new(()); + + fn tmp_root() -> String { + std::env::var("SHADI_TMP_DIR").unwrap_or_else(|_| "./.tmp".to_string()) + } + + #[test] + fn build_profile_includes_paths_and_network_rule() { + let tmp_dir = tmp_root(); + let policy = SandboxPolicy::new() + .allow_read_path(&tmp_dir) + .allow_write_path(&tmp_dir) + .block_network(false); + + let profile = build_profile(&policy).unwrap(); + assert!(profile.contains(&format!( + "(allow file-read* file-map-executable (subpath \"{}\"))", + tmp_dir + ))); + assert!(profile.contains(&format!( + "(allow file-write* file-map-executable (subpath \"{}\"))", + tmp_dir + ))); + assert!(profile.contains("(allow network*)")); + } + + #[test] + fn build_profile_blocks_network_when_enabled() { + let policy = SandboxPolicy::new().block_network(true); + let profile = build_profile(&policy).unwrap(); + assert!(!profile.contains("(allow network*)")); + } + + #[test] + fn build_profile_includes_default_read_paths() { + let policy = SandboxPolicy::new(); + let profile = build_profile(&policy).unwrap(); + assert!(profile.contains("/System")); + assert!(profile.contains("/usr/lib")); + } + + #[test] + fn build_profile_includes_home_keychain_paths() { + let _guard = HOME_LOCK.lock().expect("home lock"); + let original = std::env::var("HOME").ok(); + let tmp_dir = tmp_root(); + std::env::set_var("HOME", &tmp_dir); + let policy = SandboxPolicy::new(); + let profile = build_profile(&policy).unwrap(); + assert!(profile.contains(&format!("{}/Library/Keychains", tmp_dir))); + assert!(profile.contains(&format!("{}/Library", tmp_dir))); + + if let Some(value) = original { + std::env::set_var("HOME", value); + } else { + std::env::remove_var("HOME"); + } + } + + #[test] + fn build_profile_skips_home_paths_when_unset() { + let _guard = HOME_LOCK.lock().expect("home lock"); + let original = std::env::var("HOME").ok(); + let tmp_dir = tmp_root(); + let home_dir = format!("{}/shadi-home", tmp_dir); + std::env::set_var("HOME", &home_dir); + let policy = SandboxPolicy::new(); + let with_home = build_profile(&policy).unwrap(); + assert!(with_home.contains(&format!("{}/Library/Keychains", home_dir))); + + std::env::remove_var("HOME"); + let without_home = build_profile(&policy).unwrap(); + assert!(!without_home.contains(&format!("{}/Library/Keychains", home_dir))); + + if let Some(value) = original { + std::env::set_var("HOME", value); + } + } + + #[test] + fn build_profile_includes_keychain_system_paths() { + let policy = SandboxPolicy::new(); + let profile = build_profile(&policy).unwrap(); + assert!(profile.contains("/Library/Keychains")); + assert!(profile.contains("/private/var/db/Keychains")); + } + + #[test] + fn build_profile_rejects_non_utf8_paths() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let mut bytes = Vec::new(); + bytes.push(0xff); + bytes.push(0xfe); + let bad = OsString::from_vec(bytes); + let path = PathBuf::from(bad); + + let policy = SandboxPolicy::new().allow_read_path(path); + let err = build_profile(&policy).unwrap_err(); + assert!(matches!(err, SandboxError::InvalidConfig)); + } + + #[test] + fn build_profile_rejects_non_utf8_write_paths() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let mut bytes = Vec::new(); + bytes.push(0xff); + bytes.push(0xfe); + let bad = OsString::from_vec(bytes); + let path = PathBuf::from(bad); + + let policy = SandboxPolicy::new().allow_write_path(path); + let err = build_profile(&policy).unwrap_err(); + assert!(matches!(err, SandboxError::InvalidConfig)); + } + + #[test] + fn apply_profile_noop_in_tests() { + let profile = CStr::from_bytes_with_nul(b"(version 1)\0").expect("cstr"); + apply_profile(profile).expect("apply"); + } +} diff --git a/crates/shadi_sandbox/src/platform/mod.rs b/crates/shadi_sandbox/src/platform/mod.rs new file mode 100644 index 0000000..21191bb --- /dev/null +++ b/crates/shadi_sandbox/src/platform/mod.rs @@ -0,0 +1,30 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use std::process::Command; + +use crate::{SandboxError, SandboxPolicy, SandboxedChild}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +pub fn spawn_sandboxed(command: &mut Command, policy: &SandboxPolicy) -> Result { + #[cfg(target_os = "macos")] + { + macos::spawn_sandboxed(command, policy) + } + + #[cfg(target_os = "windows")] + { + windows::spawn_sandboxed(command, policy) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = command; + let _ = policy; + Err(SandboxError::NotSupported) + } +} diff --git a/crates/shadi_sandbox/src/platform/windows.rs b/crates/shadi_sandbox/src/platform/windows.rs new file mode 100644 index 0000000..1a44ead --- /dev/null +++ b/crates/shadi_sandbox/src/platform/windows.rs @@ -0,0 +1,375 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::process::Command; + +use crate::{SandboxError, SandboxPolicy, SandboxedChild, WindowsAclRollback, WindowsChild}; + +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, ERROR_ALREADY_EXISTS}; +use windows_sys::Win32::Security::{ + DeriveCapabilitySidsFromName, SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES, NO_INHERITANCE, +}; +use windows_sys::Win32::Security::Isolation::{ + CreateAppContainerProfile, DeriveAppContainerSidFromAppContainerName, +}; +use windows_sys::Win32::Security::Authorization::{ + GetNamedSecurityInfoW, SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, TRUSTEE_IS_SID, + TRUSTEE_W, GRANT_ACCESS, SE_FILE_OBJECT, +}; +use windows_sys::Win32::Security::ACL; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject, + JobObjectExtendedLimitInformation, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, +}; +use windows_sys::Win32::System::Threading::{ + CreateProcessW, InitializeProcThreadAttributeList, UpdateProcThreadAttribute, + DeleteProcThreadAttributeList, PROCESS_INFORMATION, STARTUPINFOEXW, + EXTENDED_STARTUPINFO_PRESENT, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, +}; +use windows_sys::Win32::Foundation::LocalFree; + +pub fn spawn_sandboxed(command: &mut Command, policy: &SandboxPolicy) -> Result { + let program = command.get_program().to_string_lossy().to_string(); + let args = command.get_args().map(|arg| arg.to_string_lossy().to_string()).collect::>(); + + let appcontainer = AppContainer::new("shadi_sandbox", policy.net_blocked()) + .map_err(SandboxError::ApplyFailed)?; + + let mut rollbacks = Vec::new(); + for path in policy.allow_read() { + let rollback = grant_path_access(appcontainer.sid(), path, true, false) + .map_err(SandboxError::ApplyFailed)?; + rollbacks.push(rollback); + } + + for path in policy.allow_write() { + let rollback = grant_path_access(appcontainer.sid(), path, true, true) + .map_err(SandboxError::ApplyFailed)?; + rollbacks.push(rollback); + } + + let child = match spawn_appcontainer_process(&program, &args, &appcontainer, rollbacks) { + Ok(child) => child, + Err(err) => { + return Err(SandboxError::SpawnFailed(err)); + } + }; + + apply_job_object(child.process).map_err(SandboxError::ApplyFailed)?; + + Ok(SandboxedChild::from_windows(child)) +} + +struct AppContainer { + sid: *mut core::ffi::c_void, + caps: *mut SID_AND_ATTRIBUTES, + group_caps: *mut SID_AND_ATTRIBUTES, + cap_count: u32, +} + +impl AppContainer { + fn new(name: &str, net_blocked: bool) -> Result { + let name_w = to_wide(name); + let display_w = to_wide("SHADI Sandbox"); + let description_w = to_wide("SHADI AppContainer"); + + let mut sid: *mut core::ffi::c_void = std::ptr::null_mut(); + let rc = unsafe { + CreateAppContainerProfile( + name_w.as_ptr(), + display_w.as_ptr(), + description_w.as_ptr(), + std::ptr::null_mut(), + 0, + &mut sid, + ) + }; + + if rc != 0 { + if rc as u32 == ERROR_ALREADY_EXISTS { + let ok = unsafe { DeriveAppContainerSidFromAppContainerName(name_w.as_ptr(), &mut sid) }; + if ok != 0 { + return Err("DeriveAppContainerSidFromAppContainerName failed".to_string()); + } + } else { + return Err("CreateAppContainerProfile failed".to_string()); + } + } + + let mut caps: *mut SID_AND_ATTRIBUTES = std::ptr::null_mut(); + let mut group_caps: *mut SID_AND_ATTRIBUTES = std::ptr::null_mut(); + let mut cap_count: u32 = 0; + let mut group_cap_count: u32 = 0; + if !net_blocked { + let cap_name = to_wide("internetClient"); + let mut caps_raw: *mut *mut core::ffi::c_void = std::ptr::null_mut(); + let mut group_caps_raw: *mut *mut core::ffi::c_void = std::ptr::null_mut(); + let ok = unsafe { + DeriveCapabilitySidsFromName( + cap_name.as_ptr(), + &mut group_caps_raw, + &mut group_cap_count, + &mut caps_raw, + &mut cap_count, + ) + }; + caps = caps_raw as *mut SID_AND_ATTRIBUTES; + group_caps = group_caps_raw as *mut SID_AND_ATTRIBUTES; + if ok == 0 || caps.is_null() || cap_count == 0 { + return Err("DeriveCapabilitySidsFromName failed".to_string()); + } + } + + Ok(Self { + sid, + caps, + group_caps, + cap_count, + }) + } + + fn sid(&self) -> *mut core::ffi::c_void { + self.sid + } +} + +impl Drop for AppContainer { + fn drop(&mut self) { + unsafe { + if !self.sid.is_null() { + LocalFree(self.sid); + } + if !self.caps.is_null() { + LocalFree(self.caps as *mut _); + } + if !self.group_caps.is_null() { + LocalFree(self.group_caps as *mut _); + } + } + } +} + +fn spawn_appcontainer_process( + program: &str, + args: &[String], + appcontainer: &AppContainer, + rollbacks: Vec, +) -> Result { + let cmdline = build_command_line(program, args); + let mut cmdline_w = to_wide(&cmdline); + + let mut caps = SECURITY_CAPABILITIES { + AppContainerSid: appcontainer.sid(), + Capabilities: appcontainer.caps, + CapabilityCount: appcontainer.cap_count, + Reserved: 0, + }; + + let mut size: usize = 0; + unsafe { + InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut size); + } + let mut buffer = vec![0u8; size]; + let list = buffer.as_mut_ptr() as windows_sys::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST; + let ok = unsafe { InitializeProcThreadAttributeList(list, 1, 0, &mut size) }; + if ok == 0 { + return Err("InitializeProcThreadAttributeList failed".to_string()); + } + + let ok = unsafe { + UpdateProcThreadAttribute( + list, + 0, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize, + &mut caps as *mut _ as *mut _, + std::mem::size_of::(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if ok == 0 { + unsafe { DeleteProcThreadAttributeList(list) }; + return Err("UpdateProcThreadAttribute failed".to_string()); + } + + let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.lpAttributeList = list; + + let mut info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let ok = unsafe { + CreateProcessW( + std::ptr::null(), + cmdline_w.as_mut_ptr(), + std::ptr::null_mut(), + std::ptr::null_mut(), + 0, + EXTENDED_STARTUPINFO_PRESENT, + std::ptr::null_mut(), + std::ptr::null(), + &startup.StartupInfo, + &mut info, + ) + }; + + unsafe { DeleteProcThreadAttributeList(list) }; + + if ok == 0 { + return Err("CreateProcessW failed".to_string()); + } + + Ok(WindowsChild::new(info.hProcess, info.hThread, info.dwProcessId, rollbacks)) +} + +fn build_command_line(program: &str, args: &[String]) -> String { + let mut out = String::new(); + out.push_str("e_arg(program)); + for arg in args { + out.push(' '); + out.push_str("e_arg(arg)); + } + out +} + +fn quote_arg(arg: &str) -> String { + if arg.contains(' ') || arg.contains('"') { + let escaped = arg.replace('"', "\\\""); + format!("\"{}\"", escaped) + } else { + arg.to_string() + } +} + +fn to_wide(value: &str) -> Vec { + OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +fn grant_path_access( + sid: *mut core::ffi::c_void, + path: &Path, + read: bool, + write: bool, +) -> Result { + let mut access_mask: u32 = 0; + if read { + access_mask |= windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; + access_mask |= windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; + } + if write { + access_mask |= windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; + } + + if access_mask == 0 { + return Err("no access requested".to_string()); + } + + let rollback = capture_dacl(path)?; + + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: 0, + ptstrName: sid as *mut u16, + }; + + let mut entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: access_mask, + grfAccessMode: GRANT_ACCESS, + grfInheritance: NO_INHERITANCE, + Trustee: trustee, + }; + + let mut acl: *mut ACL = std::ptr::null_mut(); + let result = unsafe { SetEntriesInAclW(1, &mut entry, rollback.dacl as *mut ACL, &mut acl) }; + if result != 0 { + return Err("SetEntriesInAclW failed".to_string()); + } + + let path_w = to_wide(path.to_string_lossy().as_ref()); + let result = unsafe { + SetNamedSecurityInfoW( + path_w.as_ptr() as *mut u16, + SE_FILE_OBJECT, + windows_sys::Win32::Security::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + acl, + std::ptr::null_mut(), + ) + }; + unsafe { + if !acl.is_null() { + LocalFree(acl as *mut _); + } + } + if result != 0 { + return Err("SetNamedSecurityInfoW failed".to_string()); + } + + Ok(rollback) +} + +fn capture_dacl(path: &Path) -> Result { + let mut dacl: *mut core::ffi::c_void = std::ptr::null_mut(); + let mut security_descriptor: *mut core::ffi::c_void = std::ptr::null_mut(); + let path_w = to_wide(path.to_string_lossy().as_ref()); + let result = unsafe { + GetNamedSecurityInfoW( + path_w.as_ptr() as *mut u16, + SE_FILE_OBJECT, + windows_sys::Win32::Security::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut dacl as *mut _ as *mut _, + std::ptr::null_mut(), + &mut security_descriptor as *mut _ as *mut _, + ) + }; + if result != 0 { + return Err("GetNamedSecurityInfoW failed".to_string()); + } + + Ok(WindowsAclRollback { + path: path_w, + dacl, + security_descriptor, + }) +} + +fn apply_job_object(process: HANDLE) -> Result<(), String> { + unsafe { + let job = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if job.is_null() { + return Err("CreateJobObjectW failed".to_string()); + } + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let ok = SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + &mut info as *mut _ as *mut _, + std::mem::size_of::() as u32, + ); + if ok == 0 { + CloseHandle(job); + return Err("SetInformationJobObject failed".to_string()); + } + + let ok = AssignProcessToJobObject(job, process); + if ok == 0 { + CloseHandle(job); + return Err("AssignProcessToJobObject failed".to_string()); + } + + Ok(()) + } +} diff --git a/crates/shadi_sandbox/src/policy.rs b/crates/shadi_sandbox/src/policy.rs new file mode 100644 index 0000000..4bcbb5c --- /dev/null +++ b/crates/shadi_sandbox/src/policy.rs @@ -0,0 +1,76 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub struct SandboxPolicy { + allow_read: Vec, + allow_write: Vec, + net_block: bool, +} + +impl SandboxPolicy { + pub fn new() -> Self { + Self { + allow_read: Vec::new(), + allow_write: Vec::new(), + net_block: false, + } + } + + pub fn allow_read_path(mut self, path: impl AsRef) -> Self { + self.allow_read.push(path.as_ref().to_path_buf()); + self + } + + pub fn allow_write_path(mut self, path: impl AsRef) -> Self { + self.allow_write.push(path.as_ref().to_path_buf()); + self + } + + pub fn block_network(mut self, value: bool) -> Self { + self.net_block = value; + self + } + + pub fn allow_read(&self) -> &[PathBuf] { + &self.allow_read + } + + pub fn allow_write(&self) -> &[PathBuf] { + &self.allow_write + } + + pub fn net_blocked(&self) -> bool { + self.net_block + } +} + +impl Default for SandboxPolicy { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_root() -> String { + std::env::var("SHADI_TMP_DIR").unwrap_or_else(|_| "./.tmp".to_string()) + } + + #[test] + fn policy_collects_paths_and_network_flag() { + let tmp_dir = tmp_root(); + let policy = SandboxPolicy::new() + .allow_read_path(&tmp_dir) + .allow_write_path(&tmp_dir) + .block_network(true); + + assert!(policy.allow_read().iter().any(|p| p == Path::new(&tmp_dir))); + assert!(policy.allow_write().iter().any(|p| p == Path::new(&tmp_dir))); + assert!(policy.net_blocked()); + } +} diff --git a/crates/shadi_sandbox/tests/windows_integration.rs b/crates/shadi_sandbox/tests/windows_integration.rs new file mode 100644 index 0000000..9cd3077 --- /dev/null +++ b/crates/shadi_sandbox/tests/windows_integration.rs @@ -0,0 +1,25 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(target_os = "windows")] +mod windows_integration { + use std::process::Command; + + use shadi_sandbox::{spawn_sandboxed, SandboxPolicy}; + + #[test] + fn appcontainer_smoke_test() { + if std::env::var("SHADI_WINDOWS_INTEGRATION").is_err() { + return; + } + + let policy = SandboxPolicy::new().block_network(false); + let mut command = Command::new("cmd"); + command.args(["/C", "echo", "shadi"]); + + let mut child = spawn_sandboxed(&mut command, &policy) + .expect("sandboxed command should start"); + let status = child.wait().expect("wait should succeed"); + assert!(status.success()); + } +} diff --git a/crates/shadictl/Cargo.toml b/crates/shadictl/Cargo.toml new file mode 100644 index 0000000..34b491a --- /dev/null +++ b/crates/shadictl/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "shadictl" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +shadi_sandbox = { path = "../shadi_sandbox" } +agent_secrets = { path = "../agent_secrets" } +shadi_memory = { path = "../shadi_memory" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +bs58 = "0.5" +base64 = "0.22" +ed25519-dalek = { version = "2.1", features = ["rand_core"] } +hkdf = "0.12" +sha2 = "0.10" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } + +[target.'cfg(windows)'.dependencies] +sequoia-openpgp = { version = "2.2", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto", "compression-deflate", "compression-bzip2"] } + +[target.'cfg(not(windows))'.dependencies] +sequoia-openpgp = { version = "2.2" } + +[dev-dependencies] +tempfile = "3.10" diff --git a/crates/shadictl/src/main.rs b/crates/shadictl/src/main.rs new file mode 100644 index 0000000..d0790ad --- /dev/null +++ b/crates/shadictl/src/main.rs @@ -0,0 +1,2625 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +#[cfg(test)] +use std::collections::HashMap; +#[cfg(test)] +use std::sync::{Mutex, OnceLock}; + +use base64::Engine; +use clap::{ArgAction, Parser, Subcommand}; +use ed25519_dalek::SigningKey; +use hkdf::Hkdf; +#[cfg(not(test))] +use reqwest::blocking::Client; +#[cfg(not(test))] +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::Sha256; +use shadi_sandbox::{spawn_sandboxed, SandboxPolicy}; +use agent_secrets::{SecretPolicy, SecretStore}; +use shadi_memory::{MemoryEntry, SqlCipherStore}; +use sequoia_openpgp as openpgp; + +#[derive(Parser, Debug)] +#[command(name = "shadi")] +#[command(about = "Secure Host Agentic AI Dynamic Instantiation")] +struct Cli { + #[arg(long = "profile", value_enum, value_name = "PROFILE")] + profile: Option, + + #[arg(long = "policy", value_name = "FILE")] + policy_file: Option, + + #[arg(long = "allow", value_name = "PATH", action = ArgAction::Append)] + allow: Vec, + + #[arg(long = "read", value_name = "PATH", action = ArgAction::Append)] + read: Vec, + + #[arg(long = "write", value_name = "PATH", action = ArgAction::Append)] + write: Vec, + + #[arg(long = "net-block", action = ArgAction::SetTrue)] + net_block: bool, + + #[arg(long = "allow-command", value_name = "CMD", action = ArgAction::Append)] + allow_command: Vec, + + #[arg(long = "inject-keychain", value_name = "KEY=ENV", action = ArgAction::Append)] + inject_keychain: Vec, + + #[arg(long = "list-keychain", action = ArgAction::SetTrue)] + list_keychain: bool, + + #[arg(long = "list-prefix", value_name = "PREFIX")] + list_prefix: Option, + + #[arg(long = "print-policy", action = ArgAction::SetTrue)] + print_policy: bool, + + #[arg(last = true)] + command: Vec, +} + +#[derive(clap::ValueEnum, Clone, Copy, Debug)] +enum LauncherProfile { + Strict, + Balanced, + Connected, +} + +#[derive(Parser, Debug)] +#[command(name = "memory", about = "Query SQLCipher memory using SHADI secrets")] +struct MemoryCli { + #[arg(long, env = "SHADI_MEMORY_DB", value_name = "PATH")] + db: PathBuf, + + #[arg(long, env = "SHADI_MEMORY_KEY")] + key: Option, + + #[arg(long = "key-name", env = "SHADI_MEMORY_KEY_NAME", default_value = "shadi/memory/sqlcipher_key")] + key_name: String, + + #[command(subcommand)] + command: MemoryCommand, +} + +#[derive(Subcommand, Debug)] +enum MemoryCommand { + Init, + Put { + #[arg(long)] + scope: String, + #[arg(long = "entry-key")] + entry_key: String, + #[arg(long)] + payload: Option, + #[arg(long = "payload-file")] + payload_file: Option, + }, + Get { + #[arg(long)] + scope: String, + #[arg(long = "entry-key")] + entry_key: String, + }, + Search { + #[arg(long)] + scope: Option, + #[arg(long)] + query: String, + #[arg(long, default_value = "10")] + limit: usize, + }, + List { + #[arg(long)] + scope: Option, + #[arg(long, default_value = "50")] + limit: usize, + }, + Delete { + #[arg(long)] + scope: String, + #[arg(long = "entry-key")] + entry_key: String, + }, +} + +#[derive(Parser, Debug)] +#[command(name = "did-from-gpg", about = "Create did:key DID document from a GPG Ed25519 public key")] +struct DidFromGpgArgs { + #[arg( + short = 'k', + long = "key", + value_name = "SECRET", + required_unless_present = "input", + conflicts_with = "input" + )] + key_ref: Option, + + #[arg( + short = 'i', + long = "in", + value_name = "FILE", + required_unless_present = "key_ref", + conflicts_with = "key_ref" + )] + input: Option, + + #[arg(short = 'o', long = "out", value_name = "FILE", default_value = "did-document.json")] + out_file: PathBuf, +} + +#[derive(Parser, Debug)] +#[command(name = "did-from-github", about = "Create did:key DID document from a GitHub GPG public key")] +struct DidFromGitHubArgs { + #[arg(long = "user", value_name = "USERNAME")] + user: String, + + #[arg(long = "out", value_name = "FILE")] + out_file: Option, +} + +#[derive(Parser, Debug)] +#[command(name = "get-secret", about = "Read a secret from the SHADI secret store")] +struct GetSecretArgs { + #[arg(long = "key", value_name = "KEY")] + key: String, +} + +#[derive(Parser, Debug)] +#[command(name = "derive-agent-did", about = "Derive an agent DID from a human GPG key")] +struct DeriveAgentDidArgs { + #[arg( + short = 's', + long = "secret", + value_name = "SECRET", + required_unless_present = "input", + conflicts_with = "input" + )] + secret: Option, + + #[arg( + short = 'i', + long = "in", + value_name = "FILE", + required_unless_present = "secret", + conflicts_with = "secret" + )] + input: Option, + + #[arg(short = 'n', long = "name", value_name = "NAME")] + agent_name: String, + + #[arg(long = "prefix", value_name = "PATH", default_value = "agent_keys")] + prefix: String, + + #[arg(short = 'o', long = "out", value_name = "FILE")] + out_file: Option, +} + +#[derive(clap::ValueEnum, Clone, Copy, Debug)] +enum HumanIdentitySource { + Gpg, + Seed, +} + +#[derive(Parser, Debug)] +#[command(name = "derive-agent-identity", about = "Derive one or more local agent identities from a human identity source")] +struct DeriveAgentIdentityArgs { + #[arg(long = "source", value_enum, default_value = "gpg")] + source: HumanIdentitySource, + + #[arg( + short = 's', + long = "human-secret", + value_name = "SECRET", + required_unless_present = "input", + conflicts_with = "input" + )] + human_secret: Option, + + #[arg( + short = 'i', + long = "in", + value_name = "FILE", + required_unless_present = "human_secret", + conflicts_with = "human_secret" + )] + input: Option, + + #[arg(short = 'n', long = "name", value_name = "NAME", action = ArgAction::Append, required = true)] + agent_names: Vec, + + #[arg(long = "prefix", value_name = "PATH", default_value = "agent_keys")] + prefix: String, + + #[arg(long = "human-did-key", value_name = "SECRET")] + human_did_key: Option, + + #[arg(long = "out-dir", value_name = "DIR")] + out_dir: Option, +} + +#[derive(Parser, Debug)] +#[command(name = "verify-agent-identity", about = "Verify an agent identity is derived from a human identity source")] +struct VerifyAgentIdentityArgs { + #[arg(long = "source", value_enum, default_value = "gpg")] + source: HumanIdentitySource, + + #[arg( + short = 's', + long = "human-secret", + value_name = "SECRET", + required_unless_present = "input", + conflicts_with = "input" + )] + human_secret: Option, + + #[arg( + short = 'i', + long = "in", + value_name = "FILE", + required_unless_present = "human_secret", + conflicts_with = "human_secret" + )] + input: Option, + + #[arg(short = 'n', long = "name", value_name = "NAME")] + agent_name: String, + + #[arg(long = "prefix", value_name = "PATH", default_value = "agent_keys")] + prefix: String, + + #[arg(long = "public-key-key", value_name = "SECRET")] + public_key_key: Option, + + #[arg(long = "did-key", value_name = "SECRET")] + did_key: Option, + + #[arg(long = "human-did-key", value_name = "SECRET")] + human_did_key: Option, + + #[arg(long = "require-human-binding", action = ArgAction::SetTrue)] + require_human_binding: bool, +} + +#[derive(Parser, Debug)] +#[command(name = "put-key", about = "Store an OpenPGP key in the SHADI secret store")] +struct PutKeyArgs { + #[arg(short = 'k', long = "key", value_name = "SECRET")] + key: String, + + #[arg(short = 'i', long = "in", value_name = "FILE")] + input: PathBuf, +} + +#[derive(Debug, Default, Deserialize)] +struct PolicyFile { + #[serde(default)] + allow: Vec, + #[serde(default)] + read: Vec, + #[serde(default)] + write: Vec, + #[serde(default)] + net_block: Option, + #[serde(default)] + allow_command: Vec, + #[serde(default)] + block_command: Vec, +} + +#[derive(Debug)] +struct ResolvedPolicy { + policy: SandboxPolicy, + blocked: HashSet, + allow: HashSet, +} + +#[cfg(test)] +static TEST_SECRET_STORE: OnceLock>>> = OnceLock::new(); + +#[cfg(test)] +fn test_secret_store_map() -> &'static Mutex>> { + TEST_SECRET_STORE.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +struct TestSecretStore; + +#[cfg(test)] +impl SecretStore for TestSecretStore { + fn put(&self, key: &str, secret: &[u8], _policy: SecretPolicy) -> agent_secrets::SecretResult<()> { + let mut guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + guard.insert(key.to_string(), secret.to_vec()); + Ok(()) + } + + fn get(&self, key: &str) -> agent_secrets::SecretResult { + let guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + let value = guard + .get(key) + .ok_or(agent_secrets::SecretError::InvalidInput)? + .clone(); + Ok(agent_secrets::memory::SecretBytes::new(value)) + } + + fn delete(&self, key: &str) -> agent_secrets::SecretResult<()> { + let mut guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + guard.remove(key); + Ok(()) + } + + fn list_keys(&self) -> agent_secrets::SecretResult> { + let guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + Ok(guard.keys().cloned().collect()) + } +} + +#[cfg(test)] +fn default_secret_store() -> Box { + Box::new(TestSecretStore) +} + +#[cfg(not(test))] +fn default_secret_store() -> Box { + agent_secrets::default_store() +} + +#[cfg(test)] +fn test_store_put(key: &str, value: &[u8]) { + let mut guard = test_secret_store_map().lock().expect("test store lock"); + guard.insert(key.to_string(), value.to_vec()); +} + +#[cfg(test)] +fn test_store_get(key: &str) -> Option> { + let guard = test_secret_store_map().lock().expect("test store lock"); + guard.get(key).cloned() +} + + +fn main() -> ExitCode { + let cli = Cli::parse(); + run_cli(cli) +} + +fn run_cli(cli: Cli) -> ExitCode { + if matches!(cli.command.first().map(|cmd| cmd.as_str()), Some("memory")) { + return run_memory_command(&cli.command[1..]); + } + if matches!(cli.command.first().map(|cmd| cmd.as_str()), Some("did-from-gpg")) { + return run_did_from_gpg_command(&cli.command); + } + if matches!(cli.command.first().map(|cmd| cmd.as_str()), Some("did-from-github")) { + return run_did_from_github_command(&cli.command); + } + if matches!(cli.command.first().map(|cmd| cmd.as_str()), Some("get-secret")) { + return run_get_secret_command(&cli.command); + } + if matches!(cli.command.first().map(|cmd| cmd.as_str()), Some("derive-agent-did")) { + return run_derive_agent_did_command(&cli.command); + } + if matches!(cli.command.first().map(|cmd| cmd.as_str()), Some("derive-agent-identity")) { + return run_derive_agent_identity_command(&cli.command); + } + if matches!(cli.command.first().map(|cmd| cmd.as_str()), Some("verify-agent-identity")) { + return run_verify_agent_identity_command(&cli.command); + } + if matches!(cli.command.first().map(|cmd| cmd.as_str()), Some("put-key")) { + return run_put_key_command(&cli.command); + } + + if cli.list_keychain { + return match list_keychain(cli.list_prefix.as_deref()) { + Ok(()) => ExitCode::from(0), + Err(err) => { + eprintln!("failed to list secrets: {}", err); + ExitCode::from(2) + } + }; + } + + if cli.print_policy && cli.command.is_empty() { + let file_policy = match cli.policy_file.as_ref() { + Some(path) => match load_policy_file(path) { + Ok(policy) => policy, + Err(err) => { + eprintln!("failed to read policy {}: {}", path.display(), err); + return ExitCode::from(2); + } + }, + None => PolicyFile::default(), + }; + + let resolved = match resolve_policy(&cli, &file_policy) { + Ok(resolved) => resolved, + Err(err) => { + eprintln!("{}", err); + return ExitCode::from(2); + } + }; + + return match format_policy(&resolved.policy, &resolved.blocked, &resolved.allow) { + Ok(output) => { + println!("{}", output); + ExitCode::from(0) + } + Err(err) => { + eprintln!("failed to print policy: {}", err); + ExitCode::from(2) + } + }; + } + + if cli.command.is_empty() { + eprintln!("missing command to run"); + return ExitCode::from(2); + } + let file_policy = match cli.policy_file.as_ref() { + Some(path) => match load_policy_file(path) { + Ok(policy) => policy, + Err(err) => { + eprintln!("failed to read policy {}: {}", path.display(), err); + return ExitCode::from(2); + } + }, + None => PolicyFile::default(), + }; + + let resolved = match resolve_policy(&cli, &file_policy) { + Ok(resolved) => resolved, + Err(err) => { + eprintln!("{}", err); + return ExitCode::from(2); + } + }; + + let cmd_name = cli.command.first().map(|cmd| cmd.as_str()).unwrap_or(""); + if is_command_blocked(cmd_name, &resolved.blocked, &resolved.allow) { + eprintln!("blocked command: {}", cmd_name); + return ExitCode::from(2); + } + + if cli.print_policy { + return match format_policy(&resolved.policy, &resolved.blocked, &resolved.allow) { + Ok(output) => { + println!("{}", output); + ExitCode::from(0) + } + Err(err) => { + eprintln!("failed to print policy: {}", err); + ExitCode::from(2) + } + }; + } + + let mut command = Command::new(cmd_name); + if cli.command.len() > 1 { + command.args(&cli.command[1..]); + } + + if let Err(err) = inject_keychain_secrets(&mut command, &cli.inject_keychain) { + eprintln!("failed to inject keychain secrets: {}", err); + return ExitCode::from(2); + } + + match spawn_sandboxed(&mut command, &resolved.policy) { + Ok(mut child) => match child.wait() { + Ok(status) => ExitCode::from(status.code().unwrap_or(1) as u8), + Err(err) => { + eprintln!("failed to wait for child: {}", err); + ExitCode::from(1) + } + }, + Err(err) => { + eprintln!("failed to start sandboxed command: {}", err); + ExitCode::from(1) + } + } +} + +fn run_memory_command(args: &[String]) -> ExitCode { + let mut argv = Vec::with_capacity(args.len() + 1); + argv.push("shadictl-memory".to_string()); + argv.extend_from_slice(args); + let cli = match MemoryCli::try_parse_from(argv) { + Ok(cli) => cli, + Err(err) => { + eprintln!("{}", err); + return ExitCode::from(2); + } + }; + + let key = match resolve_memory_key(&cli) { + Ok(key) => key, + Err(err) => { + eprintln!("{}", err); + return ExitCode::from(1); + } + }; + + let store = match SqlCipherStore::open(&cli.db, &key) { + Ok(store) => store, + Err(err) => { + eprintln!("{}", err); + return ExitCode::from(1); + } + }; + + match handle_memory_command(&cli, &store) { + Ok(output) => { + println!("{}", output); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("{}", err); + ExitCode::from(1) + } + } +} + +fn handle_memory_command(cli: &MemoryCli, store: &SqlCipherStore) -> Result { + match &cli.command { + MemoryCommand::Init => Ok("ok".to_string()), + MemoryCommand::Put { + scope, + entry_key, + payload, + payload_file, + } => { + let payload = read_memory_payload(payload.clone(), payload_file.clone())?; + let id = store + .put(scope, entry_key, &payload) + .map_err(|err| err.to_string())?; + Ok(serde_json::json!({"status": "saved", "id": id}).to_string()) + } + MemoryCommand::Get { scope, entry_key } => { + let entry = store + .get_latest(scope, entry_key) + .map_err(|err| err.to_string())?; + match entry { + Some(entry) => serde_json::to_string_pretty(&entry).map_err(|err| err.to_string()), + None => Ok(serde_json::json!({"found": false}).to_string()), + } + } + MemoryCommand::Search { + scope, + query, + limit, + } => { + let entries = store + .search(scope.as_deref(), query, *limit) + .map_err(|err| err.to_string())?; + format_memory_entries(entries) + } + MemoryCommand::List { scope, limit } => { + let entries = store + .list(scope.as_deref(), *limit) + .map_err(|err| err.to_string())?; + format_memory_entries(entries) + } + MemoryCommand::Delete { scope, entry_key } => { + let affected = store + .delete(scope, entry_key) + .map_err(|err| err.to_string())?; + Ok(serde_json::json!({"deleted": affected}).to_string()) + } + } +} + +fn resolve_memory_key(cli: &MemoryCli) -> Result { + if let Some(key) = cli.key.as_ref() { + if key.is_empty() { + return Err("SHADI_MEMORY_KEY is empty".to_string()); + } + return Ok(key.to_string()); + } + + let store = default_secret_store(); + let secret = store + .get(&cli.key_name) + .map_err(|_| format!("missing SHADI key: {}", cli.key_name))?; + let raw = secret.expose(|bytes| bytes.to_vec()); + String::from_utf8(raw).map_err(|_| "SHADI memory key is not utf-8".to_string()) +} + +fn read_memory_payload( + payload: Option, + payload_file: Option, +) -> Result { + match (payload, payload_file) { + (Some(text), None) => Ok(text), + (None, Some(path)) => std::fs::read_to_string(&path) + .map_err(|err| format!("failed to read payload file: {}", err)), + (None, None) => Err("payload or payload-file must be provided".to_string()), + (Some(_), Some(_)) => Err("use either payload or payload-file".to_string()), + } +} + +fn format_memory_entries(entries: Vec) -> Result { + serde_json::to_string_pretty(&entries).map_err(|err| err.to_string()) +} + +fn run_derive_agent_did_command(args: &[String]) -> ExitCode { + let parsed = match DeriveAgentDidArgs::try_parse_from(args) { + Ok(parsed) => parsed, + Err(err) => { + let _ = err.print(); + return ExitCode::from(2); + } + }; + + match run_derive_agent_did(parsed) { + Ok(()) => ExitCode::from(0), + Err(err) => { + eprintln!("{}", err); + ExitCode::from(2) + } + } +} + +fn run_derive_agent_identity_command(args: &[String]) -> ExitCode { + let parsed = match DeriveAgentIdentityArgs::try_parse_from(args) { + Ok(parsed) => parsed, + Err(err) => { + let _ = err.print(); + return ExitCode::from(2); + } + }; + + match run_derive_agent_identity(parsed) { + Ok(()) => ExitCode::from(0), + Err(err) => { + eprintln!("{}", err); + ExitCode::from(2) + } + } +} + +fn run_verify_agent_identity_command(args: &[String]) -> ExitCode { + let parsed = match VerifyAgentIdentityArgs::try_parse_from(args) { + Ok(parsed) => parsed, + Err(err) => { + let _ = err.print(); + return ExitCode::from(2); + } + }; + + match run_verify_agent_identity(parsed) { + Ok(()) => ExitCode::from(0), + Err(err) => { + eprintln!("{}", err); + ExitCode::from(2) + } + } +} + +fn run_get_secret_command(args: &[String]) -> ExitCode { + let parsed = match GetSecretArgs::try_parse_from(args) { + Ok(parsed) => parsed, + Err(err) => { + let _ = err.print(); + return ExitCode::from(2); + } + }; + + match run_get_secret(parsed) { + Ok(()) => ExitCode::from(0), + Err(err) => { + eprintln!("{}", err); + ExitCode::from(2) + } + } +} + +fn run_did_from_github_command(args: &[String]) -> ExitCode { + let parsed = match DidFromGitHubArgs::try_parse_from(args) { + Ok(parsed) => parsed, + Err(err) => { + let _ = err.print(); + return ExitCode::from(2); + } + }; + + match run_did_from_github(parsed) { + Ok(()) => ExitCode::from(0), + Err(err) => { + eprintln!("{}", err); + ExitCode::from(2) + } + } +} + +fn run_did_from_gpg_command(args: &[String]) -> ExitCode { + let parsed = match DidFromGpgArgs::try_parse_from(args) { + Ok(parsed) => parsed, + Err(err) => { + let _ = err.print(); + return ExitCode::from(2); + } + }; + + match run_did_from_gpg(parsed) { + Ok(()) => ExitCode::from(0), + Err(err) => { + eprintln!("{}", err); + ExitCode::from(2) + } + } +} + +fn run_did_from_gpg(args: DidFromGpgArgs) -> Result<(), String> { + let public_key = read_openpgp_input("--key", args.key_ref.as_deref(), args.input.as_ref())?; + + let pkey = extract_ed25519_public_key(&public_key)?; + + let (did, vm_id, doc) = build_did_document(&pkey)?; + let output = serde_json::to_string_pretty(&doc).map_err(|err| err.to_string())?; + std::fs::write(&args.out_file, format!("{}\n", output)).map_err(|err| { + format!("failed to write {}: {}", args.out_file.display(), err) + })?; + + println!("DID: {}", did); + println!("Verification Method ID: {}", vm_id); + println!("Wrote DID Document: {}", args.out_file.display()); + Ok(()) +} + +fn run_did_from_github(args: DidFromGitHubArgs) -> Result<(), String> { + let public_key = fetch_github_gpg_key(&args.user)?; + let pkey = extract_ed25519_public_key(&public_key)?; + + let (did, vm_id, doc) = build_did_document(&pkey)?; + let output = serde_json::to_string_pretty(&doc).map_err(|err| err.to_string())?; + + let did_key = format!("github/{}/did", args.user); + let did_doc_key = format!("github/{}/diddoc", args.user); + + let store = default_secret_store(); + store + .put(&did_key, did.as_bytes(), SecretPolicy::default()) + .map_err(|err| format!("failed to store secret {}: {}", did_key, err))?; + store + .put(&did_doc_key, output.as_bytes(), SecretPolicy::default()) + .map_err(|err| format!("failed to store secret {}: {}", did_doc_key, err))?; + + if let Some(out_file) = args.out_file.as_ref() { + std::fs::write(out_file, format!("{}\n", output)).map_err(|err| { + format!("failed to write {}: {}", out_file.display(), err) + })?; + } + + println!("DID: {}", did); + println!("Verification Method ID: {}", vm_id); + println!("Stored DID in secret key: {}", did_key); + println!("Stored DID Document in secret key: {}", did_doc_key); + if let Some(out_file) = args.out_file.as_ref() { + println!("Wrote DID Document: {}", out_file.display()); + } + Ok(()) +} + +fn run_get_secret(args: GetSecretArgs) -> Result<(), String> { + let store = default_secret_store(); + let secret = store + .get(&args.key) + .map_err(|_| format!("keychain lookup failed for {}", args.key))?; + let value = secret.expose(|bytes| bytes.to_vec()); + let value = secret_bytes_to_utf8(&value)?; + println!("{}", value); + Ok(()) +} + +fn run_derive_agent_did(args: DeriveAgentDidArgs) -> Result<(), String> { + let secret_key = read_openpgp_input("--secret", args.secret.as_deref(), args.input.as_ref())?; + let (private_key, public_key) = derive_agent_keypair(&secret_key, &args.agent_name)?; + let (did, vm_id, doc) = build_did_document(&public_key)?; + let output = serde_json::to_string_pretty(&doc).map_err(|err| err.to_string())?; + + store_derived_agent_identity( + args.prefix.trim_end_matches('/'), + &args.agent_name, + &private_key, + &public_key, + &did, + &output, + None, + )?; + + if let Some(out_file) = args.out_file.as_ref() { + std::fs::write(out_file, format!("{}\n", output)).map_err(|err| { + format!("failed to write {}: {}", out_file.display(), err) + })?; + } + + println!("DID: {}", did); + println!("Verification Method ID: {}", vm_id); + println!("Stored private key: {}/{}/private", args.prefix.trim_end_matches('/'), args.agent_name); + println!("Stored public key: {}/{}/public", args.prefix.trim_end_matches('/'), args.agent_name); + println!("Stored DID: {}/{}/did", args.prefix.trim_end_matches('/'), args.agent_name); + println!("Stored DID Document: {}/{}/diddoc", args.prefix.trim_end_matches('/'), args.agent_name); + if let Some(out_file) = args.out_file.as_ref() { + println!("Wrote DID Document: {}", out_file.display()); + } + Ok(()) +} + +fn run_derive_agent_identity(args: DeriveAgentIdentityArgs) -> Result<(), String> { + let seed_material = match args.source { + HumanIdentitySource::Gpg => read_openpgp_input("--human-secret", args.human_secret.as_deref(), args.input.as_ref())?, + HumanIdentitySource::Seed => read_seed_input("--human-secret", args.human_secret.as_deref(), args.input.as_ref())?, + }; + + let human_did = match args.human_did_key.as_deref() { + Some(key) => { + let store = default_secret_store(); + let secret = store + .get(key) + .map_err(|_| format!("keychain lookup failed for {}", key))?; + Some(secret_bytes_to_utf8(&secret.expose(|bytes| bytes.to_vec()))?) + } + None => None, + }; + + let prefix = args.prefix.trim_end_matches('/'); + if let Some(out_dir) = args.out_dir.as_ref() { + std::fs::create_dir_all(out_dir) + .map_err(|err| format!("failed to create {}: {}", out_dir.display(), err))?; + } + + for agent_name in &args.agent_names { + let (private_key, public_key) = derive_agent_keypair(&seed_material, agent_name)?; + let (did, vm_id, doc) = build_did_document(&public_key)?; + let output = serde_json::to_string_pretty(&doc).map_err(|err| err.to_string())?; + + store_derived_agent_identity( + prefix, + agent_name, + &private_key, + &public_key, + &did, + &output, + human_did.as_deref(), + )?; + + if let Some(out_dir) = args.out_dir.as_ref() { + let out_file = out_dir.join(format!("{}.did.json", agent_name)); + std::fs::write(&out_file, format!("{}\n", output)).map_err(|err| { + format!("failed to write {}: {}", out_file.display(), err) + })?; + println!("Wrote DID Document: {}", out_file.display()); + } + + println!("Agent: {}", agent_name); + println!("DID: {}", did); + println!("Verification Method ID: {}", vm_id); + println!("Stored private key: {}/{}/private", prefix, agent_name); + println!("Stored public key: {}/{}/public", prefix, agent_name); + println!("Stored DID: {}/{}/did", prefix, agent_name); + println!("Stored DID Document: {}/{}/diddoc", prefix, agent_name); + if args.human_did_key.is_some() { + println!("Stored human binding: {}/{}/human_did", prefix, agent_name); + } + } + + Ok(()) +} + +fn run_verify_agent_identity(args: VerifyAgentIdentityArgs) -> Result<(), String> { + let seed_material = match args.source { + HumanIdentitySource::Gpg => read_openpgp_input("--human-secret", args.human_secret.as_deref(), args.input.as_ref())?, + HumanIdentitySource::Seed => read_seed_input("--human-secret", args.human_secret.as_deref(), args.input.as_ref())?, + }; + + let (_private_key, expected_public_key) = derive_agent_keypair(&seed_material, &args.agent_name)?; + let (expected_did, _vm_id, _doc) = build_did_document(&expected_public_key)?; + + let prefix = args.prefix.trim_end_matches('/'); + let public_key_name = args + .public_key_key + .clone() + .unwrap_or_else(|| format!("{}/{}/public", prefix, args.agent_name)); + let did_key_name = args + .did_key + .clone() + .unwrap_or_else(|| format!("{}/{}/did", prefix, args.agent_name)); + + let store = default_secret_store(); + let stored_public_b64 = store + .get(&public_key_name) + .map_err(|_| format!("keychain lookup failed for {}", public_key_name))? + .expose(|bytes| bytes.to_vec()); + let stored_public_b64 = secret_bytes_to_utf8(&stored_public_b64)?; + let stored_public_key = base64::engine::general_purpose::STANDARD + .decode(stored_public_b64.as_bytes()) + .map_err(|err| format!("failed to decode {}: {}", public_key_name, err))?; + + if stored_public_key != expected_public_key { + return Err("agent public key mismatch: derived key does not match stored key".to_string()); + } + + let stored_did = store + .get(&did_key_name) + .map_err(|_| format!("keychain lookup failed for {}", did_key_name))? + .expose(|bytes| bytes.to_vec()); + let stored_did = secret_bytes_to_utf8(&stored_did)?; + + if stored_did != expected_did { + return Err("agent DID mismatch: derived DID does not match stored DID".to_string()); + } + + if args.require_human_binding || args.human_did_key.is_some() { + let binding_key = format!("{}/{}/human_did", prefix, args.agent_name); + let bound_human_did = store + .get(&binding_key) + .map_err(|_| format!("missing human binding at {}", binding_key))? + .expose(|bytes| bytes.to_vec()); + let bound_human_did = secret_bytes_to_utf8(&bound_human_did)?; + + if let Some(human_did_key) = args.human_did_key.as_deref() { + let expected_human_did = store + .get(human_did_key) + .map_err(|_| format!("keychain lookup failed for {}", human_did_key))? + .expose(|bytes| bytes.to_vec()); + let expected_human_did = secret_bytes_to_utf8(&expected_human_did)?; + + if bound_human_did != expected_human_did { + return Err("human binding mismatch: agent bound DID does not match expected human DID".to_string()); + } + } + } + + println!("verified: true"); + println!("agent: {}", args.agent_name); + println!("stored_public_key: {}", public_key_name); + println!("stored_did: {}", did_key_name); + println!("derived_did: {}", expected_did); + + Ok(()) +} + +fn store_derived_agent_identity( + prefix: &str, + agent_name: &str, + private_key: &[u8], + public_key: &[u8], + did: &str, + diddoc_json: &str, + human_did: Option<&str>, +) -> Result<(), String> { + let private_key_name = format!("{}/{}/private", prefix, agent_name); + let public_key_name = format!("{}/{}/public", prefix, agent_name); + let did_key_name = format!("{}/{}/did", prefix, agent_name); + let diddoc_key_name = format!("{}/{}/diddoc", prefix, agent_name); + + let store = default_secret_store(); + let private_b64 = base64::engine::general_purpose::STANDARD.encode(private_key); + let public_b64 = base64::engine::general_purpose::STANDARD.encode(public_key); + + store + .put(&private_key_name, private_b64.as_bytes(), SecretPolicy::default()) + .map_err(|err| format!("failed to store secret {}: {}", private_key_name, err))?; + store + .put(&public_key_name, public_b64.as_bytes(), SecretPolicy::default()) + .map_err(|err| format!("failed to store secret {}: {}", public_key_name, err))?; + store + .put(&did_key_name, did.as_bytes(), SecretPolicy::default()) + .map_err(|err| format!("failed to store secret {}: {}", did_key_name, err))?; + store + .put(&diddoc_key_name, diddoc_json.as_bytes(), SecretPolicy::default()) + .map_err(|err| format!("failed to store secret {}: {}", diddoc_key_name, err))?; + + if let Some(human_did) = human_did { + let binding_key = format!("{}/{}/human_did", prefix, agent_name); + store + .put(&binding_key, human_did.as_bytes(), SecretPolicy::default()) + .map_err(|err| format!("failed to store secret {}: {}", binding_key, err))?; + } + + Ok(()) +} + +fn read_seed_input( + label: &str, + secret_key: Option<&str>, + input: Option<&PathBuf>, +) -> Result, String> { + if let Some(secret_key) = secret_key { + let store = default_secret_store(); + let secret = store + .get(secret_key) + .map_err(|_| format!("keychain lookup failed for {}", secret_key))?; + return Ok(secret.expose(|bytes| bytes.to_vec())); + } + + if let Some(input) = input { + return std::fs::read(input) + .map_err(|err| format!("failed to read {}: {}", input.display(), err)); + } + + Err(format!("missing {} or --in", label)) +} + +fn build_did_document(pkey: &[u8]) -> Result<(String, String, serde_json::Value), String> { + let pubkey = if pkey.len() == 33 && pkey[0] == 0x40 { + pkey[1..].to_vec() + } else if pkey.len() == 32 { + pkey.to_vec() + } else { + return Err(format!( + "unexpected Ed25519 key material length: {}", + pkey.len() + )); + }; + + let mut multicodec = Vec::with_capacity(2 + pubkey.len()); + multicodec.push(0xED); + multicodec.push(0x01); + multicodec.extend_from_slice(&pubkey); + let fingerprint = format!("z{}", bs58::encode(multicodec).into_string()); + + let did = format!("did:key:{}", fingerprint); + let vm_id = format!("{}#{}", did, fingerprint); + + let doc = json!({ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/ed25519-2020/v1" + ], + "id": did, + "verificationMethod": [ + { + "id": vm_id, + "type": "Ed25519VerificationKey2020", + "controller": did, + "publicKeyMultibase": fingerprint + } + ], + "authentication": [vm_id], + "assertionMethod": [vm_id], + "capabilityDelegation": [vm_id], + "capabilityInvocation": [vm_id] + }); + + Ok((did, vm_id, doc)) +} + +fn run_put_key_command(args: &[String]) -> ExitCode { + let parsed = match PutKeyArgs::try_parse_from(args) { + Ok(parsed) => parsed, + Err(err) => { + let _ = err.print(); + return ExitCode::from(2); + } + }; + + match run_put_key(parsed) { + Ok(()) => ExitCode::from(0), + Err(err) => { + eprintln!("{}", err); + ExitCode::from(2) + } + } +} + +fn run_put_key(args: PutKeyArgs) -> Result<(), String> { + let payload = std::fs::read(&args.input) + .map_err(|err| format!("failed to read {}: {}", args.input.display(), err))?; + let store = default_secret_store(); + store + .put(&args.key, &payload, SecretPolicy::default()) + .map_err(|err| format!("failed to store secret {}: {}", args.key, err))?; + println!("Stored OpenPGP key in secret: {}", args.key); + Ok(()) +} + +fn read_openpgp_input( + label: &str, + secret_key: Option<&str>, + input: Option<&PathBuf>, +) -> Result, String> { + if let Some(secret_key) = secret_key { + let store = default_secret_store(); + let secret = store + .get(secret_key) + .map_err(|_| format!("keychain lookup failed for {}", secret_key))?; + return Ok(secret.expose(|bytes| bytes.to_vec())); + } + + if let Some(input) = input { + return std::fs::read(input) + .map_err(|err| format!("failed to read {}: {}", input.display(), err)); + } + + Err(format!("missing {} or --in", label)) +} + +fn fetch_github_gpg_key(user: &str) -> Result, String> { + let payload = github_api_get_gpg_keys(user)?; + extract_github_public_key(&payload).and_then(decode_github_public_key) +} + +fn extract_github_public_key(payload: &str) -> Result { + let value: Value = serde_json::from_str(payload).map_err(|err| err.to_string())?; + let keys = value + .as_array() + .ok_or_else(|| "unexpected GitHub response format".to_string())?; + let first = keys + .first() + .ok_or_else(|| "no GPG keys found for GitHub user".to_string())?; + let public_key = first + .get("public_key") + .and_then(|value| value.as_str()) + .ok_or_else(|| "missing public_key in GitHub response".to_string())?; + Ok(public_key.to_string()) +} + +fn decode_github_public_key(public_key: String) -> Result, String> { + if public_key.contains("BEGIN PGP PUBLIC KEY BLOCK") { + return Ok(public_key.into_bytes()); + } + + let compact = public_key + .lines() + .map(str::trim) + .collect::>() + .join(""); + if compact.is_empty() { + return Err("GitHub public_key is empty".to_string()); + } + + base64::engine::general_purpose::STANDARD + .decode(compact.as_bytes()) + .map_err(|err| format!("failed to decode GitHub public_key: {}", err)) +} + +#[cfg(test)] +static TEST_GITHUB_PAYLOAD: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +fn test_github_payload_slot() -> &'static Mutex> { + TEST_GITHUB_PAYLOAD.get_or_init(|| Mutex::new(None)) +} + +#[cfg(test)] +fn set_test_github_payload(payload: Option) { + let mut guard = test_github_payload_slot().lock().expect("github payload lock"); + *guard = payload; +} + +#[cfg(test)] +fn github_api_get_gpg_keys(_user: &str) -> Result { + let guard = test_github_payload_slot().lock().expect("github payload lock"); + guard + .clone() + .ok_or_else(|| "test github payload not set".to_string()) +} + +#[cfg(not(test))] +fn github_api_get_gpg_keys(user: &str) -> Result { + let token = std::env::var("GH_TOKEN") + .or_else(|_| std::env::var("GITHUB_TOKEN")) + .map_err(|_| "GH_TOKEN or GITHUB_TOKEN must be set for GitHub API".to_string())?; + + let url = format!("https://api.github.com/users/{}/gpg_keys", user); + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static("application/vnd.github+json")); + headers.insert(USER_AGENT, HeaderValue::from_static("shadi-shadictl")); + let auth = format!("Bearer {}", token); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&auth).map_err(|_| "invalid GitHub token".to_string())?, + ); + + let client = Client::builder() + .default_headers(headers) + .build() + .map_err(|err| format!("failed to build HTTP client: {}", err))?; + + let response = client + .get(url) + .send() + .map_err(|err| format!("GitHub API request failed: {}", err))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(format!("GitHub API error {}: {}", status, body)); + } + + response.text().map_err(|err| format!("failed to read GitHub response: {}", err)) +} + + +fn derive_agent_keypair(secret_key: &[u8], agent_name: &str) -> Result<(Vec, Vec), String> { + if agent_name.trim().is_empty() { + return Err("agent name cannot be empty".to_string()); + } + let hk = Hkdf::::new(Some(b"shadi-agent-derive"), secret_key); + let mut seed = [0u8; 32]; + hk.expand(agent_name.as_bytes(), &mut seed) + .map_err(|_| "failed to derive agent key".to_string())?; + let signing = SigningKey::from_bytes(&seed); + let verifying = signing.verifying_key(); + Ok((signing.to_bytes().to_vec(), verifying.to_bytes().to_vec())) +} + +fn extract_ed25519_public_key(openpgp_bytes: &[u8]) -> Result, String> { + use openpgp::crypto::mpi::PublicKey as MpiPublicKey; + use openpgp::crypto::Curve; + use openpgp::parse::Parse; + use openpgp::policy::StandardPolicy; + + let cert = openpgp::Cert::from_reader(openpgp_bytes) + .map_err(|err| format!("failed to parse OpenPGP certificate: {}", err))?; + let policy = &StandardPolicy::new(); + + for key in cert + .keys() + .with_policy(policy, None) + .supported() + .alive() + .revoked(false) + { + match key.key().mpis() { + MpiPublicKey::Ed25519 { a } => return Ok(a.to_vec()), + MpiPublicKey::EdDSA { curve, q } if *curve == Curve::Ed25519 => { + return Ok(q.value().to_vec()); + } + _ => {} + } + } + + Err("no Ed25519 public key found in OpenPGP certificate".to_string()) +} + + +fn format_policy( + policy: &SandboxPolicy, + blocked: &HashSet, + allow: &HashSet, +) -> Result { + #[derive(serde::Serialize)] + struct PolicyDump { + allow: Vec, + read: Vec, + write: Vec, + net_block: bool, + allow_command: Vec, + block_command: Vec, + } + + let allow_paths = policy + .allow_read() + .iter() + .filter(|path| policy.allow_write().iter().any(|write| write == *path)) + .map(|path| path.display().to_string()) + .collect::>(); + let read_paths = policy + .allow_read() + .iter() + .filter(|path| !policy.allow_write().iter().any(|write| write == *path)) + .map(|path| path.display().to_string()) + .collect::>(); + let write_paths = policy + .allow_write() + .iter() + .filter(|path| !policy.allow_read().iter().any(|read| read == *path)) + .map(|path| path.display().to_string()) + .collect::>(); + let mut blocked_list = blocked.iter().cloned().collect::>(); + blocked_list.sort(); + let mut allow_list = allow.iter().cloned().collect::>(); + allow_list.sort(); + + let dump = PolicyDump { + allow: allow_paths, + read: read_paths, + write: write_paths, + net_block: policy.net_blocked(), + allow_command: allow_list, + block_command: blocked_list, + }; + + serde_json::to_string_pretty(&dump).map_err(|err| err.to_string()) +} + +fn resolve_policy(cli: &Cli, file_policy: &PolicyFile) -> Result { + let mut blocked = default_blocked_commands() + .into_iter() + .map(|cmd| cmd.to_string()) + .collect::>(); + for cmd in file_policy.block_command.iter() { + blocked.insert(cmd.to_string()); + } + + let mut allow = file_policy + .allow_command + .iter() + .map(|cmd| cmd.to_string()) + .collect::>(); + for cmd in cli.allow_command.iter() { + allow.insert(cmd.to_string()); + } + + let profile = profile_defaults(cli.profile); + let profile_net_block = profile.net_block.unwrap_or(false); + let mut policy = SandboxPolicy::new().block_network(cli.net_block || file_policy.net_block.unwrap_or(profile_net_block)); + + policy = apply_string_paths(policy, &profile.read, PathMode::Read)?; + policy = apply_string_paths(policy, &profile.write, PathMode::Write)?; + policy = apply_string_paths(policy, &profile.allow, PathMode::Allow)?; + + policy = apply_string_paths(policy, &file_policy.read, PathMode::Read)?; + policy = apply_string_paths(policy, &file_policy.write, PathMode::Write)?; + policy = apply_string_paths(policy, &file_policy.allow, PathMode::Allow)?; + + policy = apply_paths(policy, &cli.read, PathMode::Read)?; + policy = apply_paths(policy, &cli.write, PathMode::Write)?; + policy = apply_paths(policy, &cli.allow, PathMode::Allow)?; + + Ok(ResolvedPolicy { + policy, + blocked, + allow, + }) +} + +fn profile_defaults(profile: Option) -> PolicyFile { + match profile.unwrap_or(LauncherProfile::Balanced) { + LauncherProfile::Strict => PolicyFile { + allow: vec![".".to_string()], + read: vec![".".to_string()], + write: Vec::new(), + net_block: Some(true), + allow_command: Vec::new(), + block_command: Vec::new(), + }, + LauncherProfile::Balanced => PolicyFile { + allow: vec![".".to_string()], + read: vec!["/".to_string()], + write: Vec::new(), + net_block: Some(true), + allow_command: Vec::new(), + block_command: Vec::new(), + }, + LauncherProfile::Connected => PolicyFile { + allow: vec![".".to_string()], + read: vec!["/".to_string()], + write: Vec::new(), + net_block: Some(false), + allow_command: Vec::new(), + block_command: Vec::new(), + }, + } +} + +fn is_command_blocked(cmd: &str, blocked: &HashSet, allow: &HashSet) -> bool { + blocked.contains(cmd) && !allow.contains(cmd) +} + +enum PathMode { + Read, + Write, + Allow, +} + +fn apply_string_paths( + mut policy: SandboxPolicy, + paths: &[String], + mode: PathMode, +) -> Result { + for path in paths.iter() { + let path = canonicalize_string_path(path) + .map_err(|err| format!("invalid {} path {}: {}", mode.label(), path, err))?; + policy = apply_path(policy, &path, &mode); + } + Ok(policy) +} + +fn apply_paths( + mut policy: SandboxPolicy, + paths: &[PathBuf], + mode: PathMode, +) -> Result { + for path in paths.iter() { + let path = canonicalize_path(path) + .map_err(|err| format!("invalid {} path {}: {}", mode.label(), path.display(), err))?; + policy = apply_path(policy, &path, &mode); + } + Ok(policy) +} + +fn apply_path(mut policy: SandboxPolicy, path: &PathBuf, mode: &PathMode) -> SandboxPolicy { + match mode { + PathMode::Read => policy = policy.allow_read_path(path), + PathMode::Write => policy = policy.allow_write_path(path), + PathMode::Allow => policy = policy.allow_read_path(path).allow_write_path(path), + } + policy +} + +impl PathMode { + fn label(&self) -> &'static str { + match self { + PathMode::Read => "read", + PathMode::Write => "write", + PathMode::Allow => "allow", + } + } +} + +fn list_keychain(prefix: Option<&str>) -> Result<(), String> { + let store = default_secret_store(); + let keys = list_keychain_with_store(store.as_ref(), prefix)?; + for key in keys { + println!("{}", key); + } + Ok(()) +} + +fn list_keychain_with_store( + store: &dyn SecretStore, + prefix: Option<&str>, +) -> Result, String> { + let mut keys = store.list_keys().map_err(|err| err.to_string())?; + if let Some(prefix) = prefix { + keys.retain(|key| key.starts_with(prefix)); + } + keys.sort(); + Ok(keys) +} + +fn canonicalize_path(path: &PathBuf) -> std::io::Result { + std::fs::canonicalize(path) +} + +fn canonicalize_string_path(path: &str) -> std::io::Result { + std::fs::canonicalize(Path::new(path)) +} + +fn load_policy_file(path: &Path) -> std::io::Result { + let data = std::fs::read_to_string(path)?; + serde_json::from_str(&data).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err)) +} + +fn inject_keychain_secrets(command: &mut Command, mappings: &[String]) -> Result<(), String> { + if mappings.is_empty() { + return Ok(()); + } + + let store = default_secret_store(); + inject_keychain_with_store(store.as_ref(), command, mappings) +} + +fn inject_keychain_with_store( + store: &dyn SecretStore, + command: &mut Command, + mappings: &[String], +) -> Result<(), String> { + for mapping in mappings { + let (key, env) = parse_key_env(mapping)?; + let secret = store + .get(key) + .map_err(|_| format!("keychain lookup failed for {}", key))?; + let value = secret.expose(|bytes| bytes.to_vec()); + let value = secret_bytes_to_utf8(&value)?; + command.env(env, value); + } + + Ok(()) +} + +fn secret_bytes_to_utf8(value: &[u8]) -> Result { + String::from_utf8(value.to_vec()).map_err(|_| "secret is not utf-8".to_string()) +} + +fn parse_key_env(value: &str) -> Result<(&str, &str), String> { + let mut parts = value.splitn(2, '='); + let key = parts.next().unwrap_or(""); + let env = parts.next().unwrap_or(""); + if key.is_empty() || env.is_empty() { + return Err("inject-keychain must be in KEY=ENV format".to_string()); + } + Ok((key, env)) +} + +fn default_blocked_commands() -> HashSet<&'static str> { + [ + "rm", + "rmdir", + "shred", + "srm", + "dd", + "mkfs", + "fdisk", + "parted", + "wipefs", + "chmod", + "chown", + "chgrp", + "chattr", + "shutdown", + "reboot", + "halt", + "systemctl", + "apt", + "brew", + "pip", + "yum", + "pacman", + "mv", + "cp", + "truncate", + "sudo", + "su", + "doas", + "pkexec", + "scp", + "rsync", + "sftp", + "ftp", + ] + .into_iter() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + use std::collections::HashMap; + use std::sync::Mutex; + use agent_secrets::{SecretError, SecretResult}; + use agent_secrets::memory::SecretBytes; + use agent_secrets::policy::SecretPolicy; + + fn temp_dir() -> TempDir { + tempfile::tempdir().expect("tempdir") + } + + fn build_cli() -> Cli { + Cli { + profile: None, + policy_file: None, + allow: Vec::new(), + read: Vec::new(), + write: Vec::new(), + net_block: false, + allow_command: Vec::new(), + inject_keychain: Vec::new(), + list_keychain: false, + list_prefix: None, + print_policy: false, + command: vec!["echo".to_string(), "ok".to_string()], + } + } + + fn sample_openpgp_cert_armored() -> Vec { + use openpgp::cert::prelude::*; + use openpgp::serialize::Serialize; + + let (cert, _) = CertBuilder::general_purpose(Some("alice@example.org")) + .generate() + .expect("generate cert"); + let mut exported = Vec::new(); + cert.armored().export(&mut exported).expect("export cert"); + exported + } + + fn sample_openpgp_secret_armored() -> Vec { + use openpgp::cert::prelude::*; + use openpgp::serialize::Serialize; + + let (cert, _) = CertBuilder::general_purpose(Some("alice@example.org")) + .generate() + .expect("generate cert"); + let mut exported = Vec::new(); + cert.as_tsk() + .armored() + .export(&mut exported) + .expect("export secret key"); + exported + } + + fn unique_key(prefix: &str) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos(); + format!("{}-{}-{}", prefix, std::process::id(), nanos) + } + + fn policy_from_paths(read: &[PathBuf], write: &[PathBuf], allow: &[PathBuf]) -> PolicyFile { + PolicyFile { + read: read.iter().map(|p| p.display().to_string()).collect(), + write: write.iter().map(|p| p.display().to_string()).collect(), + allow: allow.iter().map(|p| p.display().to_string()).collect(), + net_block: Some(false), + allow_command: Vec::new(), + block_command: Vec::new(), + } + } + + #[test] + fn resolve_policy_merges_paths_and_commands() { + let read_dir = temp_dir(); + let write_dir = temp_dir(); + let allow_dir = temp_dir(); + let read_path = read_dir.path().canonicalize().expect("canonicalize"); + let write_path = write_dir.path().canonicalize().expect("canonicalize"); + let allow_path = allow_dir.path().canonicalize().expect("canonicalize"); + + let mut cli = build_cli(); + cli.read.push(read_path.clone()); + cli.allow_command.push("rm".to_string()); + + let policy_file = policy_from_paths(&[], &[write_path.clone()], &[allow_path.clone()]); + let resolved = resolve_policy(&cli, &policy_file).expect("resolve"); + + assert!(resolved.policy.allow_read().iter().any(|p| p == &read_path)); + assert!(resolved.policy.allow_write().iter().any(|p| p == &write_path)); + assert!(resolved.policy.allow_read().iter().any(|p| p == &allow_path)); + assert!(resolved.policy.allow_write().iter().any(|p| p == &allow_path)); + assert!(resolved.allow.contains("rm")); + } + + #[test] + fn resolve_policy_rejects_missing_paths() { + let cli = build_cli(); + let policy_file = PolicyFile { + read: vec!["/path/does/not/exist".to_string()], + write: Vec::new(), + allow: Vec::new(), + net_block: Some(false), + allow_command: Vec::new(), + block_command: Vec::new(), + }; + + let err = resolve_policy(&cli, &policy_file).unwrap_err(); + assert!(err.contains("invalid read path")); + } + + #[test] + fn resolve_policy_sets_net_block() { + let mut cli = build_cli(); + cli.net_block = true; + let policy_file = PolicyFile::default(); + let resolved = resolve_policy(&cli, &policy_file).expect("resolve"); + assert!(resolved.policy.net_blocked()); + } + + #[test] + fn resolve_policy_honors_file_net_block() { + let cli = build_cli(); + let policy_file = PolicyFile { + net_block: Some(true), + ..PolicyFile::default() + }; + let resolved = resolve_policy(&cli, &policy_file).expect("resolve"); + assert!(resolved.policy.net_blocked()); + } + + #[test] + fn resolve_policy_uses_balanced_profile_by_default() { + let cli = build_cli(); + let resolved = resolve_policy(&cli, &PolicyFile::default()).expect("resolve"); + let default_read = canonicalize_string_path("/").expect("canonical root path"); + assert!(resolved.policy.net_blocked()); + assert!(resolved + .policy + .allow_read() + .iter() + .any(|path| path == &default_read)); + } + + #[test] + fn resolve_policy_uses_connected_profile() { + let mut cli = build_cli(); + cli.profile = Some(LauncherProfile::Connected); + let resolved = resolve_policy(&cli, &PolicyFile::default()).expect("resolve"); + assert!(!resolved.policy.net_blocked()); + } + + #[test] + fn resolve_policy_uses_strict_profile() { + let mut cli = build_cli(); + cli.profile = Some(LauncherProfile::Strict); + let resolved = resolve_policy(&cli, &PolicyFile::default()).expect("resolve"); + let default_read = canonicalize_string_path("/").expect("canonical root path"); + assert!(resolved.policy.net_blocked()); + assert!(!resolved + .policy + .allow_read() + .iter() + .any(|path| path == &default_read)); + } + + #[test] + fn resolve_policy_merges_command_lists() { + let mut cli = build_cli(); + cli.allow_command.push("rm".to_string()); + let policy_file = PolicyFile { + allow_command: vec!["echo".to_string()], + block_command: vec!["rm".to_string()], + ..PolicyFile::default() + }; + let resolved = resolve_policy(&cli, &policy_file).expect("resolve"); + assert!(resolved.blocked.contains("rm")); + assert!(resolved.allow.contains("rm")); + assert!(resolved.allow.contains("echo")); + } + + #[test] + fn is_command_blocked_allows_unknown_when_not_blocked() { + let blocked = default_blocked_commands() + .into_iter() + .map(|cmd| cmd.to_string()) + .collect::>(); + let allow = HashSet::new(); + assert!(!is_command_blocked("echo", &blocked, &allow)); + } + + #[test] + fn command_blocking_respects_allowlist() { + let blocked = default_blocked_commands() + .into_iter() + .map(|cmd| cmd.to_string()) + .collect::>(); + let mut allow = HashSet::new(); + allow.insert("rm".to_string()); + + assert!(!is_command_blocked("rm", &blocked, &allow)); + assert!(is_command_blocked("mv", &blocked, &HashSet::new())); + } + + #[test] + fn format_policy_sorts_commands() { + let policy = SandboxPolicy::new(); + let blocked = ["rm".to_string(), "cp".to_string()].into_iter().collect(); + let allow = ["zsh".to_string(), "bash".to_string()].into_iter().collect(); + + let output = format_policy(&policy, &blocked, &allow).expect("format"); + assert!(output.contains("\"block_command\"")); + assert!(output.contains("\"allow_command\"")); + } + + #[test] + fn format_policy_groups_allow_paths() { + let dir = temp_dir(); + let path = dir.path().canonicalize().expect("canonicalize"); + let policy = SandboxPolicy::new() + .allow_read_path(&path) + .allow_write_path(&path); + let output = format_policy(&policy, &HashSet::new(), &HashSet::new()).expect("format"); + let path_str = path.display().to_string().replace('\\', "\\\\"); + assert!(output.contains(&path_str)); + } + + #[test] + fn format_policy_separates_read_and_write() { + let read_dir = temp_dir(); + let write_dir = temp_dir(); + let read_path = read_dir.path().canonicalize().expect("canonicalize"); + let write_path = write_dir.path().canonicalize().expect("canonicalize"); + let policy = SandboxPolicy::new() + .allow_read_path(&read_path) + .allow_write_path(&write_path); + let output = format_policy(&policy, &HashSet::new(), &HashSet::new()).expect("format"); + let read_str = read_path.display().to_string().replace('\\', "\\\\"); + let write_str = write_path.display().to_string().replace('\\', "\\\\"); + assert!(output.contains(&read_str)); + assert!(output.contains(&write_str)); + } + + #[test] + fn load_policy_file_parses_json() { + let dir = temp_dir(); + let path = dir.path().join("policy.json"); + let tmp_dir = std::env::var("SHADI_TMP_DIR").unwrap_or_else(|_| "./.tmp".to_string()); + std::fs::write( + &path, + format!(r#"{{"allow": ["{}"], "net_block": true}}"#, tmp_dir), + ) + .expect("write"); + + let policy = load_policy_file(&path).expect("load"); + assert_eq!(policy.allow, vec![tmp_dir]); + assert_eq!(policy.net_block, Some(true)); + } + + #[test] + fn load_policy_file_rejects_invalid_json() { + let dir = temp_dir(); + let path = dir.path().join("policy.json"); + std::fs::write(&path, "not-json").expect("write"); + let err = load_policy_file(&path).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn run_cli_missing_command_returns_error() { + let mut cli = build_cli(); + cli.command.clear(); + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(2)); + } + + #[test] + fn run_cli_print_policy_returns_ok() { + let mut cli = build_cli(); + cli.command.clear(); + cli.print_policy = true; + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + } + + #[test] + fn run_cli_blocks_disallowed_command() { + let mut cli = build_cli(); + cli.command = vec!["rm".to_string()]; + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(2)); + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn run_cli_executes_allowed_command() { + let mut cli = build_cli(); + cli.command = vec!["/usr/bin/true".to_string()]; + cli.allow.push(PathBuf::from("/usr/bin")); + let code = run_cli(cli); + assert_ne!(code, ExitCode::from(2)); + } + + #[test] + #[cfg(target_os = "windows")] + fn run_cli_executes_allowed_command() { + let mut cli = build_cli(); + let system32 = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()) + + "\\System32"; + cli.command = vec![format!("{}\\where.exe", system32), "cmd".to_string()]; + cli.allow.push(PathBuf::from(&system32)); + let code = run_cli(cli); + assert_ne!(code, ExitCode::from(2)); + } + + #[test] + fn canonicalize_helpers_resolve_paths() { + let dir = temp_dir(); + let path = canonicalize_path(&dir.path().to_path_buf()).expect("path"); + let text = canonicalize_string_path(dir.path().to_str().expect("str")).expect("str path"); + assert_eq!(path, text); + } + + #[test] + fn read_openpgp_input_reads_file() { + let dir = temp_dir(); + let path = dir.path().join("key.asc"); + std::fs::write(&path, b"test-key").expect("write"); + + let payload = read_openpgp_input("--key", None, Some(&path)).expect("read"); + assert_eq!(payload, b"test-key".to_vec()); + } + + #[test] + fn read_openpgp_input_reports_missing() { + let err = read_openpgp_input("--key", None, None).unwrap_err(); + assert!(err.contains("missing --key")); + } + + #[test] + fn read_openpgp_input_errors_on_missing_file() { + let dir = temp_dir(); + let path = dir.path().join("missing.asc"); + let err = read_openpgp_input("--key", None, Some(&path)).unwrap_err(); + assert!(err.contains("failed to read")); + } + + #[test] + fn inject_keychain_noop_when_empty() { + let mut command = Command::new("/usr/bin/true"); + inject_keychain_secrets(&mut command, &[]).expect("inject"); + } + + struct MemoryStore { + entries: Mutex>>, + } + + impl MemoryStore { + fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + } + } + } + + impl SecretStore for MemoryStore { + fn put(&self, key: &str, secret: &[u8], _policy: SecretPolicy) -> SecretResult<()> { + let mut guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + guard.insert(key.to_string(), secret.to_vec()); + Ok(()) + } + + fn get(&self, key: &str) -> SecretResult { + let guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + let value = guard.get(key).ok_or(SecretError::InvalidInput)?.clone(); + Ok(SecretBytes::new(value)) + } + + fn delete(&self, key: &str) -> SecretResult<()> { + let mut guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + guard.remove(key); + Ok(()) + } + + fn list_keys(&self) -> SecretResult> { + let guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + Ok(guard.keys().cloned().collect()) + } + } + + #[test] + fn list_keychain_with_store_filters_prefix() { + let store = MemoryStore::new(); + store.put("secops/a", b"1", SecretPolicy::default()).unwrap(); + store.put("other/b", b"2", SecretPolicy::default()).unwrap(); + + let keys = list_keychain_with_store(&store, Some("secops/")).unwrap(); + assert_eq!(keys, vec!["secops/a".to_string()]); + } + + #[test] + fn list_keychain_with_store_sorts_keys() { + let store = MemoryStore::new(); + store.put("b", b"1", SecretPolicy::default()).unwrap(); + store.put("a", b"2", SecretPolicy::default()).unwrap(); + + let keys = list_keychain_with_store(&store, None).unwrap(); + assert_eq!(keys, vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn inject_keychain_with_store_sets_env() { + let store = MemoryStore::new(); + store.put("secops/token", b"value", SecretPolicy::default()).unwrap(); + + let mut command = Command::new("/usr/bin/true"); + inject_keychain_with_store(&store, &mut command, &["secops/token=TOKEN".to_string()]).unwrap(); + + let envs = command.get_envs().collect::>(); + assert!(envs.iter().any(|(key, value)| { + *key == std::ffi::OsStr::new("TOKEN") + && *value == Some(std::ffi::OsStr::new("value")) + })); + } + + #[test] + fn inject_keychain_with_store_reports_missing_key() { + let store = MemoryStore::new(); + let mut command = Command::new("/usr/bin/true"); + let err = inject_keychain_with_store(&store, &mut command, &["missing=TOKEN".to_string()]).unwrap_err(); + assert!(err.contains("keychain lookup failed")); + } + + #[test] + fn inject_keychain_with_store_rejects_invalid_mapping() { + let store = MemoryStore::new(); + let mut command = Command::new("/usr/bin/true"); + let err = inject_keychain_with_store(&store, &mut command, &["invalid".to_string()]).unwrap_err(); + assert!(err.contains("inject-keychain must be")); + } + + #[test] + fn list_keychain_returns_ok_when_enabled() { + let key_a = unique_key("secops/key-a"); + let key_b = unique_key("secops/key-b"); + test_store_put(&key_a, b"a"); + test_store_put(&key_b, b"b"); + + list_keychain(Some("secops/")).expect("list"); + } + + #[test] + fn inject_keychain_secrets_uses_default_store() { + let key = unique_key("shadi-test-secret"); + test_store_put(&key, b"value"); + + let mut command = Command::new("/usr/bin/true"); + inject_keychain_secrets(&mut command, &[format!("{}=TOKEN", key)]).expect("inject"); + + let envs = command.get_envs().collect::>(); + assert!(envs.iter().any(|(env_key, value)| { + *env_key == std::ffi::OsStr::new("TOKEN") + && *value == Some(std::ffi::OsStr::new("value")) + })); + + } + + #[test] + fn run_cli_list_keychain_routes_to_store() { + let key_a = unique_key("secops/key-a"); + let key_b = unique_key("other/key-b"); + test_store_put(&key_a, b"a"); + test_store_put(&key_b, b"b"); + + let mut cli = build_cli(); + cli.command.clear(); + cli.list_keychain = true; + cli.list_prefix = Some("secops/".to_string()); + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + } + + #[test] + fn run_cli_put_key_command_stores_payload() { + let dir = temp_dir(); + let path = dir.path().join("key.asc"); + std::fs::write(&path, b"payload").expect("write"); + + let key = unique_key("openpgp/test"); + + let mut cli = build_cli(); + cli.command = vec![ + "put-key".to_string(), + "--key".to_string(), + key.clone(), + "--in".to_string(), + path.to_string_lossy().to_string(), + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + assert_eq!(test_store_get(&key), Some(b"payload".to_vec())); + } + + #[test] + fn run_cli_put_key_missing_file_returns_error() { + let dir = temp_dir(); + let path = dir.path().join("missing.asc"); + let key = unique_key("openpgp/missing"); + + let mut cli = build_cli(); + cli.command = vec![ + "put-key".to_string(), + "--key".to_string(), + key, + "--in".to_string(), + path.to_string_lossy().to_string(), + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(2)); + } + + #[test] + fn run_cli_get_secret_command_reads_store() { + let key = unique_key("secret/key"); + test_store_put(&key, b"value"); + + let mut cli = build_cli(); + cli.command = vec![ + "get-secret".to_string(), + "--key".to_string(), + key, + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + } + + #[test] + fn run_cli_get_secret_missing_key_returns_error() { + let key = unique_key("missing/key"); + + let mut cli = build_cli(); + cli.command = vec![ + "get-secret".to_string(), + "--key".to_string(), + key, + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(2)); + } + + #[test] + fn run_cli_did_from_gpg_writes_document() { + let dir = temp_dir(); + let input = dir.path().join("key.asc"); + let output = dir.path().join("did.json"); + std::fs::write(&input, sample_openpgp_cert_armored()).expect("write"); + + let mut cli = build_cli(); + cli.command = vec![ + "did-from-gpg".to_string(), + "--in".to_string(), + input.to_string_lossy().to_string(), + "--out".to_string(), + output.to_string_lossy().to_string(), + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + let content = std::fs::read_to_string(&output).expect("read did doc"); + assert!(content.contains("\"did:key:")); + } + + #[test] + fn run_cli_derive_agent_did_stores_outputs() { + let root_key = unique_key("root-secret"); + test_store_put(&root_key, b"root-secret"); + + let dir = temp_dir(); + let output = dir.path().join("agent.json"); + + let mut cli = build_cli(); + cli.command = vec![ + "derive-agent-did".to_string(), + "--secret".to_string(), + root_key.clone(), + "--name".to_string(), + "agent-a".to_string(), + "--prefix".to_string(), + "agents".to_string(), + "--out".to_string(), + output.to_string_lossy().to_string(), + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + assert!(test_store_get("agents/agent-a/private").is_some()); + assert!(test_store_get("agents/agent-a/public").is_some()); + assert!(test_store_get("agents/agent-a/did").is_some()); + assert!(test_store_get("agents/agent-a/diddoc").is_some()); + let content = std::fs::read_to_string(&output).expect("read did doc"); + assert!(content.contains("\"did:key:")); + } + + #[test] + fn run_cli_derive_agent_did_from_openpgp_file() { + let dir = temp_dir(); + let input = dir.path().join("human.sec"); + std::fs::write(&input, sample_openpgp_secret_armored()).expect("write"); + + let agent_name = unique_key("agent-gpg"); + let output = dir.path().join("agent.json"); + + let mut cli = build_cli(); + cli.command = vec![ + "derive-agent-did".to_string(), + "--in".to_string(), + input.to_string_lossy().to_string(), + "--name".to_string(), + agent_name.clone(), + "--prefix".to_string(), + "agents".to_string(), + "--out".to_string(), + output.to_string_lossy().to_string(), + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + assert!(test_store_get(&format!("agents/{}/private", agent_name)).is_some()); + assert!(test_store_get(&format!("agents/{}/public", agent_name)).is_some()); + assert!(test_store_get(&format!("agents/{}/did", agent_name)).is_some()); + assert!(test_store_get(&format!("agents/{}/diddoc", agent_name)).is_some()); + let content = std::fs::read_to_string(&output).expect("read did doc"); + assert!(content.contains("\"did:key:")); + } + + #[test] + fn run_cli_put_key_then_derive_agent_did_from_keychain() { + let dir = temp_dir(); + let input = dir.path().join("human.sec"); + std::fs::write(&input, sample_openpgp_secret_armored()).expect("write"); + + let key_name = unique_key("human-gpg"); + let mut cli = build_cli(); + cli.command = vec![ + "put-key".to_string(), + "--key".to_string(), + key_name.clone(), + "--in".to_string(), + input.to_string_lossy().to_string(), + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + assert!(test_store_get(&key_name).is_some()); + + let agent_name = unique_key("agent-from-keychain"); + let output = dir.path().join("agent.json"); + + let mut cli = build_cli(); + cli.command = vec![ + "derive-agent-did".to_string(), + "--secret".to_string(), + key_name, + "--name".to_string(), + agent_name.clone(), + "--prefix".to_string(), + "agents".to_string(), + "--out".to_string(), + output.to_string_lossy().to_string(), + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + assert!(test_store_get(&format!("agents/{}/private", agent_name)).is_some()); + assert!(test_store_get(&format!("agents/{}/public", agent_name)).is_some()); + assert!(test_store_get(&format!("agents/{}/did", agent_name)).is_some()); + assert!(test_store_get(&format!("agents/{}/diddoc", agent_name)).is_some()); + let content = std::fs::read_to_string(&output).expect("read did doc"); + assert!(content.contains("\"did:key:")); + } + + #[test] + fn run_cli_derive_agent_identity_from_seed_for_multiple_agents() { + let seed_key = unique_key("human-seed"); + test_store_put(&seed_key, b"human-seed-material"); + + let dir = temp_dir(); + let out_dir = dir.path().join("idents"); + + let mut cli = build_cli(); + cli.command = vec![ + "derive-agent-identity".to_string(), + "--source".to_string(), + "seed".to_string(), + "--human-secret".to_string(), + seed_key, + "--name".to_string(), + "agent-a".to_string(), + "--name".to_string(), + "agent-b".to_string(), + "--prefix".to_string(), + "agents".to_string(), + "--out-dir".to_string(), + out_dir.to_string_lossy().to_string(), + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + assert!(test_store_get("agents/agent-a/private").is_some()); + assert!(test_store_get("agents/agent-a/did").is_some()); + assert!(test_store_get("agents/agent-b/private").is_some()); + assert!(test_store_get("agents/agent-b/did").is_some()); + let a_doc = std::fs::read_to_string(out_dir.join("agent-a.did.json")).expect("read did doc"); + let b_doc = std::fs::read_to_string(out_dir.join("agent-b.did.json")).expect("read did doc"); + assert!(a_doc.contains("\"did:key:")); + assert!(b_doc.contains("\"did:key:")); + } + + #[test] + fn run_cli_derive_agent_identity_stores_human_did_binding() { + let root_key = unique_key("human-gpg"); + test_store_put(&root_key, b"root-secret"); + let human_did_key = unique_key("human-did"); + test_store_put(&human_did_key, b"did:key:zHuman"); + + let mut cli = build_cli(); + cli.command = vec![ + "derive-agent-identity".to_string(), + "--source".to_string(), + "seed".to_string(), + "--human-secret".to_string(), + root_key, + "--human-did-key".to_string(), + human_did_key, + "--name".to_string(), + "agent-bound".to_string(), + "--prefix".to_string(), + "agents".to_string(), + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + let stored = test_store_get("agents/agent-bound/human_did").expect("human did binding"); + assert_eq!(stored, b"did:key:zHuman".to_vec()); + } + + #[test] + fn run_cli_verify_agent_identity_succeeds() { + let seed_key = unique_key("verify-human-seed"); + test_store_put(&seed_key, b"verify-seed-material"); + + let mut cli = build_cli(); + cli.command = vec![ + "derive-agent-identity".to_string(), + "--source".to_string(), + "seed".to_string(), + "--human-secret".to_string(), + seed_key.clone(), + "--name".to_string(), + "agent-verify".to_string(), + "--prefix".to_string(), + "agents".to_string(), + ]; + assert_eq!(run_cli(cli), ExitCode::from(0)); + + let mut cli = build_cli(); + cli.command = vec![ + "verify-agent-identity".to_string(), + "--source".to_string(), + "seed".to_string(), + "--human-secret".to_string(), + seed_key, + "--name".to_string(), + "agent-verify".to_string(), + "--prefix".to_string(), + "agents".to_string(), + ]; + + assert_eq!(run_cli(cli), ExitCode::from(0)); + } + + #[test] + fn run_cli_verify_agent_identity_fails_on_mismatch() { + let seed_key = unique_key("verify-human-seed-a"); + test_store_put(&seed_key, b"seed-a"); + let other_seed_key = unique_key("verify-human-seed-b"); + test_store_put(&other_seed_key, b"seed-b"); + + let mut cli = build_cli(); + cli.command = vec![ + "derive-agent-identity".to_string(), + "--source".to_string(), + "seed".to_string(), + "--human-secret".to_string(), + seed_key, + "--name".to_string(), + "agent-mismatch".to_string(), + "--prefix".to_string(), + "agents".to_string(), + ]; + assert_eq!(run_cli(cli), ExitCode::from(0)); + + let mut cli = build_cli(); + cli.command = vec![ + "verify-agent-identity".to_string(), + "--source".to_string(), + "seed".to_string(), + "--human-secret".to_string(), + other_seed_key, + "--name".to_string(), + "agent-mismatch".to_string(), + "--prefix".to_string(), + "agents".to_string(), + ]; + + assert_eq!(run_cli(cli), ExitCode::from(2)); + } + + #[test] + fn run_cli_verify_agent_identity_checks_human_binding() { + let seed_key = unique_key("verify-binding-seed"); + test_store_put(&seed_key, b"binding-seed"); + let human_did_key = unique_key("verify-human-did"); + test_store_put(&human_did_key, b"did:key:zHumanBinding"); + + let mut cli = build_cli(); + cli.command = vec![ + "derive-agent-identity".to_string(), + "--source".to_string(), + "seed".to_string(), + "--human-secret".to_string(), + seed_key.clone(), + "--human-did-key".to_string(), + human_did_key.clone(), + "--name".to_string(), + "agent-binding".to_string(), + "--prefix".to_string(), + "agents".to_string(), + ]; + assert_eq!(run_cli(cli), ExitCode::from(0)); + + let mut cli = build_cli(); + cli.command = vec![ + "verify-agent-identity".to_string(), + "--source".to_string(), + "seed".to_string(), + "--human-secret".to_string(), + seed_key, + "--human-did-key".to_string(), + human_did_key, + "--require-human-binding".to_string(), + "--name".to_string(), + "agent-binding".to_string(), + "--prefix".to_string(), + "agents".to_string(), + ]; + + assert_eq!(run_cli(cli), ExitCode::from(0)); + } + + #[test] + fn run_cli_did_from_github_stores_outputs() { + let armored = String::from_utf8(sample_openpgp_cert_armored()).expect("armored"); + let payload = serde_json::json!([ + {"id": 1, "public_key": armored} + ]) + .to_string(); + set_test_github_payload(Some(payload)); + + let dir = temp_dir(); + let output = dir.path().join("github.json"); + + let mut cli = build_cli(); + cli.command = vec![ + "did-from-github".to_string(), + "--user".to_string(), + "alice".to_string(), + "--out".to_string(), + output.to_string_lossy().to_string(), + ]; + + let code = run_cli(cli); + assert_eq!(code, ExitCode::from(0)); + assert!(test_store_get("github/alice/did").is_some()); + assert!(test_store_get("github/alice/diddoc").is_some()); + let content = std::fs::read_to_string(&output).expect("read did doc"); + assert!(content.contains("\"did:key:")); + + set_test_github_payload(None); + } + + #[test] + fn blocklist_blocks_default_command() { + let blocked = default_blocked_commands(); + assert!(blocked.contains("rm")); + } + + #[test] + fn allowlist_overrides_blocklist() { + let blocked = default_blocked_commands(); + let allow = ["rm"].into_iter().collect::>(); + assert!(blocked.contains("rm")); + assert!(allow.contains("rm")); + } + + #[test] + fn parse_key_env_rejects_missing_parts() { + assert!(parse_key_env("onlykey").is_err()); + assert!(parse_key_env("=ENV").is_err()); + assert!(parse_key_env("KEY=").is_err()); + } + + #[test] + fn parse_key_env_accepts_valid_format() { + let (key, env) = parse_key_env("secret=ENV").unwrap(); + assert_eq!(key, "secret"); + assert_eq!(env, "ENV"); + } + + #[test] + fn extract_ed25519_public_key_from_cert() { + let public_key = extract_ed25519_public_key(&sample_openpgp_cert_armored()).expect("extract key"); + assert!(public_key.len() == 32 || public_key.len() == 33); + if public_key.len() == 33 { + assert_eq!(public_key[0], 0x40); + } + } + + #[test] + fn build_did_document_from_ed25519_key() { + let pubkey = vec![0x01; 32]; + let mut pkey = vec![0x40]; + pkey.extend_from_slice(&pubkey); + + let (did, vm_id, doc) = build_did_document(&pkey).unwrap(); + + let mut multicodec = vec![0xED, 0x01]; + multicodec.extend_from_slice(&pubkey); + let fingerprint = format!("z{}", bs58::encode(multicodec).into_string()); + + assert_eq!(did, format!("did:key:{}", fingerprint)); + assert_eq!(vm_id, format!("{}#{}", did, fingerprint)); + assert_eq!(doc["id"], did); + assert_eq!(doc["verificationMethod"][0]["id"], vm_id); + assert_eq!(doc["verificationMethod"][0]["publicKeyMultibase"], fingerprint); + } + + #[test] + fn build_did_document_rejects_wrong_length() { + let err = build_did_document(&vec![0x01; 31]).unwrap_err(); + assert!(err.contains("unexpected Ed25519")); + } + + #[test] + fn extract_github_public_key_returns_first() { + let payload = r#"[ + {"id": 1, "public_key": "KEY1"}, + {"id": 2, "public_key": "KEY2"} + ]"#; + + let key = extract_github_public_key(payload).unwrap(); + assert_eq!(key, "KEY1"); + } + + #[test] + fn extract_github_public_key_errors_on_empty_list() { + let err = extract_github_public_key("[]").unwrap_err(); + assert!(err.contains("no GPG keys")); + } + + #[test] + fn extract_github_public_key_errors_on_missing_field() { + let payload = r#"[{"id": 1}]"#; + let err = extract_github_public_key(payload).unwrap_err(); + assert!(err.contains("public_key")); + } + + #[test] + fn extract_github_public_key_errors_on_unexpected_format() { + let err = extract_github_public_key("{}").unwrap_err(); + assert!(err.contains("unexpected GitHub response")); + } + + #[test] + fn decode_github_public_key_accepts_armored() { + let armored = "-----BEGIN PGP PUBLIC KEY BLOCK-----\nabc\n-----END PGP PUBLIC KEY BLOCK-----\n"; + let decoded = decode_github_public_key(armored.to_string()).unwrap(); + assert_eq!(decoded, armored.as_bytes()); + } + + #[test] + fn decode_github_public_key_decodes_base64() { + let decoded = decode_github_public_key("AQID".to_string()).unwrap(); + assert_eq!(decoded, vec![1, 2, 3]); + } + + #[test] + fn decode_github_public_key_rejects_empty() { + let err = decode_github_public_key("\n \n".to_string()).unwrap_err(); + assert!(err.contains("empty")); + } + + #[test] + fn secret_bytes_to_utf8_rejects_invalid_utf8() { + let err = secret_bytes_to_utf8(&[0xff, 0xff]).unwrap_err(); + assert!(err.contains("utf-8")); + } + + #[test] + fn derive_agent_keypair_is_deterministic() { + let seed = b"root-secret"; + let (priv1, pub1) = derive_agent_keypair(seed, "agent-a").expect("derive"); + let (priv2, pub2) = derive_agent_keypair(seed, "agent-a").expect("derive"); + assert_eq!(priv1, priv2); + assert_eq!(pub1, pub2); + } + + #[test] + fn derive_agent_keypair_changes_with_name() { + let seed = b"root-secret"; + let (_, pub1) = derive_agent_keypair(seed, "agent-a").expect("derive"); + let (_, pub2) = derive_agent_keypair(seed, "agent-b").expect("derive"); + assert_ne!(pub1, pub2); + } + + #[test] + fn derive_agent_keypair_rejects_empty_name() { + let err = derive_agent_keypair(b"root-secret", " ").unwrap_err(); + assert!(err.contains("agent name")); + } +} diff --git a/crates/slim_mas/Cargo.toml b/crates/slim_mas/Cargo.toml new file mode 100644 index 0000000..91bb75c --- /dev/null +++ b/crates/slim_mas/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "slim_mas" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +agent_secrets = { path = "../agent_secrets" } + +[dev-dependencies] +tempfile = "3.10" diff --git a/crates/slim_mas/README.md b/crates/slim_mas/README.md new file mode 100644 index 0000000..71b3c75 --- /dev/null +++ b/crates/slim_mas/README.md @@ -0,0 +1,26 @@ +# slim_mas + +SLIM Multi-Agent System (MAS) moderator for SHADI. + +This crate provides a minimal group registry and admission control for SLIM +participants using DIDs. + +## Config (mas.toml) +```toml +[mas] +default_group = "secops-team" + +[groups.secops-team] +moderator_did = "did:key:..." +members = [ + { did = "did:key:...", role = "human" }, + { did = "did:key:...", role = "agent" } +] +``` + +## CLI +```bash +cargo run -p slim_mas -- list-groups +cargo run -p slim_mas -- list-members --group secops-team +cargo run -p slim_mas -- admit --group secops-team --did did:key:... --role human +``` diff --git a/crates/slim_mas/src/lib.rs b/crates/slim_mas/src/lib.rs new file mode 100644 index 0000000..99bf922 --- /dev/null +++ b/crates/slim_mas/src/lib.rs @@ -0,0 +1,240 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct MasConfig { + pub mas: Option, + #[serde(default)] + pub groups: BTreeMap, +} + +#[derive(Debug, Deserialize)] +pub struct MasSettings { + pub default_group: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GroupConfig { + pub moderator_did: Option, + #[serde(default)] + pub members: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct MemberConfig { + pub did: String, + pub role: Option, +} + +impl MasConfig { + pub fn default_group(&self) -> Option<&str> { + self.mas.as_ref()?.default_group.as_deref() + } + + pub fn group(&self, name: &str) -> Option<&GroupConfig> { + self.groups.get(name) + } +} + +pub fn load_config(path: &Path) -> Result { + let data = std::fs::read_to_string(path) + .map_err(|err| format!("failed to read {}: {}", path.display(), err))?; + toml::from_str(&data).map_err(|err| format!("invalid config {}: {}", path.display(), err)) +} + +pub fn resolve_group<'a>(config: &'a MasConfig, group: Option<&'a str>) -> Result<&'a str, String> { + if let Some(group) = group { + return Ok(group); + } + config + .default_group() + .ok_or_else(|| "group is required (no default_group set)".to_string()) +} + +pub fn is_member_allowed(group: &GroupConfig, did: &str, role: Option<&str>) -> bool { + group.members.iter().any(|member| { + if member.did != did { + return false; + } + match (role, member.role.as_deref()) { + (Some(expected), Some(actual)) => expected == actual, + (Some(_), None) => false, + (None, _) => true, + } + }) +} + +pub fn resolve_did_ref(did_ref: &str, mut fetch: F) -> Result +where + F: FnMut(&str) -> Result, +{ + let prefix = "shadi://"; + if let Some(key) = did_ref.strip_prefix(prefix) { + if key.is_empty() { + return Err("empty SHADI key in DID reference".to_string()); + } + return fetch(key); + } + Ok(did_ref.to_string()) +} + +pub fn resolve_group_dids(group: &GroupConfig, mut fetch: F) -> Result +where + F: FnMut(&str) -> Result, +{ + let moderator_did = match group.moderator_did.as_deref() { + Some(did_ref) => Some(resolve_did_ref(did_ref, &mut fetch)?), + None => None, + }; + let mut members = Vec::with_capacity(group.members.len()); + for member in &group.members { + let resolved = resolve_did_ref(&member.did, &mut fetch)?; + members.push(MemberConfig { + did: resolved, + role: member.role.clone(), + }); + } + Ok(GroupConfig { + moderator_did, + members, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_config(contents: &str) -> tempfile::NamedTempFile { + let file = tempfile::NamedTempFile::new().expect("tempfile"); + std::fs::write(file.path(), contents).expect("write config"); + file + } + + #[test] + fn load_config_parses_groups_and_default() { + let contents = r#" +[mas] +default_group = "team-a" + +[groups.team-a] +moderator_did = "did:key:moderator" +members = [ + { did = "did:key:human", role = "human" }, + { did = "did:key:agent", role = "agent" } +] +"#; + let file = write_config(contents); + let config = load_config(file.path()).expect("load"); + assert_eq!(config.default_group(), Some("team-a")); + let group = config.group("team-a").expect("group"); + assert_eq!(group.moderator_did.as_deref(), Some("did:key:moderator")); + assert_eq!(group.members.len(), 2); + } + + #[test] + fn resolve_group_prefers_argument() { + let config = MasConfig { + mas: Some(MasSettings { + default_group: Some("team-a".to_string()), + }), + groups: BTreeMap::new(), + }; + let group = resolve_group(&config, Some("team-b")).expect("group"); + assert_eq!(group, "team-b"); + } + + #[test] + fn resolve_group_uses_default() { + let config = MasConfig { + mas: Some(MasSettings { + default_group: Some("team-a".to_string()), + }), + groups: BTreeMap::new(), + }; + let group = resolve_group(&config, None).expect("group"); + assert_eq!(group, "team-a"); + } + + #[test] + fn resolve_group_errors_without_default() { + let config = MasConfig { + mas: None, + groups: BTreeMap::new(), + }; + let err = resolve_group(&config, None).unwrap_err(); + assert!(err.contains("group is required")); + } + + #[test] + fn is_member_allowed_matches_role_when_required() { + let group = GroupConfig { + moderator_did: None, + members: vec![MemberConfig { + did: "did:key:human".to_string(), + role: Some("human".to_string()), + }], + }; + assert!(is_member_allowed(&group, "did:key:human", Some("human"))); + assert!(!is_member_allowed(&group, "did:key:human", Some("agent"))); + } + + #[test] + fn is_member_allowed_accepts_when_role_not_required() { + let group = GroupConfig { + moderator_did: None, + members: vec![MemberConfig { + did: "did:key:agent".to_string(), + role: None, + }], + }; + assert!(is_member_allowed(&group, "did:key:agent", None)); + assert!(!is_member_allowed(&group, "did:key:agent", Some("agent"))); + } + + #[test] + fn is_member_allowed_rejects_unknown_did() { + let group = GroupConfig { + moderator_did: None, + members: vec![MemberConfig { + did: "did:key:human".to_string(), + role: Some("human".to_string()), + }], + }; + assert!(!is_member_allowed(&group, "did:key:unknown", None)); + } + + #[test] + fn resolve_did_ref_returns_literal() { + let did = resolve_did_ref("did:key:abc", |_| Ok("x".to_string())).expect("did"); + assert_eq!(did, "did:key:abc"); + } + + #[test] + fn resolve_did_ref_fetches_shadi_key() { + let did = resolve_did_ref("shadi://github/user/did", |key| Ok(format!("did:{}", key))) + .expect("did"); + assert_eq!(did, "did:github/user/did"); + } + + #[test] + fn resolve_did_ref_rejects_empty_key() { + let err = resolve_did_ref("shadi://", |_| Ok("did".to_string())).unwrap_err(); + assert!(err.contains("empty SHADI key")); + } + + #[test] + fn resolve_group_dids_resolves_members() { + let group = GroupConfig { + moderator_did: Some("shadi://mod".to_string()), + members: vec![MemberConfig { + did: "shadi://member".to_string(), + role: Some("human".to_string()), + }], + }; + let resolved = resolve_group_dids(&group, |key| Ok(format!("did:{}", key))).expect("group"); + assert_eq!(resolved.moderator_did.as_deref(), Some("did:mod")); + assert_eq!(resolved.members[0].did, "did:member"); + } +} diff --git a/crates/slim_mas/src/main.rs b/crates/slim_mas/src/main.rs new file mode 100644 index 0000000..9b3dbe1 --- /dev/null +++ b/crates/slim_mas/src/main.rs @@ -0,0 +1,499 @@ +use std::path::PathBuf; + +#[cfg(test)] +use std::collections::HashMap; +#[cfg(test)] +use std::sync::{Mutex, OnceLock}; + +use clap::{Parser, Subcommand}; + +use slim_mas::{is_member_allowed, load_config, resolve_group, resolve_group_dids}; + +#[derive(Parser, Debug)] +#[command(name = "slim-mas", about = "SHADI SLIM Multi-Agent System moderator")] +struct Cli { + #[arg(long = "config", value_name = "FILE", default_value = "mas.toml")] + config: PathBuf, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + Admit { + #[arg(long = "group", value_name = "GROUP")] + group: Option, + + #[arg(long = "did", value_name = "DID")] + did: String, + + #[arg(long = "role", value_name = "ROLE")] + role: Option, + }, + ListGroups, + ListMembers { + #[arg(long = "group", value_name = "GROUP")] + group: Option, + }, + Validate, +} + +fn main() -> std::process::ExitCode { + let cli = Cli::parse(); + match run(cli) { + Ok(code) => code, + Err(err) => { + eprintln!("{}", err); + std::process::ExitCode::from(2) + } + } +} + +fn run(cli: Cli) -> Result { + let config = load_config(&cli.config)?; + let store = default_secret_store(); + let mut fetch = |key: &str| { + let secret = store.get(key).map_err(|_| format!("keychain lookup failed for {}", key))?; + let value = secret.expose(|bytes| bytes.to_vec()); + String::from_utf8(value).map_err(|_| "secret is not utf-8".to_string()) + }; + + match cli.command { + Commands::Admit { group, did, role } => { + let group_name = resolve_group(&config, group.as_deref())?; + let group_config = config + .group(group_name) + .ok_or_else(|| format!("group '{}' not found", group_name))?; + let group_config = resolve_group_dids(group_config, &mut fetch)?; + let did = slim_mas::resolve_did_ref(&did, &mut fetch)?; + + if is_member_allowed(&group_config, &did, role.as_deref()) { + println!("allow"); + Ok(std::process::ExitCode::from(0)) + } else { + println!("deny"); + Ok(std::process::ExitCode::from(3)) + } + } + Commands::ListGroups => { + for name in config.groups.keys() { + println!("{}", name); + } + Ok(std::process::ExitCode::from(0)) + } + Commands::ListMembers { group } => { + let group_name = resolve_group(&config, group.as_deref())?; + let group_config = config + .group(group_name) + .ok_or_else(|| format!("group '{}' not found", group_name))?; + let group_config = resolve_group_dids(group_config, &mut fetch)?; + for member in &group_config.members { + match member.role.as_deref() { + Some(role) => println!("{} {}", member.did, role), + None => println!("{}", member.did), + } + } + Ok(std::process::ExitCode::from(0)) + } + Commands::Validate => { + let _ = resolve_group(&config, None)?; + Ok(std::process::ExitCode::from(0)) + } + } +} + +#[cfg(test)] +static TEST_SECRET_STORE: OnceLock>>> = OnceLock::new(); + +#[cfg(test)] +fn test_secret_store_map() -> &'static Mutex>> { + TEST_SECRET_STORE.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +struct TestSecretStore; + +#[cfg(test)] +impl agent_secrets::SecretStore for TestSecretStore { + fn put( + &self, + key: &str, + secret: &[u8], + _policy: agent_secrets::SecretPolicy, + ) -> agent_secrets::SecretResult<()> { + let mut guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + guard.insert(key.to_string(), secret.to_vec()); + Ok(()) + } + + fn get(&self, key: &str) -> agent_secrets::SecretResult { + let guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + let value = guard + .get(key) + .ok_or(agent_secrets::SecretError::InvalidInput)? + .clone(); + Ok(agent_secrets::SecretBytes::new(value)) + } + + fn delete(&self, key: &str) -> agent_secrets::SecretResult<()> { + let mut guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + guard.remove(key); + Ok(()) + } + + fn list_keys(&self) -> agent_secrets::SecretResult> { + let guard = test_secret_store_map() + .lock() + .map_err(|_| agent_secrets::SecretError::StorageFailure)?; + Ok(guard.keys().cloned().collect()) + } +} + +#[cfg(test)] +fn default_secret_store() -> Box { + Box::new(TestSecretStore) +} + +#[cfg(not(test))] +fn default_secret_store() -> Box { + agent_secrets::default_store() +} + +#[cfg(test)] +fn test_store_put(key: &str, value: &[u8]) { + let mut guard = test_secret_store_map().lock().expect("test store lock"); + guard.insert(key.to_string(), value.to_vec()); +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_secrets::{SecretBytes, SecretError, SecretResult, SecretStore}; + use std::collections::HashMap; + use std::sync::Mutex; + + fn write_config(contents: &str) -> tempfile::NamedTempFile { + let file = tempfile::NamedTempFile::new().expect("tempfile"); + std::fs::write(file.path(), contents).expect("write config"); + file + } + + fn sample_config() -> tempfile::NamedTempFile { + write_config( + r#" +[mas] +default_group = "team-a" + +[groups.team-a] +moderator_did = "did:key:moderator" +members = [ + { did = "did:key:human", role = "human" }, + { did = "did:key:agent", role = "agent" } +] +"#, + ) + } + + struct MemoryStore { + entries: Mutex>>, + } + + impl MemoryStore { + fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + } + } + } + + impl SecretStore for MemoryStore { + fn put(&self, key: &str, secret: &[u8], _policy: agent_secrets::SecretPolicy) -> SecretResult<()> { + let mut guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + guard.insert(key.to_string(), secret.to_vec()); + Ok(()) + } + + fn get(&self, key: &str) -> SecretResult { + let guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + let value = guard.get(key).ok_or(SecretError::InvalidInput)?.clone(); + Ok(SecretBytes::new(value)) + } + + fn delete(&self, key: &str) -> SecretResult<()> { + let mut guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + guard.remove(key); + Ok(()) + } + + fn list_keys(&self) -> SecretResult> { + let guard = self.entries.lock().map_err(|_| SecretError::StorageFailure)?; + Ok(guard.keys().cloned().collect()) + } + } + + fn sample_config_with_shadi_refs() -> tempfile::NamedTempFile { + write_config( + r#" +[mas] +default_group = "team-a" + +[groups.team-a] +moderator_did = "shadi://mod" +members = [ + { did = "shadi://human", role = "human" } +] +"#, + ) + } + + fn sample_config_with_shadi_refs_named(mod_key: &str, human_key: &str) -> tempfile::NamedTempFile { + write_config(&format!( + r#" +[mas] +default_group = "team-a" + +[groups.team-a] +moderator_did = "shadi://{}" +members = [ + {{ did = "shadi://{}", role = "human" }} +] +"#, + mod_key, human_key + )) + } + + fn unique_key(prefix: &str) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos(); + format!("{}-{}-{}", prefix, std::process::id(), nanos) + } + + #[test] + fn run_lists_groups() { + let file = sample_config(); + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::ListGroups, + }; + let code = run(cli).expect("run"); + assert_eq!(code, std::process::ExitCode::from(0)); + } + + #[test] + fn run_lists_members() { + let file = sample_config(); + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::ListMembers { + group: Some("team-a".to_string()), + }, + }; + let code = run(cli).expect("run"); + assert_eq!(code, std::process::ExitCode::from(0)); + } + + #[test] + fn run_admit_allows_member() { + let file = sample_config(); + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::Admit { + group: Some("team-a".to_string()), + did: "did:key:human".to_string(), + role: Some("human".to_string()), + }, + }; + let code = run(cli).expect("run"); + assert_eq!(code, std::process::ExitCode::from(0)); + } + + #[test] + fn run_admit_denies_member() { + let file = sample_config(); + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::Admit { + group: Some("team-a".to_string()), + did: "did:key:human".to_string(), + role: Some("agent".to_string()), + }, + }; + let code = run(cli).expect("run"); + assert_eq!(code, std::process::ExitCode::from(3)); + } + + #[test] + fn run_validate_rejects_missing_default_group() { + let file = write_config( + r#" +[groups.team-a] +members = [{ did = "did:key:human" }] +"#, + ); + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::Validate, + }; + let err = run(cli).unwrap_err(); + assert!(err.contains("default_group")); + } + + #[test] + fn run_admit_resolves_shadi_dids() { + let file = sample_config_with_shadi_refs(); + let store = MemoryStore::new(); + store + .put("human", b"did:key:human", agent_secrets::SecretPolicy::default()) + .expect("put"); + store + .put("mod", b"did:key:mod", agent_secrets::SecretPolicy::default()) + .expect("put"); + + let mut fetch = |key: &str| { + let secret = store.get(key).map_err(|_| format!("keychain lookup failed for {}", key))?; + let value = secret.expose(|bytes| bytes.to_vec()); + String::from_utf8(value).map_err(|_| "secret is not utf-8".to_string()) + }; + + let config = load_config(file.path()).expect("load"); + let group_name = resolve_group(&config, None).expect("group"); + let group_config = resolve_group_dids(config.group(group_name).unwrap(), &mut fetch).expect("group"); + let did = slim_mas::resolve_did_ref("shadi://human", &mut fetch).expect("did"); + + assert!(is_member_allowed(&group_config, &did, Some("human"))); + } + + #[test] + fn run_admit_reports_unknown_group() { + let file = sample_config(); + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::Admit { + group: Some("missing".to_string()), + did: "did:key:human".to_string(), + role: Some("human".to_string()), + }, + }; + + let err = run(cli).unwrap_err(); + assert!(err.contains("group 'missing' not found")); + } + + #[test] + fn run_list_members_reports_unknown_group() { + let file = sample_config(); + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::ListMembers { + group: Some("missing".to_string()), + }, + }; + + let err = run(cli).unwrap_err(); + assert!(err.contains("group 'missing' not found")); + } + + #[test] + fn run_admit_resolves_shadi_dids_via_store() { + let mod_key = unique_key("mod"); + let human_key = unique_key("human"); + let file = sample_config_with_shadi_refs_named(&mod_key, &human_key); + + test_store_put(&human_key, b"did:key:human"); + test_store_put(&mod_key, b"did:key:mod"); + + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::Admit { + group: None, + did: format!("shadi://{}", human_key), + role: Some("human".to_string()), + }, + }; + + let code = run(cli).expect("run"); + assert_eq!(code, std::process::ExitCode::from(0)); + } + + #[test] + fn run_admit_rejects_non_utf8_secret() { + let mod_key = unique_key("mod"); + let human_key = unique_key("human"); + let file = sample_config_with_shadi_refs_named(&mod_key, &human_key); + + test_store_put(&mod_key, b"did:key:mod"); + test_store_put(&human_key, &[0xff, 0xfe]); + + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::Admit { + group: None, + did: format!("shadi://{}", human_key), + role: Some("human".to_string()), + }, + }; + + let err = run(cli).unwrap_err(); + assert!(err.contains("secret is not utf-8")); + } + + #[test] + fn run_lists_members_without_role() { + let file = write_config( + r#" +[mas] +default_group = "team-a" + +[groups.team-a] +moderator_did = "did:key:moderator" +members = [ + { did = "did:key:human" } +] +"#, + ); + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::ListMembers { + group: Some("team-a".to_string()), + }, + }; + + let code = run(cli).expect("run"); + assert_eq!(code, std::process::ExitCode::from(0)); + } + + #[test] + fn run_validate_ok_when_default_group_present() { + let file = sample_config(); + let cli = Cli { + config: file.path().to_path_buf(), + command: Commands::Validate, + }; + + let code = run(cli).expect("run"); + assert_eq!(code, std::process::ExitCode::from(0)); + } + + #[test] + fn test_secret_store_roundtrip() { + let store = default_secret_store(); + let key = unique_key("slim-mas/store"); + store + .put(&key, b"value", agent_secrets::SecretPolicy::default()) + .expect("put"); + let keys = store.list_keys().expect("list"); + assert!(keys.iter().any(|item| item == &key)); + store.delete(&key).expect("delete"); + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ede4d67 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,9 @@ +# Documentation + +This folder is the source for the SHADI documentation site built with MkDocs. + +Run locally: + +```bash +mkdocs serve +``` diff --git a/docs/adk_integration.md b/docs/adk_integration.md new file mode 100644 index 0000000..15bda17 --- /dev/null +++ b/docs/adk_integration.md @@ -0,0 +1,29 @@ +# Google ADK Integration (Draft) + +SHADI exposes a Python extension module for secret access, SQLCipher memory, +and sandbox execution so ADK agents can run without handling OS keychains +directly. + +## Recommended example app +The agentic-apps tourist scheduling system includes ADK agents and a SLIM +configuration already. The Tourist and Guide agents are good candidates for +first integration with SHADI. + +## Suggested flow +1. Create a `PySessionContext` for the agent. +2. Configure `ShadiStore.set_verifier()` with a DID/VC verification callback. +3. Call `ShadiStore.verify_session()` with the DID/VC presentation. +4. Use `ShadiStore` to fetch secrets during tool execution. +5. Use `SqlCipherMemoryStore` for encrypted local memory when needed. +6. Run under the sandbox using `run_sandboxed` or the JSON policy runner. + +## Key provisioning +Use `shadictl` to ingest OpenPGP keys and derive agent DIDs without invoking OS `gpg`: + +```bash +cargo run -p shadictl -- \ + put-key --key human/gpg --in /path/to/human-secret.asc + +cargo run -p shadictl -- \ + derive-agent-did --secret human/gpg --name agent-a --prefix agents +``` diff --git a/docs/api_integration.md b/docs/api_integration.md new file mode 100644 index 0000000..9b2093d --- /dev/null +++ b/docs/api_integration.md @@ -0,0 +1,350 @@ +# API and Integration Guide + +This guide describes SHADI APIs and integration patterns for applications. + +## Quickstart flow + +```bash +# 1) Fetch human public key from GitHub and store DID artifacts. +cargo run -p shadictl -- did-from-github --user alice --out human.did.json + +# 2) Store the human OpenPGP secret key locally. +cargo run -p shadictl -- put-key --key human/gpg --in /path/to/human-secret.asc + +# 3) Derive agent identities and keys. +cargo run -p shadictl -- derive-agent-did --secret human/gpg --name agent-a --prefix agents +``` + +```mermaid +flowchart LR + A[GitHub GPG public key] --> B[did-from-github] + B --> C[Human DID + DID doc] + D[Local OpenPGP secret key] --> E[put-key] + E --> F[Secret store: human/gpg] + F --> G[derive-agent-did] + G --> H[Agent keys + DIDs] + H --> I[Secret store: agents/*] +``` + +## Listing context and long-term memory + +To list secrets stored for an app or agent, use `shadictl`: + +```bash +cargo run -p shadictl -- --list-keychain --list-prefix agents/ +cargo run -p shadictl -- --list-keychain --list-prefix secops/ +``` + +Avoid printing secret values. Pass key names to helpers that resolve secrets +inside SHADI. + +To list or search long-term memory in the encrypted SQLCipher store, use the +`shadictl memory` helper so keys stay in SHADI: + +```bash +cargo run -p shadictl -- -- memory list \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + --scope secops --limit 50 +``` + +```bash +cargo run -p shadictl -- -- memory search \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + --scope secops --query dependabot --limit 10 +``` + +## Integration overview + +Most applications integrate SHADI in three layers: + +1. **Identity and session verification**: validate a DID/VC presentation. +2. **Secrets access**: gate reads and writes to the secret store. +3. **Sandbox execution** (optional): run agents with OS-level restrictions. + +## Rust API + +The Rust integration surface lives in `agent_secrets`. + +### Core traits and types + +- `SecretStore`: storage backend (platform keychain by default). +- `AgentVerifier`: verifies a session before secret access. +- `SessionContext`: session metadata used by `AgentVerifier`. +- `AgentSecretAccess`: gatekeeper for per-session secret access. +- `SecretPolicy`: per-secret policy metadata (currently default only). +- `SecretBytes`: zeroizing wrapper for secret material. + +### Example: verify then access secrets + +```rust +use agent_secrets::{ + AgentSecretAccess, AgentVerifier, SecretError, SecretPolicy, SessionContext, +}; + +struct AllowVerifier; + +impl AgentVerifier for AllowVerifier { + fn verify(&self, session: &SessionContext) -> Result<(), SecretError> { + if session.verified { + Ok(()) + } else { + Err(SecretError::NotAuthorized) + } + } +} + +fn main() -> Result<(), SecretError> { + let store = agent_secrets::default_store(); + let verifier = AllowVerifier; + let access = AgentSecretAccess::new(store.as_ref(), &verifier); + + let mut session = SessionContext::new("agent-1", "session-1"); + session.verified = true; + + access.put_for_session(&session, "secops/token", b"secret", SecretPolicy::default())?; + let secret = access.get_for_session(&session, "secops/token")?; + let value = secret.expose(|bytes| bytes.to_vec()); + + println!("secret len: {}", value.len()); + Ok(()) +} +``` + +### SessionContext fields + +`SessionContext` includes: + +- `agent_id`: logical agent identifier +- `session_id`: per-session identifier +- `verified`: whether verification succeeded +- `claims`: list of DID/VC claims + +Your verifier should set `verified` after validating a DID/VC presentation. + +## Python API + +The Python bindings are provided by `shadi_py` and expose `ShadiStore`, +`PySessionContext`, `SqlCipherMemoryStore`, `SandboxPolicyHandle`, and +`run_sandboxed`. + +### Installing the bindings + +Install the extension module into the current Python environment with maturin: + +```bash +uv run maturin develop --manifest-path crates/shadi_py/Cargo.toml +``` + +For Rust-only builds, use: + +```bash +just build +``` + +### Example: Python secret access + +```python +from shadi import ShadiStore, PySessionContext + +store = ShadiStore() + +# Provide a verification callback. +# It receives: agent_id, session_id, presentation_bytes, claims. +store.set_verifier(lambda agent_id, session_id, presentation, claims: True) + +session = PySessionContext("agent-1", "session-1") +store.verify_session(session, b"didvc-presentation") + +store.put(session, "secops/token", b"secret") +print(store.get(session, "secops/token")) +store.delete(session, "secops/token") +``` + +### Example: integrate with an app session + +```python +from shadi import ShadiStore, PySessionContext + +def verify_didvc(agent_id, session_id, presentation, claims): + # Replace with real DID/VC validation. + return True + +store = ShadiStore() +store.set_verifier(verify_didvc) + +session = PySessionContext("agent-1", "session-1") +ok = store.verify_session(session, b"presentation-bytes") +if not ok: + raise RuntimeError("verification failed") + +# Secrets are now gated by the verified session. +store.put(session, "app/config", b"value") +``` + +### Verification flow + +- `set_verifier(callable)` installs a DID/VC verifier callback. +- `verify_session(session, presentation)` calls the verifier and sets + `session.verified = True` if the callback returns truthy. +- `put/get/delete/list_keys` require a verified session. + +### Example: SQLCipher memory (Python) + +```python +from shadi import SqlCipherMemoryStore + +store = SqlCipherMemoryStore( + db_path="./.tmp/shadi-secops/secops_memory.db", + key_name="secops/memory_key", +) + +store.put("secops", "security_report", "{\"status\":\"ok\"}") +latest = store.get_latest("secops", "security_report") +print(latest.payload if latest else "no entry") +``` + +### Example: sandboxed execution (Python) + +```python +from shadi import SandboxPolicyHandle, run_sandboxed + +policy = SandboxPolicyHandle() +policy.allow_read_path(".") +policy.allow_write_path("./.tmp") +policy.block_network(False) + +run_sandboxed(["/usr/bin/env", "python", "-V"], policy) +``` + +## Key provisioning for agents + +Use `shadictl` to ingest OpenPGP keys and derive agent DIDs without invoking +OS `gpg`: + +```bash +cargo run -p shadictl -- \ + put-key --key human/gpg --in /path/to/human-secret.asc + +cargo run -p shadictl -- \ + derive-agent-did --secret human/gpg --name agent-a --prefix agents +``` + +Generated keys and DID artifacts are stored under: + +- `agents/agent-a/private` +- `agents/agent-a/public` +- `agents/agent-a/did` +- `agents/agent-a/diddoc` + +## End-to-end flow: GitHub GPG identity to agent DIDs + +This flow starts from a human's GitHub GPG identity and produces a collection +of agent DIDs and key material stored in the SHADI secret store. + +1) Fetch the public OpenPGP key from GitHub and create a DID document: + +```bash +cargo run -p shadictl -- \ + did-from-github --user alice --out human.did.json +``` + +This stores the human DID and DID document under: + +- `github/alice/did` +- `github/alice/diddoc` + +2) Import the human OpenPGP secret key locally (private material never comes +from GitHub): + +```bash +cargo run -p shadictl -- \ + put-key --key human/gpg --in /path/to/human-secret.asc +``` + +3) Derive agent identities and keys from the human secret key: + +```bash +cargo run -p shadictl -- \ + derive-agent-did --secret human/gpg --name agent-a --prefix agents + +cargo run -p shadictl -- \ + derive-agent-did --secret human/gpg --name agent-b --prefix agents +``` + +Each derived agent writes keys and DID artifacts to: + +- `agents//private` +- `agents//public` +- `agents//did` +- `agents//diddoc` + +4) In your app, verify sessions using the human DID/VC and then grant secrets +access to the agent sessions. + +## Secret store naming in Rust vs Python + +Rust and Python use the same underlying SHADI secret store. The "name" you see +in examples is just the secret key string (for example, `secops/token` or +`agents/agent-a/did`). There is no separate store per language. + +If you see different names between Rust and Python examples, it is only a +convention difference. Pick a shared key naming scheme and use it consistently +across both runtimes. + +## Sandbox integration (fundamental) + +The sandbox launcher is a core SHADI feature. Agents should run under an OS +sandbox by default to enforce least-privilege execution. + +Invoke `shadictl` with policy flags and a command to execute: + +```bash +cargo run -p shadictl -- \ + --allow . \ + --read / \ + --net-block \ + -- \ + ./your-agent --arg value +``` + +For Python agents, you can run under the sandbox using the JSON policy runner: + +```bash +./.venv/bin/python tools/run_sandboxed_agent.py \ + --policy policies/demo/secops-a.json \ + -- ./.venv/bin/python agents/secops/a2a_server.py +``` + +`net_allow` in the policy file is enforced by the Python runner using a +best-effort network guard (not OS-enforced). + +If keychain access is restricted in the sandbox, broker a secret outside the +sandbox and inject it as an environment variable: + +```bash +cargo run -p shadictl -- \ + --inject-keychain secops/token=GITHUB_TOKEN \ + -- \ + ./your-agent +``` + +## Error handling + +Rust APIs return `SecretError`: + +- `NotAuthorized`: session verification failed +- `InvalidInput`: key does not exist or malformed input +- `StorageFailure`: backend failure or OS error + +In Python, these errors are reported as `RuntimeError` with the same message. + +## Recommended integration steps + +1. Generate or import a human OpenPGP key and store it with `put-key`. +2. Derive agent DIDs and keypairs with `derive-agent-did`. +3. Implement a DID/VC verification callback in your app. +4. Set `session.verified = true` only after verification succeeds. +5. Use `AgentSecretAccess` (Rust) or `ShadiStore` (Python) to access secrets. +6. Run the agent under a sandbox policy if needed. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5ff6cce --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,257 @@ +# Architecture + +SHADI (Secure Host Agentic AI Dynamic Instantiation) is a secure host runtime for +interactive, autonomous agents. It combines secure secret storage, verified +identity, and kernel-enforced sandboxing to reduce the blast radius of agent +actions and prevent unauthorized data access or exfiltration. + +## Goals +- Run dynamic, interactive agents with least-privilege access. +- Protect secrets at rest and limit access to verified sessions. +- Enforce kernel-level restrictions so unauthorized operations are blocked by the OS. +- Provide secure, low-latency agent-to-agent messaging via MLS. +- Keep platform support across macOS, Windows, and mobile targets. + +## Non-goals +- Protect against a fully compromised host OS or kernel. +- Provide complete metadata privacy for network traffic in v1. +- Replace upstream MLS or OS keystore implementations. + +## Core components + +### 1) Secrets layer +- **OS keystores**: Keychain (macOS/iOS), DPAPI/CNG (Windows), Keystore (Android), + Secret Service (Linux). +- **Access control**: Secrets are accessed only after DID/VC verification. +- **Memory safety**: Secrets are wrapped in `SecretBytes` and zeroized on drop. +- **OpenPGP parsing**: `shadictl` uses `sequoia-openpgp` to ingest keys without calling OS `gpg`. + +#### Key modules +- `crates/agent_secrets/src/lib.rs`: `SecretStore` trait, errors, and default store. +- `crates/agent_secrets/src/agent.rs`: `AgentSecretAccess` gates reads/writes on verification. +- `crates/agent_secrets/src/memory.rs`: `SecretBytes` zeroization wrapper. +- `crates/agent_secrets/src/platform`: platform keychain backends. +- `crates/agent_secrets/src/platform/macos.rs`: Keychain storage + key registry for listing. +- `crates/shadictl/src/main.rs`: OpenPGP ingestion, DID derivation, and secret store helpers. + +### 1b) Identity derivation and provenance +- **Deterministic derivation**: Agent keys are derived from human identity material + through a fixed KDF pipeline. +- **KDF details**: HKDF-SHA256 with salt `shadi-agent-derive`, IKM from human + source bytes (`gpg` or `seed`), and `agent_name` as HKDF info. +- **Output model**: 32-byte Ed25519 seed -> local agent keypair -> `did:key` DID document. +- **Provenance binding**: Optional `{prefix}/{agent}/human_did` binding allows + explicit linkage from derived agent identity back to a stored human DID. +- **Verification command**: `verify-agent-identity` recomputes derived key + DID + and compares with stored values; can also enforce human DID binding checks. + +#### Key modules +- `crates/shadictl/src/main.rs`: `derive-agent-did`, `derive-agent-identity`, + `verify-agent-identity`, and derivation helpers. + +### 2) Sandbox layer +- **macOS**: Seatbelt profile enforcement for filesystem and network policies. +- **Windows**: AppContainer + ACL allowlists + Job Objects (kill-on-close). +- **CLI**: `shadi` provides command blocklists and a JSON policy format. +- **Portable launcher model**: `shadi` supports built-in profiles + (`strict`, `balanced`, `connected`) for portable secure launch defaults. + +#### Key modules +- `crates/shadi_sandbox/src/policy.rs`: policy model and helpers. +- `crates/shadi_sandbox/src/platform`: OS-specific sandbox enforcement. +- `crates/shadictl/src/main.rs`: CLI parsing, policy resolution, and key listing. + +### 3) Memory layer +- **Local encrypted store**: SQLCipher-backed SQLite for portable, on-device memory. +- **Key management**: Encryption keys live in SHADI secrets (keychain backed). +- **Agent usage**: SecOps writes summaries to the encrypted store; ADK memory remains in-process unless configured for persistent backends. + +#### Key modules +- `crates/shadi_memory/src/lib.rs`: SQLCipher store and query helpers. +- `crates/shadi_memory/src/main.rs`: shadi-memory CLI. +- `crates/shadictl/src/main.rs`: `shadictl memory` helper. +- `crates/shadi_py/src/lib.rs`: SQLCipher bindings. +- `agents/secops/skills.py`: SecOps summary persistence. + +### 4) Transport layer +- **SLIM/A2A**: MLS provides confidentiality and integrity between agents. +- **Verified sessions**: Messages are only sent/received after DID/VC checks. + +#### Key modules +- `crates/agent_transport_slim/src/lib.rs`: transport adapter and verifier gating. + +### 5) Brokered secret injection (optional) +- If sandbox rules prevent keystore access, secrets can be brokered outside the + sandbox and injected as environment variables into the agent process. + +#### Key modules +- `crates/shadictl/src/main.rs`: `--inject-keychain` and policy enforcement. + +## Python bindings +SHADI exposes a Python extension for secrets, SQLCipher memory, and sandbox +execution. + +#### Key modules +- `crates/shadi_py/src/lib.rs`: `ShadiStore`, `PySessionContext`, + `SqlCipherMemoryStore`, `SandboxPolicyHandle`, and `run_sandboxed` bindings. + +#### Python API surface +- `ShadiStore.set_verifier(callable)` to supply DID/VC verification logic. +- `ShadiStore.verify_session(session, presentation)` to set verified sessions. +- `ShadiStore.get/put/delete` for secret access. +- `ShadiStore.list_keys` to enumerate stored keys (backed by key registry on macOS). +- `SqlCipherMemoryStore` for encrypted local memory. +- `SandboxPolicyHandle` + `run_sandboxed` for sandbox execution. + +## CLI policy resolution +The CLI combines profile defaults, policy file settings, and explicit flags: +- Profile defaults are loaded first (`balanced` by default). +- Policy file values are merged next. +- CLI flags override or extend resulting policy. +- The effective policy can be printed with `--print-policy`. + +## SecOps agent architecture +The SecOps agent runs locally under SHADI and uses the Python bindings for secrets +plus GitHub APIs for security signals. + +#### Key modules +- `agents/secops/skills.py`: skills to collect alerts and issues. +- `agents/secops/secops.py`: runner invoking the skill. +- `agents/secops/adk_agent/agent.py`: Google ADK agent. +- `agents/secops/SKILL.md`: Agent Skills spec metadata and runbook. + +#### SecOps flow +1. Read config from secops.toml. +2. Fetch GitHub token and workspace path from SHADI. +3. Collect Dependabot alerts and security-labeled issues. +4. Write `secops_security_report.json` to the workspace. + +## Data flow (high level) +1. Human identity material is ingested (OpenPGP or seed bytes). +2. Agent local keypair + `did:key` are deterministically derived and stored. +3. Optional human DID binding is stored for provenance checks. +4. Agent session starts and performs DID/VC verification. +5. SHADI sandbox applies OS-level restrictions to the agent process. +6. Agent requests secrets; access is granted only if verification succeeds. +7. Agent communicates over SLIM/MLS; messages are encrypted end-to-end. + +```mermaid +sequenceDiagram + participant Human + participant Agent + participant SHADI + participant Sandbox + participant Keystore + participant MemoryStore + participant SLIM + + Human->>SHADI: Provide source identity material + SHADI->>SHADI: HKDF derive agent local key + did:key + SHADI->>Keystore: Store agent keys + DID (+ optional human binding) + Agent->>SHADI: Start session + DID/VC + SHADI->>Sandbox: Apply OS policy + Agent->>SHADI: Secret request + SHADI->>Keystore: Read secret (if verified) + Keystore-->>SHADI: Secret bytes + SHADI-->>Agent: Secret handle + Agent->>MemoryStore: Write encrypted summary (SQLCipher) + MemoryStore-->>Agent: Persisted entry id + Agent->>SLIM: Send MLS message + SLIM-->>Agent: Encrypted transport +``` + +```mermaid +flowchart LR + A[Profile Defaults] --> B[Sandbox Policy] + A2[Policy JSON] --> B + A3[CLI Flags] --> B + B --> C[macOS Seatbelt] + B --> D[Windows AppContainer + ACL] + H0[Human Identity Source] --> H1[HKDF Derivation] + H1 --> H2[Agent Local Keypair] + H2 --> H3[did:key] + H3 --> G + E[Session Verification] --> F[Secrets Access] + F --> G[OS Keystore] + G --> J[Memory Key] + J --> K[SQLCipher Memory Store] + E --> K + H[SLIM/MLS] --> I[Agent-to-Agent Secure Channel] +``` + +## Threats addressed + +### Secrets and credential theft +- **Stopped**: Reading secrets without verification. +- **Stopped**: Exfiltration of secrets from disk when keystore access is denied. +- **Stopped**: Accidental logging of secrets by non-verified sessions. +- **Mitigated**: In-memory exposure via zeroization and limited lifetime. + +### Identity spoofing / provenance ambiguity +- **Stopped**: Undetected key substitution when `verify-agent-identity` is used. +- **Mitigated**: Agent ownership ambiguity via stored `human_did` binding + verification. + +### Filesystem abuse +- **Stopped**: Accessing paths outside allowlists (kernel enforcement). +- **Stopped**: Writing to disallowed paths in sandboxed processes. +- **Mitigated**: Destructive commands via CLI blocklist. + +### Network abuse +- **Stopped**: Network access when `net_block` is enabled. +- **Mitigated**: Unapproved endpoints with best-effort `net_allow` guard for + Python sandbox runners. + +### Agent-to-agent data leakage +- **Stopped**: Unauthorized peers reading messages; MLS provides confidentiality. +- **Stopped**: Message tampering; MLS provides integrity/authentication. + +### Privilege escalation +- **Stopped**: Running blocked commands via CLI blocklist. +- **Mitigated**: Kernel-level constraints remain even if the agent tries to evade. + +## Threat-to-control mapping + +| Threat | Control | Outcome | +| --- | --- | --- | +| Unverified secret access | DID/VC verifier + `AgentSecretAccess` | Blocked | +| Secret theft at rest | OS keystore storage | Blocked | +| Secret exfiltration in sandbox | OS sandbox + net block | Blocked | +| Unauthorized file access | Seatbelt/AppContainer allowlists | Blocked | +| Destructive commands | CLI blocklist | Blocked | +| Message interception | MLS in SLIM | Blocked | +| Message tampering | MLS integrity | Blocked | +| Agent identity substitution | HKDF derivation + verify-agent-identity | Blocked (with verification) | +| Process escape | Kernel enforcement | Mitigated | + +## Residual risks +- Host OS compromise or kernel-level malware can bypass sandbox controls. +- Metadata leakage (timing, sizes, endpoints) is not fully addressed in v1. +- ACL changes on Windows could be interrupted before rollback in a crash. + +## Deployment guidance +- Prefer running agents under the sandbox with a JSON policy file. +- Use brokered secrets for demos, and keystore access for production where allowed. +- Rotate secrets and use short-lived tokens whenever possible. + +## Policy examples + +### Minimal sandbox +```json +{ + "allow": ["."], + "net_block": true +} +``` + +### Read-only project +```json +{ + "read": ["./src"], + "net_block": true +} +``` + +## Future work +- Signed policy files with provenance (Sigstore/DSSE). +- OS-enforced network allowlist/denylist policies. +- Stronger metadata privacy controls. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..ee75fdc --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,377 @@ +# CLI Reference + +This page documents the command-line tools shipped with SHADI: + +- `shadictl` (binary name `shadi`): sandbox runner and key management. +- `shadi-memory` (binary name `shadi-memory`): SQLCipher-backed encrypted store. +- `slim-mas` (binary name `slim-mas`): SLIM multi-agent moderator helper. + +Most agents use the Python bindings (`shadi` module) for secrets, SQLCipher +memory, and sandbox execution. The CLIs remain useful for ops and debugging. + +## shadictl (`shadi`) + +`shadictl` is the main CLI for running sandboxed commands, printing policy, and +managing OpenPGP keys and DIDs. In development, run it with: + +```bash +cargo run -p shadictl -- [FLAGS] -- [COMMAND] +``` + +### Global flags + +- `--policy FILE`: JSON policy file to load before flags are applied. +- `--allow PATH`: Allow read+write under PATH (can be repeated). +- `--read PATH`: Allow read-only access under PATH (can be repeated). +- `--write PATH`: Allow write access under PATH (can be repeated). +- `--net-block`: Block network access. +- `--allow-command CMD`: Allow a command that is blocked by default (repeatable). +- `--inject-keychain KEY=ENV`: Read a secret and inject it as an env var before launch (repeatable). +- `--list-keychain`: List secrets in the SHADI store. +- `--list-prefix PREFIX`: Optional prefix filter for `--list-keychain`. +- `--print-policy`: Print the resolved policy and exit. + +### Sandbox execution + +Run a command inside the sandbox after flags: + +```bash +cargo run -p shadictl -- \ + --allow . \ + --read / \ + --net-block \ + -- \ + ./your-agent --arg value +``` + +Print the effective policy after merging JSON and flags: + +```bash +cargo run -p shadictl -- --policy ./sandbox.json --print-policy +``` + +Inject a secret into the command environment (brokered secrets): + +```bash +cargo run -p shadictl -- \ + --inject-keychain secops/token=GITHUB_TOKEN \ + -- \ + ./your-agent +``` + +### Key, DID, and identity provenance + +#### Cryptographic derivation model + +Agent identities are deterministically derived from human identity material using +an HKDF-based pipeline: + +- KDF: `HKDF-SHA256` +- Salt: `"shadi-agent-derive"` +- Input keying material (IKM): bytes from the selected human identity source + (`gpg` secret material or `seed` bytes) +- Info: `agent_name` bytes +- Output key bytes: 32-byte Ed25519 private key seed + +The derived Ed25519 public key is converted into a `did:key` DID document. +This is the same derivation path used by `derive-agent-did` and +`derive-agent-identity`. + +Create a DID document from an OpenPGP public key file: + +```bash +cargo run -p shadictl -- \ + did-from-gpg \ + --in /path/to/human-public.asc \ + --out human.did.json +``` + +Fetch an OpenPGP key from GitHub and create a DID document: + +```bash +cargo run -p shadictl -- \ + did-from-github \ + --user octocat \ + --out github.did.json +``` + +Store an OpenPGP secret key in the SHADI secret store: + +```bash +cargo run -p shadictl -- \ + put-key \ + --key human/gpg \ + --in /path/to/human-secret.asc +``` + +Derive an agent DID and keypair from a human OpenPGP secret key: + +```bash +cargo run -p shadictl -- \ + derive-agent-did \ + --secret human/gpg \ + --name agent-a \ + --prefix agents \ + --out agent-a.did.json +``` + +Automate identity creation for one or more agents from a human identity source +(`gpg` or generic `seed`) using the same deterministic local-key to `did:key` +derivation pipeline: + +```bash +cargo run -p shadictl -- \ + derive-agent-identity \ + --source gpg \ + --human-secret human/gpg \ + --name agent-a \ + --name agent-b \ + --prefix agents \ + --out-dir ./agent-dids +``` + +For non-GPG identities, store source material in SHADI and use `--source seed`: + +```bash +cargo run -p shadictl -- \ + derive-agent-identity \ + --source seed \ + --human-secret human/seed \ + --name agent-c \ + --prefix agents +``` + +If you already store the human DID, bind derived identities to it: + +```bash +cargo run -p shadictl -- \ + derive-agent-identity \ + --source gpg \ + --human-secret human/gpg \ + --human-did-key humans/alice/did \ + --name agent-a \ + --prefix agents +``` + +Verify that a stored agent identity belongs to a human source by recomputing +the key and DID from the same derivation pipeline: + +```bash +cargo run -p shadictl -- \ + verify-agent-identity \ + --source gpg \ + --human-secret human/gpg \ + --name agent-a \ + --prefix agents +``` + +Require verification of stored human binding: + +```bash +cargo run -p shadictl -- \ + verify-agent-identity \ + --source gpg \ + --human-secret human/gpg \ + --name agent-a \ + --prefix agents \ + --human-did-key humans/alice/did \ + --require-human-binding +``` + +Avoid printing secret values. Use `--list-keychain` for inventory and pass key +names to commands that resolve secrets inside SHADI. + +### Key storage layout + +`derive-agent-did` writes the following entries under the prefix: + +- `{prefix}/{agent}/private` (base64-encoded Ed25519 private key) +- `{prefix}/{agent}/public` (base64-encoded Ed25519 public key) +- `{prefix}/{agent}/did` (DID string) +- `{prefix}/{agent}/diddoc` (DID document JSON) + +`derive-agent-identity` writes the same entries for each `--name` and also +stores `{prefix}/{agent}/human_did` when `--human-did-key` is provided. + +## shadictl memory (`shadictl memory`) + +`shadictl memory` proxies SQLCipher memory access while resolving the key from +the SHADI secret store (no key material is printed). + +### Commands + +Initialize a store: + +```bash +cargo run -p shadictl -- -- memory init \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key +``` + +Put a memory entry from inline payload or file: + +```bash +cargo run -p shadictl -- -- memory put \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + --scope secops --entry-key report --payload '{"status":"ok"}' +``` + +```bash +cargo run -p shadictl -- -- memory put \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + --scope secops --entry-key report --payload-file ./report.json +``` + +Get the latest entry: + +```bash +cargo run -p shadictl -- -- memory get \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + --scope secops --entry-key report +``` + +Search entries: + +```bash +cargo run -p shadictl -- -- memory search \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + --scope secops --query dependabot --limit 10 +``` + +List entries: + +```bash +cargo run -p shadictl -- -- memory list \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + --scope secops --limit 50 +``` + +Delete an entry: + +```bash +cargo run -p shadictl -- -- memory delete \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + --scope secops --entry-key report +``` + +## shadi-memory (`shadi-memory`) + +`shadi-memory` manages SQLCipher-backed encrypted memory entries. +Prefer `shadictl memory` when you want keys resolved from SHADI without +exporting them, or the Python bindings (`SqlCipherMemoryStore`) in apps. + +### Global flags + +- `--db PATH`: Path to the SQLCipher database file. +- `--key VALUE`: Raw SQLCipher key value (optional). +- `--key-name NAME`: Secret store key name for the SQLCipher key (default `shadi/memory/sqlcipher_key`). + +### Commands + +Initialize a store: + +```bash +cargo run -p shadi_memory -- \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + init +``` + +Put a memory entry from inline payload or file: + +```bash +cargo run -p shadi_memory -- \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + put --scope secops --entry-key report --payload '{"status":"ok"}' +``` + +```bash +cargo run -p shadi_memory -- \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + put --scope secops --entry-key report --payload-file ./report.json +``` + +Get the latest entry: + +```bash +cargo run -p shadi_memory -- \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + get --scope secops --entry-key report +``` + +Search entries: + +```bash +cargo run -p shadi_memory -- \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + search --scope secops --query dependabot --limit 10 +``` + +List entries: + +```bash +cargo run -p shadi_memory -- \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + list --scope secops --limit 50 +``` + +Delete an entry: + +```bash +cargo run -p shadi_memory -- \ + --db "${SHADI_TMP_DIR:-./.tmp}/shadi-memory.db" \ + --key-name shadi/memory/sqlcipher_key \ + delete --scope secops --entry-key report +``` + +## slim-mas (`slim-mas`) + +`slim-mas` evaluates SLIM multi-agent membership rules from a TOML config. + +### Global flags + +- `--config FILE`: Path to the MAS config (default `mas.toml`). + +### Commands + +List available groups: + +```bash +cargo run -p slim_mas -- list-groups +``` + +List members for a group: + +```bash +cargo run -p slim_mas -- list-members --group team-a +``` + +Validate config (ensures a default group exists): + +```bash +cargo run -p slim_mas -- validate +``` + +Admit or deny a member: + +```bash +cargo run -p slim_mas -- \ + admit --group team-a --did did:key:human --role human +``` + +Exit codes: + +- `0`: allow / success +- `3`: deny (member not allowed) +- `2`: error diff --git a/docs/demo.md b/docs/demo.md new file mode 100644 index 0000000..cf61aa1 --- /dev/null +++ b/docs/demo.md @@ -0,0 +1,83 @@ +# Demo walkthrough + +This walkthrough shows a real, end-to-end SLIM A2A demo: two SecOps agents and +one human (Avatar) in the same group channel, backed by a local SLIM node. + +## 1) Start a local SLIM node + +Use the launcher to start the local SLIM instance configured for the demo: + +```bash +just launch-slim-example +``` + +## 2) Seed SLIM shared secret in SHADI + +Both SecOps agents and the Avatar agent use the same shared secret stored in +SHADI. Import it once using the bootstrap script: + +```bash +export SHADI_OPERATOR_PRESENTATION="local-operator" +export SLIM_SHARED_SECRET="$(openssl rand -hex 32)" + +just import-secops-secrets +``` + +## 3) Run two SecOps agents on the same channel + +The demo ships per-agent configs under `./.tmp`. Start each agent in its own +terminal: + +```bash +just launch-secops-a2a-example +``` + +To start a second agent, set `SHADI_AGENT_ID=secops-b` and point +`SHADI_SECOPS_CONFIG` to `./.tmp/secops-b.toml` before running the launcher. + +## 4) Connect as a human using the Avatar ADK agent + +```bash +just launch-avatar-example +``` + +In the Avatar prompt, ask for actions like: + +``` +scan dependabot for the allowlist +report +``` + +## 5) Key and DID utilities + +SHADI can ingest OpenPGP keys without shelling out to `gpg`. Store a human +OpenPGP secret key, then derive an agent DID and keypair in the secret store: + +```bash +cargo run -p shadictl -- \ + put-key --key human/gpg --in /path/to/human-secret.asc + +cargo run -p shadictl -- \ + derive-agent-did \ + --secret human/gpg \ + --name agent-a \ + --prefix agents \ + --out agent-a.did.json +``` + +You can also create a DID document from a public OpenPGP key file: + +```bash +cargo run -p shadictl -- \ + did-from-gpg --in /path/to/human-public.asc --out human.did.json +``` + +Notes: +- Keys and DIDs are stored in the SHADI secret store. +- OpenPGP parsing uses `sequoia-openpgp`, not the OS `gpg` binary. + +## Notes +- The SecOps A2A servers and Avatar agent share the same SLIM endpoint and + shared secret in SHADI. +- Adjust `secops.toml` or the per-agent configs if you want different identities + or endpoints. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..468e375 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,16 @@ +# Design Overview + +The workspace contains several crates: +- agent_secrets: secret storage and access control +- shadictl: CLI for sandboxing, key management, and policy resolution +- shadi_memory: SQLCipher-backed encrypted memory store +- shadi_sandbox: OS sandbox enforcement layer +- shadi_py: Python bindings for secrets, SQLCipher memory, and sandbox execution +- slim_mas: SLIM multi-agent moderator CLI +- agent_transport_slim: transport adapter for SLIM/A2A + +Platform-specific backends live under agent_secrets/src/platform. + +## Documentation +The documentation site is built with MkDocs. See docs/README.md for running it +locally. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ef7664c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,30 @@ +# SHADI + +Secure Host Agentic AI Dynamic Instantiation (SHADI) is a host runtime designed +for autonomous, multi-agent systems running on end devices. It is critical in +this setting because agents are long-lived, operate with real credentials, and +run on machines that can access sensitive local data. SHADI reduces the blast +radius of mistakes or compromise by enforcing identity checks, secrets access +gating, and OS-level restrictions. + +### Why it matters on end devices + +- **Least-privilege execution**: sandbox policies limit filesystem and network access. +- **Verified identity**: agents must pass DID/VC checks before touching secrets. +- **Local secrets hygiene**: secrets live in OS keystores and are zeroized in memory. +- **Secure agent-to-agent transport**: MLS-backed messaging protects content in transit. +- **Durable local memory**: SQLCipher-backed storage keeps contexts and long-term memory secure at rest. + +### What SHADI provides + +- Secure secrets storage for agents across platforms. +- OpenPGP key ingestion and DID derivation via `shadictl` (no OS `gpg` dependency). +- Deterministic human->agent identity derivation (HKDF-SHA256) with verifiable provenance. +- MLS-backed secure agent messaging via SLIM/A2A integration. +- Kernel-enforced sandboxing for agent processes. +- Portable profile-based secure launcher defaults (`strict`, `balanced`, `connected`). +- Encrypted local memory via SQLCipher. +- Python bindings for secrets, SQLCipher memory, and sandbox execution. +- A SecOps agent for security monitoring and reporting. + +Use the navigation to find setup, security, CLI, and integration details. diff --git a/docs/mobile_integration.md b/docs/mobile_integration.md new file mode 100644 index 0000000..0f8c3ae --- /dev/null +++ b/docs/mobile_integration.md @@ -0,0 +1,5 @@ +# Mobile Integration + +The Rust core can be exposed through FFI to Swift and Kotlin. A thin binding +layer will provide platform-native ergonomic APIs while delegating storage to +OS keystores. diff --git a/docs/sandbox.md b/docs/sandbox.md new file mode 100644 index 0000000..fc1dec7 --- /dev/null +++ b/docs/sandbox.md @@ -0,0 +1,135 @@ +# SHADI Sandbox (MVP) + +SHADI includes a kernel-enforced sandbox launcher to run agent processes with a +restricted capability set. The initial implementation targets macOS using the +Seatbelt sandbox APIs. Windows uses AppContainer + ACL-based allowlists with +optional network capability toggles, plus Job Objects to ensure child processes +are terminated with the parent. + +## CLI + +```bash +cargo run -p shadictl -- \ + --allow . \ + --net-block \ + -- \ + ./your-agent --arg value +``` + +### Portable launcher profiles (no shell wrapper) + +`shadictl` now has a built-in policy profile model so you can launch securely +without platform-specific Bash/PowerShell wrappers: + +```bash +cargo run -p shadictl -- --profile strict -- -- ./your-agent +``` + +Profiles: +- `strict`: local workspace only, network blocked. +- `balanced` (default): workspace + system reads, network blocked. +- `connected`: workspace + system reads, network allowed. + +### Starter profile matrix + +Use this matrix as a baseline when selecting a profile: + +| Workload | Recommended profile | Why | +| --- | --- | --- | +| Local processing agent (no network calls) | `strict` | Smallest blast radius and full network block. | +| Typical development agent (reads toolchain/system paths) | `balanced` | Keeps network off while allowing common runtime reads. | +| API-integrated agent (GitHub/LLM calls) | `connected` | Enables network while keeping filesystem policy centralized. | + +Then tighten with explicit path flags (`--allow`, `--read`, `--write`) and only open command exceptions with `--allow-command` when strictly required. + +Print the resolved profile policy: + +```bash +cargo run -p shadictl -- --profile balanced --print-policy +``` + +### JSON Policy + +You can pass a JSON policy file to avoid long CLI arguments: + +```json +{ + "allow": ["."], + "read": ["/opt/homebrew"], + "write": ["./output"], + "net_block": true, + "net_allow": ["api.github.com", "127.0.0.1"], + "allow_command": ["rm"], + "block_command": ["curl"] +} +``` + +Run it with: + +```bash +cargo run -p shadictl -- --policy ./sandbox.json -- ./your-agent +``` + +CLI flags override policy file settings. Paths are canonicalized before use. +Profile defaults are applied first, then policy file values, then CLI flags. + +### Flags +- `--policy FILE`: Load policy settings from a JSON file. +- `--profile PROFILE`: Built-in launcher profile (`strict`, `balanced`, `connected`). +- `--allow PATH`: Allow read+write under the path. +- `--read PATH`: Allow read-only access under the path. +- `--write PATH`: Allow write access under the path. +- `--net-block`: Block network access. +- `--allow-command CMD`: Override default command blocklist. +- `--inject-keychain KEY=ENV`: Read a keychain secret and inject it as an env var before sandboxing. + +`net_allow` is honored by the Python sandbox runner. It injects a `sitecustomize.py` hook that blocks connections outside the allowlist (best-effort; not OS-enforced). + +### Key utilities +`shadictl` also manages OpenPGP keys and agent DIDs without invoking OS `gpg`: + +```bash +cargo run -p shadictl -- \ + put-key --key human/gpg --in /path/to/human-secret.asc + +cargo run -p shadictl -- \ + derive-agent-did --secret human/gpg --name agent-a --prefix agents +``` + +## Brokered secrets + +Keychain access is often restricted inside a sandbox. You can broker secrets by +reading them before sandboxing and injecting them as environment variables: + +```bash +cargo run -p shadictl -- \ + --allow . \ + --read / \ + --net-block \ + --inject-keychain tourist_api_key=SHADI_BROKER_SECRET \ + -- \ + uv run agents/secops/secops.py +``` + +## Notes +- This is an MVP and uses a conservative Seatbelt profile. System paths required + to execute processes are allowed for read access. +- Command blocking is enforced before launch in the CLI. +- Windows: ACL allowlists are applied to the specified paths for the AppContainer + SID and automatically reverted when the sandboxed process exits. Network + access is controlled by AppContainer capabilities. + +## Windows integration test + +The Windows AppContainer sandbox has an opt-in integration test. Run it on +Windows with: + +```bash +SHADI_WINDOWS_INTEGRATION=1 cargo test -p shadi_sandbox +``` + +Or via Just: + +```bash +just windows-integration +``` diff --git a/docs/secops_agent.md b/docs/secops_agent.md new file mode 100644 index 0000000..be506eb --- /dev/null +++ b/docs/secops_agent.md @@ -0,0 +1,148 @@ +# SecOps Agent + +The SecOps agent runs locally under SHADI sandbox constraints and monitors +GitHub security signals for an allowlist of repositories. It writes a report +and can be extended to open remediation PRs. + +## Prerequisites +- SHADI Python extension installed in your `uv` environment. +- GitHub token stored in SHADI. +- Operator presentation set via `SHADI_OPERATOR_PRESENTATION`. + +## Configure +Configuration lives in secops.toml at the repo root: +- `secops.allowlist` +- `secops.token_key` +- `secops.workspace_key` +- `github.api_base` + +## Load secrets +```bash +source ~/.env-phoenix +export GITHUB_TOKEN="$(gh auth token)" +export SHADI_OPERATOR_PRESENTATION="local-operator" +uv run agents/secops/import_secops_secrets.py +``` + +If `SECOPS_MEMORY_KEY` is unset, the importer generates a key and stores it in +SHADI so it never leaves the secret store. + +## Run the agent +```bash +SHADI_OPERATOR_PRESENTATION="local-operator" uv run agents/secops/secops.py +``` + +The report is written to: +- `${SHADI_TMP_DIR:-./.tmp}/shadi-secops/secops_security_report.md` + +## Long-running operation +For continuous monitoring, run on a schedule and manage memory: + +### Short-term memory +- Keep recent alerts and tool state in process memory. + +### Long-term memory +- Persist summaries to the allowlisted workspace directory. +- Store remediation history in a local file or external store allowed by policy. +- The ADK agent uses `PreloadMemoryTool` and `load_memory` to recall prior runs. +- Sessions are saved to ADK memory automatically after each run. + +### Continuous runner +```python +import time + +POLL_SECONDS = 900 + +while True: + # invoke the skill or ADK agent + time.sleep(POLL_SECONDS) +``` + +Ensure the sandbox policy allows workspace read/write and network access to +GitHub and the ADK model endpoint. + +To use persistent ADK memory (Vertex AI Memory Bank), run the ADK agent with a +memory service URI: + +```bash +adk run agents/secops/adk_agent --memory_service_uri "agentengine://YOUR_ENGINE_ID" +``` + +## List secrets +```bash +cargo run -p shadictl -- --list-keychain --list-prefix secops/ +``` + +## Secret store helpers +`shadictl` can read secrets and store OpenPGP keys used for agent identity: + +```bash +cargo run -p shadictl -- put-key --key human/gpg --in /path/to/human-secret.asc +``` + +Avoid exporting secret values. For SQLCipher memory, use the helper below so +the key is resolved from SHADI without printing it. + +## List enforced policy +```bash +cargo run -p shadictl -- --policy policies/demo/secops-a.json --print-policy +``` + +## Skill definition +The skill lives in: +- agents/secops/SKILL.md + +To run via ADK: +```bash +uv pip install google-adk +adk run agents/secops/adk_agent +``` + +Local-only run with in-memory ADK memory service: + +```bash +uv run agents/secops/adk_agent/run_local.py +``` + +## Encrypted local memory (SQLCipher) +The SecOps agent uses the Python bindings (`SqlCipherMemoryStore`) for the +encrypted store. Use the helper below to inspect or seed entries without +exporting keys. + +```bash +cargo run -p shadictl -- -- memory init --db "$SHADI_SECOPS_MEMORY_DB" +``` + +Set the database path and write summaries from the SecOps skill: + +```bash +export SHADI_TMP_DIR="./.tmp" +export SHADI_SECOPS_MEMORY_DB="${SHADI_TMP_DIR}/${SHADI_AGENT_ID:-secops_agent}/shadi-secops/secops_memory.db" +``` + +Always reuse the same full path when listing or searching; using a relative +path like `secops_memory.db` will point to a different database. + +Store a summary via the CLI: + +```bash +cargo run -p shadictl -- -- memory put --db "$SHADI_SECOPS_MEMORY_DB" \ + --key-name secops/memory_key \ + --scope secops --entry-key security_report --payload '{"status":"ok"}' +``` + +Search memory: + +```bash +cargo run -p shadictl -- -- memory search --db "$SHADI_SECOPS_MEMORY_DB" \ + --key-name secops/memory_key \ + --scope secops --query "dependabot" +``` + +List memory: + +```bash +cargo run -p shadictl -- -- memory list --db "$SHADI_SECOPS_MEMORY_DB" \ + --key-name secops/memory_key \ + --scope secops +``` diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..653df5b --- /dev/null +++ b/docs/security.md @@ -0,0 +1,31 @@ +# Security Notes + +This library targets confidentiality for agent secrets at rest and in memory. +It does not assume a hostile OS for v1. + +OpenPGP key handling is performed in-process via `sequoia-openpgp` rather than +shelling out to `gpg`. + +## Agent key derivation + +SHADI derives local agent keys from human identity material using: + +- KDF: `HKDF-SHA256` +- Salt: `shadi-agent-derive` +- IKM: human source bytes (`gpg` material or generic seed bytes) +- Info: agent name + +The 32-byte HKDF output becomes an Ed25519 private key seed. SHADI stores the +derived public key and computes `did:key` from that key. + +## Human-to-agent linkage verification + +Use `shadictl verify-agent-identity` to recompute the expected agent key and +DID from the same human source and compare them to stored values. + +If a human DID binding is stored (`{prefix}/{agent}/human_did`), verification +can additionally assert that the binding matches a specific human DID key. + +## Non-goals +- Protecting against a fully compromised host OS. +- Metadata privacy beyond message content when using SLIM/MLS. diff --git a/docs/slim_a2a.md b/docs/slim_a2a.md new file mode 100644 index 0000000..91e398e --- /dev/null +++ b/docs/slim_a2a.md @@ -0,0 +1,9 @@ +# SLIM/A2A Integration + +This crate is designed to hook into the slima2a distribution and SLIM's MLS +stack without re-implementing MLS. + +## Intended flow +1. SLIM session setup verifies agent identity (DID/VC). +2. Secrets are accessed only after verification succeeds. +3. MLS provides content confidentiality between agents. diff --git a/examples/secops/import_secops_secrets.py b/examples/secops/import_secops_secrets.py new file mode 100644 index 0000000..e654292 --- /dev/null +++ b/examples/secops/import_secops_secrets.py @@ -0,0 +1,105 @@ +import os +import tomllib +from pathlib import Path + +from shadi import ShadiStore, PySessionContext + + +def load_secops_config(): + config_path = Path(os.getenv("SHADI_SECOPS_CONFIG", "secops.toml")) + if not config_path.exists(): + return config_path, {} + with config_path.open("rb") as handle: + return config_path, tomllib.load(handle) + + +def main(): + config_path, config = load_secops_config() + secops_config = config.get("secops", {}) + token_key = secops_config.get("token_key", "secops/github_token") + workspace_key = secops_config.get("workspace_key", "secops/workspace_dir") + tmp_dir = os.getenv("SHADI_TMP_DIR") + if tmp_dir: + workspace_dir = str(Path(tmp_dir) / "shadi-secops") + else: + workspace_dir = secops_config.get("workspace_dir", "./.tmp/shadi-secops") + llm_key_prefix = secops_config.get("llm_key_prefix", "secops/llm") + llm_provider = secops_config.get("llm_provider", "google") + slim_shared_secret_key = secops_config.get("slim_shared_secret_key", "secops/slim_shared_secret") + slim_local_did_key = secops_config.get("slim_local_did_key", "secops/slim_local_did") + slim_remote_did_key = secops_config.get("slim_remote_did_key", "secops/slim_remote_did") + + github_token = os.getenv("GITHUB_TOKEN", "").strip() + if not github_token: + raise RuntimeError( + "GITHUB_TOKEN is required. Set it with: export GITHUB_TOKEN=\"$(gh auth token)\"" + ) + + agent_id = os.getenv("SHADI_OPERATOR_AGENT_ID", "secops_agent") + presentation = os.getenv("SHADI_OPERATOR_PRESENTATION", "").encode("utf-8") + if not presentation: + raise RuntimeError("SHADI_OPERATOR_PRESENTATION must be set") + + store = ShadiStore() + session = PySessionContext(agent_id, "secops-bootstrap-1") + + def verify_operator(verify_agent_id, session_id, presentation_bytes, claims): + return verify_agent_id == agent_id and len(presentation_bytes) > 0 + + store.set_verifier(verify_operator) + ok = store.verify_session(session, presentation) + if not ok: + raise RuntimeError("SecOps verification failed") + + store.put(session, token_key, github_token.encode("utf-8")) + store.put(session, workspace_key, workspace_dir.encode("utf-8")) + + llm_env_map = { + "AZURE_OPENAI_API_KEY": "azure_openai_api_key", + "AZURE_OPENAI_API_VERSION": "azure_openai_api_version", + "AZURE_OPENAI_ENDPOINT": "azure_openai_endpoint", + "AZURE_OPENAI_DEPLOYMENT_NAME": "azure_openai_deployment_name", + "CLAUDE_API_KEY": "claude_api_key", + "CLAUDE_ENDPOINT": "claude_endpoint", + "CLAUDE_MODEL": "claude_model", + "GOOGLE_API_KEY": "google_api_key", + "GOOGLE_ENDPOINT": "google_endpoint", + "GOOGLE_MODEL": "google_model", + } + missing = [] + for env_key, suffix in llm_env_map.items(): + value = os.getenv(env_key, "").strip() + if value: + store.put(session, f"{llm_key_prefix}/{suffix}", value.encode("utf-8")) + print("Stored LLM secret in SHADI:", f"{llm_key_prefix}/{suffix}") + else: + missing.append(env_key) + + store.put(session, f"{llm_key_prefix}/provider", llm_provider.encode("utf-8")) + print("Stored LLM provider in SHADI:", f"{llm_key_prefix}/provider") + + slim_shared_secret = os.getenv("SLIM_SHARED_SECRET", "").strip() + if slim_shared_secret: + store.put(session, slim_shared_secret_key, slim_shared_secret.encode("utf-8")) + print("Stored SLIM shared secret in SHADI:", slim_shared_secret_key) + + slim_local_did = os.getenv("SLIM_LOCAL_DID", "").strip() + if slim_local_did: + store.put(session, slim_local_did_key, slim_local_did.encode("utf-8")) + print("Stored SLIM local DID in SHADI:", slim_local_did_key) + + slim_remote_did = os.getenv("SLIM_REMOTE_DID", "").strip() + if slim_remote_did: + store.put(session, slim_remote_did_key, slim_remote_did.encode("utf-8")) + print("Stored SLIM remote DID in SHADI:", slim_remote_did_key) + + print("Stored GitHub token in SHADI:", token_key) + print("Stored workspace dir in SHADI:", workspace_key) + print("Using config:", config_path) + if missing: + print("Missing LLM env vars:", ", ".join(missing)) + print("Hint: source ~/.env-phoenix before running this script") + + +if __name__ == "__main__": + main() diff --git a/mas.toml b/mas.toml new file mode 100644 index 0000000..31c49c5 --- /dev/null +++ b/mas.toml @@ -0,0 +1,9 @@ +[mas] +default_group = "secops-team" + +[groups.secops-team] +moderator_did = "did:key:REPLACE_ME" +members = [ +ls -la "$PWD/.tmp" { did = "shadi://github/muscariello/did", role = "human" }, + { did = "did:key:REPLACE_SECOPS", role = "agent" } +] diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ca515ee --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,50 @@ +site_name: SHADI +site_description: Secure Host Agentic AI Dynamic Instantiation +site_url: https://example.com/shadi +repo_url: https://github.com/your-org/shadi +repo_name: shadi + +theme: + name: material + palette: + - scheme: default + toggle: + icon: material/weather-night + name: Switch to dark mode + - scheme: slate + toggle: + icon: material/weather-sunny + name: Switch to light mode + features: + - navigation.tabs + - navigation.top + - content.code.copy + +nav: + - Overview: index.md + - CLI Reference: cli.md + - API & Integration: api_integration.md + - Architecture: architecture.md + - Demo: demo.md + - SecOps Agent: secops_agent.md + - Security: security.md + - Design: design.md + - Sandbox: sandbox.md + +exclude_docs: | + README.md + +markdown_extensions: + - admonition + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.highlight + - toc: + permalink: true + - codehilite + +plugins: + - search diff --git a/policies/demo/avatar.json b/policies/demo/avatar.json new file mode 100644 index 0000000..b9e8ad3 --- /dev/null +++ b/policies/demo/avatar.json @@ -0,0 +1,10 @@ +{ + "allow": ["."], + "read": ["/"], + "net_block": false, + "net_allow": [ + "localhost", + "127.0.0.1", + "::1" + ] +} diff --git a/policies/demo/secops-a.json b/policies/demo/secops-a.json new file mode 100644 index 0000000..2c2fb31 --- /dev/null +++ b/policies/demo/secops-a.json @@ -0,0 +1,11 @@ +{ + "allow": ["."], + "read": ["/"], + "net_block": false, + "net_allow": [ + "api.github.com", + "localhost", + "127.0.0.1", + "::1" + ] +} diff --git a/policies/demo/secops-b.json b/policies/demo/secops-b.json new file mode 100644 index 0000000..2c2fb31 --- /dev/null +++ b/policies/demo/secops-b.json @@ -0,0 +1,11 @@ +{ + "allow": ["."], + "read": ["/"], + "net_block": false, + "net_allow": [ + "api.github.com", + "localhost", + "127.0.0.1", + "::1" + ] +} diff --git a/policies/demo/tourist.json b/policies/demo/tourist.json new file mode 100644 index 0000000..b71c496 --- /dev/null +++ b/policies/demo/tourist.json @@ -0,0 +1,5 @@ +{ + "allow": ["."], + "read": ["/"], + "net_block": true +} diff --git a/sandbox.json b/sandbox.json new file mode 100644 index 0000000..b71c496 --- /dev/null +++ b/sandbox.json @@ -0,0 +1,5 @@ +{ + "allow": ["."], + "read": ["/"], + "net_block": true +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..75c4618 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,74 @@ +# Launcher Scripts + +## Quickstart + +Open three terminals and run: + +```bash +./scripts/launch_slim.sh +``` + +```bash +./scripts/import_secops_secrets.sh +``` + +```bash +./scripts/launch_secops_a2a.sh +``` + +```bash +./scripts/launch_avatar.sh +``` + +## Environment variables + +These scripts default to local paths and can be overridden per terminal. + +### Shared +- SHADI_TMP_DIR: Base directory for per-agent data (default: ./\.tmp). +- SHADI_AGENT_ID: Agent-specific suffix used for isolation. +- SHADI_OPERATOR_PRESENTATION: Required to access secrets in SHADI. + +### SecOps A2A server +- SHADI_SECOPS_CONFIG: Path to secops TOML (default: ${SHADI_TMP_DIR}/secops-a.toml). +- SLIM_TLS_CERT: Client cert (default: ${SHADI_TMP_DIR}/shadi-slim-mtls/client-secops-a.crt). +- SLIM_TLS_KEY: Client key (default: ${SHADI_TMP_DIR}/shadi-slim-mtls/client-secops-a.key). +- SLIM_TLS_CA: CA cert (default: ${SHADI_TMP_DIR}/shadi-slim-mtls/ca.crt). + +### Avatar agent +- SHADI_SECOPS_CONFIG: Path to secops TOML (default: ${SHADI_TMP_DIR}/secops-a.toml). +- SLIM_TLS_CERT: Client cert (default: ${SHADI_TMP_DIR}/shadi-slim-mtls/client-avatar.crt). +- SLIM_TLS_KEY: Client key (default: ${SHADI_TMP_DIR}/shadi-slim-mtls/client-avatar.key). +- SLIM_TLS_CA: CA cert (default: ${SHADI_TMP_DIR}/shadi-slim-mtls/ca.crt). + +### SLIM node +- SLIM_ENDPOINT: Host:port for the node (default: 127.0.0.1:47357). + +## Example per-terminal env + +Terminal 1 (SLIM): + +```bash +export SHADI_TMP_DIR="./.tmp" +export SLIM_ENDPOINT="127.0.0.1:47357" +./scripts/launch_slim.sh +``` + +Terminal 2 (SecOps A2A): + +```bash +export SHADI_TMP_DIR="./.tmp" +export SHADI_AGENT_ID="secops-a" +export SHADI_OPERATOR_PRESENTATION="local-operator" +./scripts/import_secops_secrets.sh +./scripts/launch_secops_a2a.sh +``` + +Terminal 3 (Avatar): + +```bash +export SHADI_TMP_DIR="./.tmp" +export SHADI_AGENT_ID="avatar-1" +export SHADI_OPERATOR_PRESENTATION="local-operator" +./scripts/launch_avatar.sh +``` diff --git a/scripts/import_secops_secrets.sh b/scripts/import_secops_secrets.sh new file mode 100755 index 0000000..1defb87 --- /dev/null +++ b/scripts/import_secops_secrets.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +: "${SHADI_TMP_DIR:="${ROOT_DIR}/.tmp"}" +: "${SHADI_AGENT_ID:="secops-a"}" +: "${SHADI_OPERATOR_PRESENTATION:="local-operator"}" + +export SHADI_TMP_DIR +export SHADI_AGENT_ID +export SHADI_OPERATOR_PRESENTATION + +cd "${ROOT_DIR}" +uv run agents/secops/import_secops_secrets.py diff --git a/scripts/launch_avatar.sh b/scripts/launch_avatar.sh new file mode 100755 index 0000000..eb05160 --- /dev/null +++ b/scripts/launch_avatar.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +: "${SHADI_TMP_DIR:="${ROOT_DIR}/.tmp"}" +: "${SHADI_AGENT_ID:="avatar-1"}" +: "${SHADI_OPERATOR_PRESENTATION:="local-operator"}" +: "${SHADI_SECOPS_CONFIG:="${SHADI_TMP_DIR}/secops-a.toml"}" +: "${SHADI_POLICY_PATH:="${ROOT_DIR}/policies/demo/avatar.json"}" +: "${SHADI_PYTHON:="${ROOT_DIR}/.venv/bin/python"}" + +export SHADI_TMP_DIR +export SHADI_AGENT_ID +export SHADI_OPERATOR_PRESENTATION +export SHADI_SECOPS_CONFIG +export SHADI_POLICY_PATH + +: "${SLIM_TLS_CERT:="${SHADI_TMP_DIR}/shadi-slim-mtls/client-avatar.crt"}" +: "${SLIM_TLS_KEY:="${SHADI_TMP_DIR}/shadi-slim-mtls/client-avatar.key"}" +: "${SLIM_TLS_CA:="${SHADI_TMP_DIR}/shadi-slim-mtls/ca.crt"}" + +export SLIM_TLS_CERT +export SLIM_TLS_KEY +export SLIM_TLS_CA + +cd "${ROOT_DIR}" +"${SHADI_PYTHON}" "${ROOT_DIR}/tools/run_sandboxed_agent.py" \ + --policy "${SHADI_POLICY_PATH}" \ + -- "${SHADI_PYTHON}" "${ROOT_DIR}/agents/avatar/adk_agent/run_shadi_memory.py" diff --git a/scripts/launch_secops_a2a.sh b/scripts/launch_secops_a2a.sh new file mode 100755 index 0000000..73ac28a --- /dev/null +++ b/scripts/launch_secops_a2a.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +: "${SHADI_TMP_DIR:="${ROOT_DIR}/.tmp"}" +: "${SHADI_AGENT_ID:="secops-a"}" +: "${SHADI_OPERATOR_PRESENTATION:="local-operator"}" +: "${SHADI_SECOPS_CONFIG:="${SHADI_TMP_DIR}/secops-a.toml"}" +: "${SHADI_POLICY_PATH:="${ROOT_DIR}/policies/demo/secops-a.json"}" +: "${SHADI_PYTHON:="${ROOT_DIR}/.venv/bin/python"}" + +export SHADI_TMP_DIR +export SHADI_AGENT_ID +export SHADI_OPERATOR_PRESENTATION +export SHADI_SECOPS_CONFIG +export SHADI_POLICY_PATH + +: "${SLIM_TLS_CERT:="${SHADI_TMP_DIR}/shadi-slim-mtls/client-secops-a.crt"}" +: "${SLIM_TLS_KEY:="${SHADI_TMP_DIR}/shadi-slim-mtls/client-secops-a.key"}" +: "${SLIM_TLS_CA:="${SHADI_TMP_DIR}/shadi-slim-mtls/ca.crt"}" + +export SLIM_TLS_CERT +export SLIM_TLS_KEY +export SLIM_TLS_CA + +cd "${ROOT_DIR}" +"${SHADI_PYTHON}" "${ROOT_DIR}/tools/run_sandboxed_agent.py" \ + --policy "${SHADI_POLICY_PATH}" \ + -- "${SHADI_PYTHON}" "${ROOT_DIR}/agents/secops/a2a_server.py" diff --git a/scripts/launch_slim.sh b/scripts/launch_slim.sh new file mode 100755 index 0000000..1b3ba36 --- /dev/null +++ b/scripts/launch_slim.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +: "${SHADI_TMP_DIR:="${ROOT_DIR}/.tmp"}" +: "${SLIM_ENDPOINT:="127.0.0.1:47357"}" + +CONFIG_PATH="${SHADI_TMP_DIR}/shadi-slim-mtls/server-config.yaml" + +slimctl slim start --config "${CONFIG_PATH}" --endpoint "${SLIM_ENDPOINT}" diff --git a/secops.toml b/secops.toml new file mode 100644 index 0000000..9b4784e --- /dev/null +++ b/secops.toml @@ -0,0 +1,29 @@ +[secops] +allowlist = [ + "agntcy/dir", + "agntcy/slim", + "agntcy/oasf", + "agntcy/agentic-apps", +] +workspace_dir = "./.tmp/shadi-secops" +token_key = "secops/github_token" +workspace_key = "secops/workspace_dir" +llm_key_prefix = "secops/llm" +llm_provider = "openai" +memory_key = "secops/memory_key" +slim_identity = "agntcy/secops/agent" +slim_endpoint = "http://localhost:47357" +slim_shared_secret_key = "secops/slim_shared_secret" +slim_local_did_key = "secops/slim_local_did" +slim_remote_did_key = "secops/slim_remote_did" +slim_tls_insecure = true + +[github] +api_base = "https://api.github.com" +auth_mode = "shadi" + +[adk] +model = "gemini-3-flash-preview" + +[python] +packages = ["openai", "slima2a", "slimrpc", "a2a"] diff --git a/tools/generate_slim_mtls_certs.sh b/tools/generate_slim_mtls_certs.sh new file mode 100644 index 0000000..b594f54 --- /dev/null +++ b/tools/generate_slim_mtls_certs.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +CERT_DIR="${1:-./tmp/shadi-slim-mtls}" +DAYS="${DAYS:-365}" + +mkdir -p "$CERT_DIR" +cd "$CERT_DIR" + +rm -f \ + ca.key ca.crt ca.srl \ + server.key server.csr server.crt server.ext \ + client-secops-a.key client-secops-a.csr client-secops-a.crt \ + client-secops-b.key client-secops-b.csr client-secops-b.crt \ + client-avatar.key client-avatar.csr client-avatar.crt + +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout ca.key -out ca.crt -days "$DAYS" \ + -subj "/CN=SHADI SLIM CA" + +openssl req -newkey rsa:2048 -nodes \ + -keyout server.key -out server.csr -subj "/CN=localhost" + +cat > server.ext <<'EOF' +subjectAltName=DNS:localhost,IP:127.0.0.1 +EOF + +openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out server.crt -days "$DAYS" -extfile server.ext + +openssl req -newkey rsa:2048 -nodes \ + -keyout client-secops-a.key -out client-secops-a.csr -subj "/CN=secops-a" + +openssl x509 -req -in client-secops-a.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out client-secops-a.crt -days "$DAYS" + +openssl req -newkey rsa:2048 -nodes \ + -keyout client-secops-b.key -out client-secops-b.csr -subj "/CN=secops-b" + +openssl x509 -req -in client-secops-b.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out client-secops-b.crt -days "$DAYS" + +openssl req -newkey rsa:2048 -nodes \ + -keyout client-avatar.key -out client-avatar.csr -subj "/CN=avatar" + +openssl x509 -req -in client-avatar.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out client-avatar.crt -days "$DAYS" + +echo "Generated mTLS certs in $CERT_DIR" diff --git a/tools/run_sandboxed_agent.py b/tools/run_sandboxed_agent.py new file mode 100644 index 0000000..cf5664e --- /dev/null +++ b/tools/run_sandboxed_agent.py @@ -0,0 +1,80 @@ +import argparse +import json +import os +import sys +from pathlib import Path + +from shadi import SandboxPolicyHandle, run_sandboxed + + +def load_policy(policy_path: str) -> tuple[SandboxPolicyHandle, dict]: + policy_file = Path(policy_path) + if not policy_file.exists(): + raise FileNotFoundError(f"Policy file not found: {policy_file}") + try: + policy_data = json.loads(policy_file.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"Policy file is not valid JSON: {exc}") from exc + + policy = SandboxPolicyHandle() + for path in policy_data.get("read", []) or []: + policy.allow_read_path(path) + for path in policy_data.get("write", []) or []: + policy.allow_write_path(path) + for path in policy_data.get("allow", []) or []: + policy.allow_read_path(path) + policy.allow_write_path(path) + if policy_data.get("net_block") is not None: + policy.block_network(bool(policy_data.get("net_block"))) + return policy, policy_data + + +def build_env(policy_data: dict) -> dict | None: + allowlist = policy_data.get("net_allow", []) or [] + if not allowlist: + return None + + env = dict(os.environ) + env["SHADI_NET_ALLOWLIST"] = ",".join(str(item) for item in allowlist) + + tools_dir = str(Path(__file__).resolve().parent) + existing = env.get("PYTHONPATH", "") + if existing: + env["PYTHONPATH"] = f"{tools_dir}{os.pathsep}{existing}" + else: + env["PYTHONPATH"] = tools_dir + + return env + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run a command inside the SHADI sandbox using a JSON policy." + ) + parser.add_argument("--policy", required=True, help="Path to JSON policy file") + parser.add_argument("--cwd", help="Working directory for the command") + parser.add_argument("command", nargs=argparse.REMAINDER) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + command = list(args.command) + if command and command[0] == "--": + command = command[1:] + if not command: + print("Command required after '--'", file=sys.stderr) + return 2 + + try: + policy, policy_data = load_policy(args.policy) + except (FileNotFoundError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 2 + + env = build_env(policy_data) + return run_sandboxed(command, policy, cwd=args.cwd, env=env) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/shadi_prompt.py b/tools/shadi_prompt.py new file mode 100644 index 0000000..6be8038 --- /dev/null +++ b/tools/shadi_prompt.py @@ -0,0 +1,240 @@ +import argparse +import asyncio +import json +import logging +import os +import shutil +import subprocess +from uuid import uuid4 + +from shadi import ShadiStore, PySessionContext + + +def require_slima2a_packages(): +import tomllib +from pathlib import Path + try: + import httpx + import slimrpc + from a2a.client import ClientFactory, minimal_agent_card + from a2a.types import Message, Part, Role, TextPart + from slima2a.client_transport import ClientConfig, SRPCTransport, slimrpc_channel_factory + except ImportError as exc: + raise RuntimeError( + "Missing SLIM A2A dependencies. Install: uv pip install slima2a slimrpc a2a httpx" + ) from exc + + return { + "httpx": httpx, +def load_secops_config(): + config_path = Path(os.getenv("SHADI_SECOPS_CONFIG", "secops.toml")) + if not config_path.exists(): + return config_path, {} + with config_path.open("rb") as handle: + return config_path, tomllib.load(handle) + "slimrpc": slimrpc, + "ClientFactory": ClientFactory, + parser.add_argument("--endpoint", default=os.getenv("SHADI_SLIM_ENDPOINT", "http://localhost:46357")) + parser.add_argument("--local-did", default=os.getenv("SHADI_SLIM_LOCAL_DID")) + parser.add_argument("--remote-did", default=os.getenv("SHADI_SLIM_REMOTE_DID")) + parser.add_argument("--secret-key", default="secops/slim_shared_secret") + "TextPart": TextPart, + "ClientConfig": ClientConfig, + "SRPCTransport": SRPCTransport, + "slimrpc_channel_factory": slimrpc_channel_factory, + } + + +def create_prompt_session(): + store = ShadiStore() + agent_id = os.getenv("SHADI_OPERATOR_AGENT_ID", "shadi_prompt") + presentation = os.getenv("SHADI_OPERATOR_PRESENTATION", "").encode("utf-8") + if not presentation: + raise RuntimeError("SHADI_OPERATOR_PRESENTATION must be set") + session = PySessionContext(agent_id, "shadi-prompt-1") + + def verify_operator(verify_agent_id, session_id, presentation_bytes, claims): + return verify_agent_id == agent_id and len(presentation_bytes) > 0 + + store.set_verifier(verify_operator) + ok = store.verify_session(session, presentation) + if not ok: + shared_secret = require_shadi_secret_value( + store, + session, + shared_secret_key, + "SLIM shared secret", + ) + + +def require_shadi_secret_value(store, session, key_name, label): + try: + value = store.get(session, key_name) + except Exception as exc: + raise RuntimeError(f"Missing {label} in SHADI at key '{key_name}'.") from exc + text = value.decode("utf-8").strip() + if not text: + raise RuntimeError(f"Missing {label} in SHADI at key '{key_name}'.") + return text + + +def run_slimctl(args): + if not shutil.which("slimctl"): + print("slimctl not found. Install it to discover channels.") + return + try: + result = subprocess.run( + ["slimctl"] + args, + check=True, + capture_output=True, + text=True, + ) + print(result.stdout.strip()) + except subprocess.CalledProcessError as exc: + print("slimctl error:", exc.stderr.strip() or exc.stdout.strip()) + + +async def build_client(types, endpoint, local_identity, remote_identity, shared_secret, insecure): + httpx_client = types["httpx"].AsyncClient() + slimrpc = types["slimrpc"] + + slim_app = await slimrpc.common.create_local_app( + slimrpc.SLIMAppConfig( + identity=local_identity, + slim_client_config={ + "endpoint": endpoint, + "tls": {"insecure": bool(insecure)}, + }, + shared_secret=shared_secret, + ) + ) + client_config = types["ClientConfig"]( + supported_transports=["JSONRPC", "slimrpc"], + streaming=True, + httpx_client=httpx_client, + slimrpc_channel_factory=types["slimrpc_channel_factory"](slim_app), + ) + factory = types["ClientFactory"](client_config) + factory.register("slimrpc", types["SRPCTransport"].create) + agent_card = types["minimal_agent_card"](remote_identity, ["slimrpc"]) + client = factory.create(card=agent_card) + return client, httpx_client + + +async def send_message(types, client, text): + request = types["Message"]( + role=types["Role"].user, + message_id=str(uuid4()), + parts=[types["Part"](root=types["TextPart"](text=text))], + ) + output = "" + async for response in client.send_message(request=request): + if isinstance(response, types["Message"]): + for part in response.parts: + if isinstance(part.root, types["TextPart"]): + output += part.root.text + else: + task, _ = response + if task.status.state == "completed" and task.artifacts: + for artifact in task.artifacts: + for part in artifact.parts: + if isinstance(part.root, types["TextPart"]): + output += part.root.text + return output + + +def prompt_help(): + return ( + "Commands:\n" + " :help Show this help\n" + " :exit Exit the prompt\n" + " :use Set remote agent DID\n" + " :channels [node_id] List SLIM nodes or connections via slimctl\n" + " :routes List SLIM routes via slimctl\n" + " :commands Ask agent for available commands\n" + " Send free-text to the agent\n" + ) + + +async def main(): + logging.getLogger("a2a.utils.telemetry").setLevel(logging.ERROR) + logging.getLogger("asyncio").setLevel(logging.ERROR) + + parser = argparse.ArgumentParser(description="SHADI SLIM A2A prompt") + parser.add_argument("--endpoint", default=os.getenv("SHADI_SLIM_ENDPOINT", "http://localhost:46357")) + parser.add_argument("--local-did", default=os.getenv("SHADI_SLIM_LOCAL_DID", "did:slim:local-user")) + parser.add_argument("--remote-did", default=os.getenv("SHADI_SLIM_REMOTE_DID", "")) + parser.add_argument("--secret-key", default="secops/slim_shared_secret") + parser.add_argument("--insecure", action="store_true", default=True) + args = parser.parse_args() + + if not args.remote_did: + print("Remote DID is required. Use --remote-did or SHADI_SLIM_REMOTE_DID.") + return + + types = require_slima2a_packages() + store, session = create_prompt_session() + shared_secret = require_shadi_secret_value(store, session, args.secret_key, "SLIM shared secret") + + client, httpx_client = await build_client( + types, + endpoint=args.endpoint, + local_identity=args.local_did, + remote_identity=args.remote_did, + shared_secret=shared_secret, + insecure=args.insecure, + ) + + print("SHADI prompt connected.") + print(prompt_help()) + + try: + while True: + line = input("shadi> ").strip() + if not line: + continue + if line in (":exit", "exit", "quit"): + break + if line in (":help", "help", "?"): + print(prompt_help()) + continue + if line.startswith(":use "): + args.remote_did = line.split(" ", 1)[1].strip() + client, httpx_client = await build_client( + types, + endpoint=args.endpoint, + local_identity=args.local_did, + remote_identity=args.remote_did, + shared_secret=shared_secret, + insecure=args.insecure, + ) + print(f"Remote DID set to {args.remote_did}") + continue + if line.startswith(":channels"): + parts = line.split() + if len(parts) == 1: + run_slimctl(["node", "list"]) + else: + run_slimctl(["connection", "list", "--node-id", parts[1]]) + continue + if line.startswith(":routes"): + parts = line.split() + if len(parts) < 2: + print("Usage: :routes ") + else: + run_slimctl(["route", "list", "--node-id", parts[1]]) + continue + if line in (":commands", ":skills"): + payload = json.dumps({"command": "help"}) + output = await send_message(types, client, payload) + print(output) + continue + + output = await send_message(types, client, line) + print(output) + finally: + await httpx_client.aclose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tools/sitecustomize.py b/tools/sitecustomize.py new file mode 100644 index 0000000..54feb5b --- /dev/null +++ b/tools/sitecustomize.py @@ -0,0 +1,90 @@ +import os +import socket +from typing import Any + + +def _split_host_port(entry: str) -> tuple[str, int | None]: + entry = entry.strip() + if not entry: + return "", None + if entry.startswith("[") and "]" in entry: + host = entry[1:entry.index("]")] + rest = entry[entry.index("]") + 1 :] + if rest.startswith(":") and rest[1:].isdigit(): + return host, int(rest[1:]) + return host, None + if entry.count(":") == 1: + host, port = entry.rsplit(":", 1) + if port.isdigit(): + return host, int(port) + return entry, None + + +def _normalize_host(host: Any) -> str: + if host is None: + return "" + if isinstance(host, bytes): + host = host.decode("utf-8", errors="ignore") + return str(host).strip().strip("[]").lower() + + +def _parse_allowlist(value: str) -> tuple[set[str], set[tuple[str, int]]]: + hosts: set[str] = set() + host_ports: set[tuple[str, int]] = set() + for item in value.split(","): + host, port = _split_host_port(item) + host = _normalize_host(host) + if not host: + continue + if port is None: + hosts.add(host) + else: + host_ports.add((host, port)) + return hosts, host_ports + + +def _install_network_guard() -> None: + raw_allowlist = os.getenv("SHADI_NET_ALLOWLIST", "").strip() + if not raw_allowlist: + return + + hosts, host_ports = _parse_allowlist(raw_allowlist) + if not hosts and not host_ports: + return + + original_create_connection = socket.create_connection + original_getaddrinfo = socket.getaddrinfo + + def is_allowed(host: Any, port: Any) -> bool: + if host is None: + return True + normalized = _normalize_host(host) + if not normalized: + return True + if normalized in hosts: + return True + try: + port_num = int(port) + except (TypeError, ValueError): + port_num = None + if port_num is not None and (normalized, port_num) in host_ports: + return True + return False + + def guarded_create_connection(address, *args, **kwargs): + if isinstance(address, tuple) and len(address) >= 2: + host, port = address[0], address[1] + if not is_allowed(host, port): + raise OSError(f"SHADI sandbox blocked network to {host}:{port}") + return original_create_connection(address, *args, **kwargs) + + def guarded_getaddrinfo(host, port, *args, **kwargs): + if host is not None and not is_allowed(host, port): + raise OSError(f"SHADI sandbox blocked network to {host}:{port}") + return original_getaddrinfo(host, port, *args, **kwargs) + + socket.create_connection = guarded_create_connection + socket.getaddrinfo = guarded_getaddrinfo + + +_install_network_guard() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e6a4af1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,324 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "openai" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "shadi-secops" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "openai" }, +] + +[package.metadata] +requires-dist = [{ name = "openai" }] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +]