diff --git a/.github/homebrew-templates/haisdk.rb.tmpl b/.github/homebrew-templates/haisdk.rb.tmpl new file mode 100644 index 000000000..2fadf0996 --- /dev/null +++ b/.github/homebrew-templates/haisdk.rb.tmpl @@ -0,0 +1,26 @@ +class Haisdk < Formula + include Language::Python::Virtualenv + + desc "HAI SDK CLI for JACS registration and attestation workflows" + homepage "https://github.com/HumanAssisted/haisdk" + url "__HAISDK_SDIST_URL__" + sha256 "__HAISDK_SHA256__" + license "MIT" + + depends_on "python@3.12" + depends_on "httpx" + depends_on "cryptography" + depends_on "jacs" + + def install + python = Formula["python@3.12"].opt_bin/"python3.12" + venv = virtualenv_create(libexec, python, system_site_packages: true) + venv.pip_install buildpath + bin.install_symlink libexec/"bin/haisdk" + end + + test do + assert_match "HAI SDK CLI", shell_output("#{bin}/haisdk --help") + assert_match "jacs version:", shell_output("#{Formula["jacs"].opt_bin}/jacs version") + end +end diff --git a/.github/homebrew-templates/jacs.rb.tmpl b/.github/homebrew-templates/jacs.rb.tmpl new file mode 100644 index 000000000..9bbf10c75 --- /dev/null +++ b/.github/homebrew-templates/jacs.rb.tmpl @@ -0,0 +1,18 @@ +class Jacs < Formula + desc "JSON Agent Communication Standard command-line interface" + homepage "https://github.com/HumanAssisted/JACS" + url "https://crates.io/api/v1/crates/jacs/__JACS_VERSION__/download" + sha256 "__JACS_SHA256__" + license "Apache-2.0" + + depends_on "rust" => :build + + def install + system "cargo", "install", *std_cargo_args(path: "."), "--features", "cli" + end + + test do + assert_match "jacs version: #{version}", shell_output("#{bin}/jacs version") + assert_match "Usage: jacs [COMMAND]", shell_output("#{bin}/jacs --help") + end +end diff --git a/.github/workflows/components.yml b/.github/workflows/components.yml new file mode 100644 index 000000000..e41e278a8 --- /dev/null +++ b/.github/workflows/components.yml @@ -0,0 +1,70 @@ +name: Workspace Components (jacs-mcp, jacsgo) + +on: + push: + branches: ["main"] + paths: + - "jacs-mcp/**" + - "jacsgo/**" + - "jacs/**" + - "binding-core/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/components.yml" + pull_request: + branches: ["main"] + paths: + - "jacs-mcp/**" + - "jacsgo/**" + - "jacs/**" + - "binding-core/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/components.yml" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + test-jacs-mcp: + name: Test jacs-mcp + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.93" + + - name: Run jacs-mcp tests + run: cargo test -p jacs-mcp --verbose + + test-jacsgo: + name: Test jacsgo + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.93" + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: Run jacsgo tests + working-directory: jacsgo + run: GOCACHE=/tmp/jacs-go-cache make test + + - name: Build jacsgo examples + working-directory: jacsgo + run: GOCACHE=/tmp/jacs-go-cache make examples diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml new file mode 100644 index 000000000..5986ef7b6 --- /dev/null +++ b/.github/workflows/homebrew.yml @@ -0,0 +1,70 @@ +name: Homebrew Install Smoke Tests + +on: + push: + branches: ["main"] + pull_request: + workflow_dispatch: + +jobs: + macos-brew-install: + runs-on: macos-latest + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + steps: + - uses: actions/checkout@v4 + + - name: Build local JACS formula from current checkout + run: | + set -euo pipefail + version="$(grep '^version = ' jacs/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')" + archive="$RUNNER_TEMP/jacs-homebrew-src.tar.gz" + formula="$RUNNER_TEMP/jacs.rb" + git archive --format=tar.gz --output "$archive" HEAD + sha256="$(shasum -a 256 "$archive" | awk '{print $1}')" + cat > "$formula" < :build + + def install + system "cargo", "install", *std_cargo_args(path: "jacs"), "--features", "cli" + end + + test do + assert_match "jacs version: #{version}", shell_output("#{bin}/jacs version") + assert_match "Usage: jacs [COMMAND]", shell_output("#{bin}/jacs --help") + end + end + EOF + + - name: Set up local Homebrew tap + run: | + tap_dir="$(brew --repository)/Library/Taps/humanassisted/homebrew-jacs" + mkdir -p "$tap_dir/Formula" + cp "$RUNNER_TEMP/jacs.rb" "$tap_dir/Formula/jacs.rb" + cp ./Formula/haisdk.rb "$tap_dir/Formula/" + + - name: Install JACS formula + run: brew install humanassisted/jacs/jacs + + - name: Smoke test JACS CLI + run: | + jacs version + jacs --help + + - name: Install HAISDK formula (HEAD) + continue-on-error: true + run: brew install --HEAD humanassisted/jacs/haisdk + + - name: Smoke test HAISDK CLI + continue-on-error: true + run: | + haisdk --help + haisdk register --help diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index edd402053..49eaac785 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -2,14 +2,14 @@ name: Node.js (jacsnpm) on: push: - branches: [ "main" ] + branches: [ "master" ] paths: - 'jacsnpm/**' - 'jacs/**' # jacsnpm depends on jacs - 'binding-core/**' # jacsnpm verifyStandalone depends on binding-core - '.github/workflows/nodejs.yml' pull_request: - branches: [ "main" ] + branches: [ "master" ] paths: - 'jacsnpm/**' - 'jacs/**' # jacsnpm depends on jacs @@ -60,12 +60,22 @@ jobs: JACS_DATA_DIRECTORY: /tmp/does-not-exist JACS_KEY_DIRECTORY: /tmp/does-not-exist JACS_DEFAULT_STORAGE: memory - JACS_KEY_RESOLUTION: hai + JACS_KEY_RESOLUTION: local run: npm run test:cross-language - - name: Run tests + - name: Run tests (parallel, excluding cross-language already covered above) working-directory: jacsnpm - run: npm test + run: npm run test:ci + + - name: Smoke test quickstart example + working-directory: jacsnpm + env: + JACS_PRIVATE_KEY_PASSWORD: TestP@ss123!# + run: | + set -euo pipefail + TMP_EXAMPLE_DIR=$(mktemp -d) + cd "${TMP_EXAMPLE_DIR}" + node "${GITHUB_WORKSPACE}/jacsnpm/examples/quickstart.js" - name: Smoke test packed npm install working-directory: jacsnpm @@ -76,4 +86,4 @@ jobs: cd /tmp/jacs-npm-smoke npm init -y >/dev/null npm install "${GITHUB_WORKSPACE}/jacsnpm/${PACKAGE_TGZ}" --ignore-scripts - node -e "require('@hai.ai/jacs'); require('@hai.ai/jacs/simple'); require('@hai.ai/jacs/mcp'); require('@hai.ai/jacs/a2a'); console.log('smoke imports ok')" + node -e "require('jacs'); require('jacs/simple'); require('jacs/mcp'); require('jacs/a2a'); console.log('smoke imports ok')" diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 5d46d5f0c..afdae0759 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -2,9 +2,9 @@ name: Python (jacs) on: push: - branches: [ "main" ] + branches: [ "master" ] pull_request: - branches: [ "main" ] + branches: [ "master" ] workflow_dispatch: # Allows manual triggering env: @@ -39,9 +39,9 @@ jobs: cd /workspace/jacspy && \ /opt/python/cp311-cp311/bin/python3.11 -m venv .venv && \ source .venv/bin/activate && \ - pip install maturin pytest pytest-asyncio && \ + pip install maturin pytest pytest-asyncio pytest-xdist && \ pip install fastmcp mcp starlette && \ - make test-python" + PYTEST_XDIST_WORKERS=4 make test-python-parallel" - name: Verify sdist build on PR/push working-directory: jacspy @@ -91,7 +91,6 @@ jobs: /tmp/jacs-wheel-smoke/bin/python - <<'PY' import jacs import jacs.simple - import jacs.hai print("wheel smoke imports ok") PY diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 7951f8b95..33bd28ebe 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -22,13 +22,19 @@ jobs: TAG="${GITHUB_REF#refs/tags/cli/v}" echo "version=$TAG" >> $GITHUB_OUTPUT - - name: Check Cargo.toml version matches tag + - name: Check Cargo.toml versions match tag run: | TAG_VERSION="${{ steps.extract.outputs.version }}" - CARGO_VERSION=$(grep '^version = ' jacs/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - echo "Cargo version: $CARGO_VERSION, tag: $TAG_VERSION" - if [ "$CARGO_VERSION" != "$TAG_VERSION" ]; then - echo "::error::Version mismatch! jacs/Cargo.toml has $CARGO_VERSION but tag is $TAG_VERSION" + JACS_VERSION=$(grep '^version = ' jacs/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + MCP_VERSION=$(grep '^version = ' jacs-mcp/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "jacs version: $JACS_VERSION, tag: $TAG_VERSION" + echo "jacs-mcp version: $MCP_VERSION, tag: $TAG_VERSION" + if [ "$JACS_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Version mismatch! jacs/Cargo.toml has $JACS_VERSION but tag is $TAG_VERSION" + exit 1 + fi + if [ "$MCP_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Version mismatch! jacs-mcp/Cargo.toml has $MCP_VERSION but tag is $TAG_VERSION" exit 1 fi @@ -40,31 +46,30 @@ jobs: include: - os: macos-latest target: aarch64-apple-darwin - artifact_name: jacs-cli - asset_name: jacs-cli-${{ needs.verify-version.outputs.version }}-darwin-arm64 - archive: tar.gz + asset_suffix: darwin-arm64 + archive_ext: tar.gz - os: macos-14 target: x86_64-apple-darwin - artifact_name: jacs-cli - asset_name: jacs-cli-${{ needs.verify-version.outputs.version }}-darwin-x64 - archive: tar.gz + asset_suffix: darwin-x64 + archive_ext: tar.gz - os: ubuntu-latest target: x86_64-unknown-linux-gnu - artifact_name: jacs-cli - asset_name: jacs-cli-${{ needs.verify-version.outputs.version }}-linux-x64 - archive: tar.gz + asset_suffix: linux-x64 + archive_ext: tar.gz - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu - artifact_name: jacs-cli - asset_name: jacs-cli-${{ needs.verify-version.outputs.version }}-linux-arm64 - archive: tar.gz + asset_suffix: linux-arm64 + archive_ext: tar.gz - os: windows-latest target: x86_64-pc-windows-msvc - artifact_name: jacs-cli.exe - asset_name: jacs-cli-${{ needs.verify-version.outputs.version }}-windows-x64 - archive: zip + asset_suffix: windows-x64 + archive_ext: zip runs-on: ${{ matrix.os }} + env: + VERSION: ${{ needs.verify-version.outputs.version }} + CLI_ASSET_NAME: jacs-cli-${{ needs.verify-version.outputs.version }}-${{ matrix.asset_suffix }} + MCP_ASSET_NAME: jacs-mcp-${{ needs.verify-version.outputs.version }}-${{ matrix.asset_suffix }} steps: - uses: actions/checkout@v4 @@ -73,37 +78,51 @@ jobs: toolchain: '1.93' targets: ${{ matrix.target }} - - name: Build CLI binary - run: cargo build --release -p jacs --features cli --target ${{ matrix.target }} + - name: Build binaries + run: | + cargo build --release -p jacs --features cli --target ${{ matrix.target }} + cargo build --release -p jacs-mcp --target ${{ matrix.target }} - name: Smoke test (Unix) if: runner.os != 'Windows' - run: ./target/${{ matrix.target }}/release/jacs --version + run: | + ./target/${{ matrix.target }}/release/jacs --version + ./target/${{ matrix.target }}/release/jacs-mcp --help > /dev/null - name: Smoke test (Windows) if: runner.os == 'Windows' - run: .\target\${{ matrix.target }}\release\jacs.exe --version + shell: pwsh + run: | + .\target\${{ matrix.target }}\release\jacs.exe version + .\target\${{ matrix.target }}\release\jacs-mcp.exe --help | Out-Null - name: Package (Unix) if: runner.os != 'Windows' run: | cp target/${{ matrix.target }}/release/jacs jacs-cli - tar czf ${{ matrix.asset_name }}.tar.gz jacs-cli - shasum -a 256 ${{ matrix.asset_name }}.tar.gz > ${{ matrix.asset_name }}.tar.gz.sha256 + cp target/${{ matrix.target }}/release/jacs-mcp jacs-mcp + tar czf ${CLI_ASSET_NAME}.tar.gz jacs-cli + tar czf ${MCP_ASSET_NAME}.tar.gz jacs-mcp + shasum -a 256 ${CLI_ASSET_NAME}.tar.gz > ${CLI_ASSET_NAME}.tar.gz.sha256 + shasum -a 256 ${MCP_ASSET_NAME}.tar.gz > ${MCP_ASSET_NAME}.tar.gz.sha256 - name: Package (Windows) if: runner.os == 'Windows' shell: pwsh run: | Copy-Item "target/${{ matrix.target }}/release/jacs.exe" "jacs-cli.exe" - Compress-Archive -Path "jacs-cli.exe" -DestinationPath "${{ matrix.asset_name }}.zip" - Get-FileHash "${{ matrix.asset_name }}.zip" -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLower()) ${{ matrix.asset_name }}.zip" } | Out-File "${{ matrix.asset_name }}.zip.sha256" -Encoding ascii + Copy-Item "target/${{ matrix.target }}/release/jacs-mcp.exe" "jacs-mcp.exe" + Compress-Archive -Path "jacs-cli.exe" -DestinationPath "${env:CLI_ASSET_NAME}.zip" + Compress-Archive -Path "jacs-mcp.exe" -DestinationPath "${env:MCP_ASSET_NAME}.zip" + Get-FileHash "${env:CLI_ASSET_NAME}.zip" -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLower()) ${env:CLI_ASSET_NAME}.zip" } | Out-File "${env:CLI_ASSET_NAME}.zip.sha256" -Encoding ascii + Get-FileHash "${env:MCP_ASSET_NAME}.zip" -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLower()) ${env:MCP_ASSET_NAME}.zip" } | Out-File "${env:MCP_ASSET_NAME}.zip.sha256" -Encoding ascii - uses: actions/upload-artifact@v4 with: - name: ${{ matrix.asset_name }} + name: binaries-${{ matrix.asset_suffix }} path: | - ${{ matrix.asset_name }}.* + ${{ env.CLI_ASSET_NAME }}.* + ${{ env.MCP_ASSET_NAME }}.* release: needs: [verify-version, build] @@ -128,7 +147,9 @@ jobs: body: | ## JACS CLI v${{ needs.verify-version.outputs.version }} - Prebuilt CLI binaries for verifying and signing JACS documents. + Prebuilt binaries for: + - `jacs` (CLI) + - `jacs-mcp` (MCP server) ### Install @@ -146,7 +167,12 @@ jobs: sudo mv jacs-cli /usr/local/bin/ ``` - Or install via npm/pip (ships with `@hai.ai/jacs` and `jacs`). + Install MCP directly from the CLI (uses these platform assets by default): + + ```bash + jacs mcp install + jacs mcp run + ``` ### Verify checksums ```bash diff --git a/.github/workflows/release-homebrew.yml b/.github/workflows/release-homebrew.yml new file mode 100644 index 000000000..10ab443ca --- /dev/null +++ b/.github/workflows/release-homebrew.yml @@ -0,0 +1,137 @@ +name: Sync Homebrew Tap + +on: + push: + tags: + - 'cli/v*' + - 'crate/v*' + workflow_dispatch: + inputs: + jacs_version: + description: "JACS version for Formula/jacs.rb (defaults to tag or jacs/Cargo.toml)" + required: false + haisdk_version: + description: "HAISDK version for Formula/haisdk.rb (defaults to latest on PyPI)" + required: false + haisdk_pypi_package: + description: "PyPI package name for HAISDK" + required: false + default: "haisdk" + tap_repository: + description: "Homebrew tap repository (owner/repo)" + required: false + default: "HumanAssisted/homebrew-jacs" + tap_branch: + description: "Tap branch (stable branch is master)" + required: false + default: "master" + +permissions: + contents: read + +jobs: + sync-formulas: + runs-on: ubuntu-latest + steps: + - name: Check for tap token + env: + HAS_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }} + run: | + if [ "$HAS_TOKEN" != "true" ]; then + echo "::error::HOMEBREW_TAP_TOKEN secret is not configured. Set it at https://github.com/HumanAssisted/JACS/settings/secrets/actions" + exit 1 + fi + + - uses: actions/checkout@v4 + + - name: Resolve versions and artifact checksums + id: resolve + shell: bash + run: | + set -euo pipefail + + ref_name="${GITHUB_REF_NAME:-}" + jacs_version_input="${{ github.event.inputs.jacs_version }}" + haisdk_version_input="${{ github.event.inputs.haisdk_version }}" + haisdk_pkg_input="${{ github.event.inputs.haisdk_pypi_package }}" + + if [[ -n "${jacs_version_input}" ]]; then + jacs_version="${jacs_version_input}" + elif [[ "${ref_name}" =~ ^cli/v(.+)$ ]]; then + jacs_version="${BASH_REMATCH[1]}" + elif [[ "${ref_name}" =~ ^crate/v(.+)$ ]]; then + jacs_version="${BASH_REMATCH[1]}" + else + jacs_version="$(grep '^version = ' jacs/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')" + fi + + haisdk_pkg="${haisdk_pkg_input:-haisdk}" + if [[ -z "${haisdk_pkg}" ]]; then + haisdk_pkg="haisdk" + fi + + pypi_json="$(mktemp)" + curl -fsSL "https://pypi.org/pypi/${haisdk_pkg}/json" -o "${pypi_json}" + + if [[ -n "${haisdk_version_input}" ]]; then + haisdk_version="${haisdk_version_input}" + else + haisdk_version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["info"]["version"])' "${pypi_json}")" + fi + + read -r haisdk_sdist_url haisdk_sha256 < <(python3 -c 'import json,sys; data=json.load(open(sys.argv[1], encoding="utf-8")); version=sys.argv[2]; entry=next((f for f in data.get("releases", {}).get(version, []) if f.get("packagetype")=="sdist"), None); (entry is not None) or sys.exit(f"No sdist found for version {version}"); print(entry["url"], entry["digests"]["sha256"])' "${pypi_json}" "${haisdk_version}") + + jacs_crate="$(mktemp)" + curl -fsSL "https://crates.io/api/v1/crates/jacs/${jacs_version}/download" -o "${jacs_crate}" + jacs_sha256="$(sha256sum "${jacs_crate}" | awk '{print $1}')" + + echo "jacs_version=${jacs_version}" >> "${GITHUB_OUTPUT}" + echo "jacs_sha256=${jacs_sha256}" >> "${GITHUB_OUTPUT}" + echo "haisdk_version=${haisdk_version}" >> "${GITHUB_OUTPUT}" + echo "haisdk_sdist_url=${haisdk_sdist_url}" >> "${GITHUB_OUTPUT}" + echo "haisdk_sha256=${haisdk_sha256}" >> "${GITHUB_OUTPUT}" + echo "haisdk_pypi_package=${haisdk_pkg}" >> "${GITHUB_OUTPUT}" + + - name: Generate formula files + shell: bash + run: | + set -euo pipefail + mkdir -p generated/Formula + + sed \ + -e "s|__JACS_VERSION__|${{ steps.resolve.outputs.jacs_version }}|g" \ + -e "s|__JACS_SHA256__|${{ steps.resolve.outputs.jacs_sha256 }}|g" \ + .github/homebrew-templates/jacs.rb.tmpl > generated/Formula/jacs.rb + + sed \ + -e "s|__HAISDK_SDIST_URL__|${{ steps.resolve.outputs.haisdk_sdist_url }}|g" \ + -e "s|__HAISDK_SHA256__|${{ steps.resolve.outputs.haisdk_sha256 }}|g" \ + .github/homebrew-templates/haisdk.rb.tmpl > generated/Formula/haisdk.rb + + - name: Checkout Homebrew tap repository + uses: actions/checkout@v4 + with: + repository: ${{ github.event.inputs.tap_repository || 'HumanAssisted/homebrew-jacs' }} + ref: ${{ github.event.inputs.tap_branch || 'master' }} + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + + - name: Publish updated formulas to tap + shell: bash + run: | + set -euo pipefail + mkdir -p tap/Formula + cp generated/Formula/jacs.rb tap/Formula/jacs.rb + cp generated/Formula/haisdk.rb tap/Formula/haisdk.rb + + cd tap + if git diff --quiet -- Formula/jacs.rb Formula/haisdk.rb; then + echo "No Homebrew formula changes to publish." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/jacs.rb Formula/haisdk.rb + git commit -m "Update formulas: jacs v${{ steps.resolve.outputs.jacs_version }}, haisdk v${{ steps.resolve.outputs.haisdk_version }}" + git push origin HEAD:${{ github.event.inputs.tap_branch || 'master' }} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 5eb062a50..033f84864 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,23 +2,71 @@ name: Rust (jacs crate) on: push: - branches: [ "main" ] + branches: ["main"] + tags: ["v*"] paths: - - 'jacs/**' - - '.github/workflows/rust.yml' + - "jacs/**" + - "binding-core/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/rust.yml" pull_request: - branches: [ "main" ] + branches: ["main"] paths: - - 'jacs/**' - - '.github/workflows/rust.yml' - workflow_dispatch: # Allows manual triggering + - "jacs/**" + - "binding-core/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/rust.yml" + workflow_dispatch: + inputs: + run_full: + description: "Run full release-level Rust test matrix" + required: false + default: false + type: boolean + schedule: + - cron: "0 9 * * *" env: CARGO_TERM_COLOR: always jobs: - test-jacs: # Renamed job for clarity - name: Test jacs crate (${{ matrix.os }}) + quick-jacs: + name: Quick jacs checks (ubuntu) + if: | + github.event_name == 'pull_request' || + (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_full != 'true') + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.93" + + - name: Install cargo-audit and run audit + run: | + cargo install cargo-audit --locked --version 0.22.1 + cargo audit --ignore RUSTSEC-2023-0071 + + - name: Run quick Rust test suite + run: | + cargo test -p jacs --verbose --lib --features cli + cargo test -p jacs --verbose --test pq2025_tests + cargo test -p jacs --verbose --test cross_language_tests + cargo test -p jacs --verbose --test structured_logging_tests + cargo test -p jacs-binding-core --verbose + + full-jacs-release: + name: Full jacs suite (${{ matrix.os }}) + if: | + startsWith(github.ref, 'refs/tags/') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_full == 'true') runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -26,21 +74,45 @@ jobs: os: [ubuntu-latest, macos-latest] steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: '1.93' - - - name: Install cargo-audit and run audit - if: runner.os == 'Linux' - working-directory: jacs - run: | - cargo install cargo-audit - cargo audit || true - - - name: Run jacs tests - working-directory: jacs - run: cargo test --verbose --features cli + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.93" + + - name: Install cargo-audit and run audit (Linux) + if: runner.os == 'Linux' + run: | + cargo install cargo-audit --locked --version 0.22.1 + cargo audit --ignore RUSTSEC-2023-0071 + + - name: Run full jacs tests + run: | + cargo test -p jacs --verbose --features cli + cargo test -p jacs-binding-core --verbose + + full-jacs-nightly: + name: Full jacs suite (nightly ubuntu) + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.93" + + - name: Install cargo-audit and run audit + run: | + cargo install cargo-audit --locked --version 0.22.1 + cargo audit --ignore RUSTSEC-2023-0071 + + - name: Run nightly full Rust tests + run: | + cargo test -p jacs --verbose --features cli + cargo test -p jacs-binding-core --verbose diff --git a/A2A_QUICKSTART.md b/A2A_QUICKSTART.md index ea303d9d5..0e2138fb6 100644 --- a/A2A_QUICKSTART.md +++ b/A2A_QUICKSTART.md @@ -1,103 +1,142 @@ -# JACS A2A Quick Start Guide +# JACS A2A Quick Start (5 Minutes) -JACS extends Google's A2A (Agent-to-Agent) protocol with cryptographic document provenance. +JACS extends the A2A (Agent-to-Agent) protocol with cryptographic document provenance. Every JACS agent is automatically an A2A agent -- zero additional configuration. + +> **Deep dive:** See the [A2A Quickstart Guide](./jacs/docs/jacsbook/src/guides/a2a-quickstart.md) in the jacsbook for tabbed step-by-step walkthroughs, or the [A2A Interoperability Reference](./jacs/docs/jacsbook/src/integrations/a2a.md) for the full API. ## What JACS Adds to A2A - **Document signatures** that persist with data (not just transport security) -- **Post-quantum cryptography** for future-proof security +- **Post-quantum cryptography** for future-proof security - **Chain of custody** tracking for multi-agent workflows - **Self-verifying artifacts** that work offline +- **Trust policies** (open / verified / strict) for controlling foreign agent access -## Installation +## Install ```bash -# Rust -cargo add jacs +pip install jacs # Python +pip install jacs[a2a-server] # Python + discovery server (FastAPI + uvicorn) +npm install @hai.ai/jacs # Node.js +cargo install jacs --features cli # Rust CLI +``` + +## The 10-Line Journey + +### Python + +```python +from jacs.client import JacsClient -# Python -pip install jacs +client = JacsClient.quickstart() # 1. Create agent +card = client.export_agent_card(url="https://myagent.example.com") # 2. Export Agent Card +signed = client.sign_artifact({"action": "classify"}, "task") # 3. Sign an artifact +result = client.verify_artifact(signed) # 4. Verify it +print(f"Valid: {result['valid']}, Signer: {result['signer_id']}") -# Node.js -npm install @hai.ai/jacs +a2a = client.get_a2a(url="http://localhost:8080") # 5. Get A2A integration +a2a.serve(port=8080) # 6. Serve discovery endpoints ``` -## Basic Usage +### Node.js -### 1. Export Agent to A2A Format +```javascript +const { JacsClient } = require('@hai.ai/jacs/client'); -```python -from jacs.a2a import JACSA2AIntegration +const client = await JacsClient.quickstart(); // 1. Create agent +const card = client.exportAgentCard(); // 2. Export Agent Card +const signed = await client.signArtifact({ action: 'classify' }, 'task'); // 3. Sign an artifact +const result = await client.verifyArtifact(signed); // 4. Verify it +console.log(`Valid: ${result.valid}, Signer: ${result.signerId}`); -a2a = JACSA2AIntegration("jacs.config.json") -agent_card = a2a.export_agent_card({ - "jacsId": "my-agent", - "jacsName": "My Agent", - "jacsServices": [{ - "name": "Process Data", - "tools": [{ - "url": "/api/process", - "function": { - "name": "process", - "description": "Process data" - } - }] - }] -}) +const a2a = client.getA2A(); // 5. Get A2A integration +const server = await a2a.listen({ port: 8080 }); // 6. Serve discovery endpoints ``` -### 2. Wrap A2A Artifacts with Provenance +### Rust CLI + +```bash +jacs quickstart # 1. Create agent +jacs a2a export-card # 2. Export Agent Card +echo '{"action":"classify"}' | jacs a2a sign --type task # 3. Sign an artifact +jacs a2a serve --port 8080 # 4. Serve discovery endpoints +``` + +## Discover and Assess Remote Agents + +```python +from jacs.a2a_discovery import discover_and_assess_sync + +result = discover_and_assess_sync("https://agent.example.com") +print(f"Agent: {result['card']['name']}") +print(f"JACS registered: {result['jacs_registered']}") +print(f"Trust level: {result['trust_level']}") # trusted / jacs_registered / untrusted +``` ```javascript -const { JACSA2AIntegration } = require('@hai.ai/jacs/a2a'); -const a2a = new JACSA2AIntegration(); - -// Wrap any A2A artifact -const wrapped = a2a.wrapArtifactWithProvenance({ - taskId: 'task-123', - operation: 'analyze', - data: { /* ... */ } -}, 'task'); +const { discoverAndAssess } = require('@hai.ai/jacs/a2a-discovery'); + +const result = await discoverAndAssess('https://agent.example.com'); +console.log(`Agent: ${result.card.name}`); +console.log(`JACS registered: ${result.jacsRegistered}`); +console.log(`Trust level: ${result.trustLevel}`); ``` -### 3. Verify Wrapped Artifacts +## Chain of Custody -```rust -use jacs::a2a::provenance::verify_wrapped_artifact; +Track provenance across multi-agent workflows: -let result = verify_wrapped_artifact(&agent, &wrapped_artifact)?; -if result.valid { - println!("Verified by: {}", result.signer_id); -} +```python +step1 = client_a.sign_artifact({"step": 1, "data": "raw"}, "message") +step2 = client_b.sign_artifact({"step": 2, "data": "processed"}, "message", parent_signatures=[step1]) +``` + +```javascript +const step1 = await clientA.signArtifact({ step: 1, data: 'raw' }, 'message'); +const step2 = await clientB.signArtifact({ step: 2, data: 'processed' }, 'message', [step1]); ``` -### 4. Create Chain of Custody +## Trust Policies + +| Policy | Behavior | Use Case | +|--------|----------|----------| +| `open` | Accept all agents | Development, testing | +| `verified` | Require JACS extension in agent card | **Default** -- production use | +| `strict` | Require agent in local trust store | High-security environments | ```python -# Track multi-step workflows -step1 = a2a.wrap_artifact_with_provenance(data1, "step") -step2 = a2a.wrap_artifact_with_provenance(data2, "step", [step1]) -step3 = a2a.wrap_artifact_with_provenance(data3, "step", [step2]) +from jacs.a2a import JACSA2AIntegration +a2a = JACSA2AIntegration(client, trust_policy="strict") +assessment = a2a.assess_remote_agent(remote_card_json) +``` -chain = a2a.create_chain_of_custody([step1, step2, step3]) +```javascript +const { JACSA2AIntegration } = require('@hai.ai/jacs/a2a'); +const a2a = new JACSA2AIntegration(client, 'strict'); +const assessment = a2a.assessRemoteAgent(remoteCardJson); ``` ## Well-Known Endpoints -Serve these endpoints for A2A discovery: +JACS serves five endpoints for A2A discovery: -- `/.well-known/agent-card.json` - A2A Agent Card (JWS signed) -- `/.well-known/jwks.json` - JWK set for verifying Agent Card signatures -- `/.well-known/jacs-agent.json` - JACS agent descriptor -- `/.well-known/jacs-pubkey.json` - JACS public key +| Endpoint | Purpose | +|----------|---------| +| `/.well-known/agent-card.json` | A2A Agent Card | +| `/.well-known/jwks.json` | JWK set for verifying signatures | +| `/.well-known/jacs-agent.json` | JACS agent descriptor | +| `/.well-known/jacs-pubkey.json` | JACS public key | +| `/.well-known/jacs-extension.json` | JACS provenance extension descriptor | ## JACS Extension in Agent Cards +JACS agents declare the `urn:jacs:provenance-v1` extension in their Agent Card so other JACS agents can identify them: + ```json { "capabilities": { "extensions": [{ - "uri": "urn:hai.ai:jacs-provenance-v1", + "uri": "urn:jacs:provenance-v1", "description": "JACS cryptographic document signing", "required": false }] @@ -105,29 +144,15 @@ Serve these endpoints for A2A discovery: } ``` -## Examples - -- **Rust**: [jacs/examples/a2a_complete_example.rs](./jacs/examples/a2a_complete_example.rs) -- **Python**: [jacspy/examples/fastmcp/a2a_agent_server.py](./jacspy/examples/fastmcp/a2a_agent_server.py) -- **Node.js**: [jacsnpm/examples/a2a-agent-example.js](./jacsnpm/examples/a2a-agent-example.js) - -## Key Concepts - -1. **Dual Keys**: JACS generates two key pairs: - - Post-quantum (Dilithium) for document signatures - - Traditional (RSA/ECDSA) for A2A compatibility - -2. **Separation of Concerns**: - - A2A handles discovery and transport - - JACS handles document provenance - -3. **Zero Trust**: Every artifact is self-verifying with complete audit trail - ## Next Steps -1. Set up JACS configuration with keys -2. Export your agent as A2A Agent Card -3. Implement verification endpoints -4. Register with A2A discovery services - -See full documentation: [jacs/src/a2a/README.md](./jacs/src/a2a/README.md) +- **[A2A Quickstart Guide](./jacs/docs/jacsbook/src/guides/a2a-quickstart.md)** -- Hub page with "JACS for A2A Developers" and troubleshooting FAQ + - [Serve Your Agent Card](./jacs/docs/jacsbook/src/guides/a2a-serve.md) -- Publish discovery endpoints + - [Discover & Trust](./jacs/docs/jacsbook/src/guides/a2a-discover.md) -- Find and assess remote agents + - [Exchange Artifacts](./jacs/docs/jacsbook/src/guides/a2a-exchange.md) -- Sign, verify, chain of custody +- **[A2A Interoperability Reference](./jacs/docs/jacsbook/src/integrations/a2a.md)** -- Full API reference, MCP integration, framework adapters +- **[Trust Store](./jacs/docs/jacsbook/src/advanced/trust-store.md)** -- Managing trusted agents +- **[Framework Adapters](./jacs/docs/jacsbook/src/python/adapters.md)** -- Auto-sign with LangChain, FastAPI, CrewAI +- **[Express Middleware](./jacs/docs/jacsbook/src/nodejs/express.md)** -- Add A2A to Express apps +- **[Hero Demo (Python)](./examples/a2a_trust_demo.py)** -- 3-agent trust verification example +- **[Hero Demo (Node.js)](./examples/a2a_trust_demo.ts)** -- Same demo in TypeScript diff --git a/CHANGELOG.md b/CHANGELOG.md index 8beb2f971..94bc31181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +## 0.9.0 + +### Attestation + +- **Attestation module**: Feature-gated (`--features attestation`) system for creating evidence-based trust proofs on top of cryptographic signing. Attestations bind claims, evidence references, derivation chains, and policy context to signed JACS documents. +- **Core types**: `AttestationSubject`, `Claim`, `EvidenceRef`, `Derivation`, `PolicyContext`, `DigestSet` in `jacs/src/attestation/types.rs` with full JSON Schema at `schemas/attestation/v1/attestation.schema.json`. +- **Create attestation**: `create_attestation()` API with subject, claims, optional evidence/derivation/policy. Claims support `confidence` (0.0-1.0) and `assuranceLevel` (self-reported, verified, audited, formal). +- **Verify attestation**: Two-tier verification -- local (signature + hash, <1ms) and full (evidence digests, freshness, derivation chain, <10ms). Structured `AttestationVerificationResult` output. +- **Lift to attestation**: `lift_to_attestation()` upgrades existing signed documents to attestations by wrapping them as the attestation subject with additional claims. +- **DSSE export**: `export_attestation_dsse()` wraps attestations as in-toto Statements in DSSE envelopes for SLSA/Sigstore/in-toto compatibility. Predicate type: `https://jacs.dev/attestation/v1`. +- **Evidence adapters**: Pluggable `EvidenceAdapter` trait with built-in adapters for A2A artifacts and email evidence. Custom adapter support via `normalize()` / `verify_evidence()` contract. +- **Derivation chains**: Track multi-step transformations with input/output digests, transform metadata, and configurable depth limits (default 10). +- **CLI**: `jacs attest create` and `jacs attest verify` subcommands with `--full`, `--json`, `--from-document`, and output file support. +- **Digest utilities**: Shared `compute_digest_set()` / `compute_digest_set_bytes()` using JCS canonicalization (RFC 8785). 64KB auto-embed threshold for evidence. + +### Attestation Bindings + +- **Rust SimpleAgent**: `create_attestation()`, `verify_attestation()`, `verify_attestation_full()`, `lift_to_attestation()`, `export_attestation_dsse()`. +- **binding-core**: JSON-in/JSON-out attestation API for all language bindings. +- **Python (jacspy)**: `JacsClient.create_attestation()`, `verify_attestation()`, `lift_to_attestation()`, `export_attestation_dsse()` with keyword arguments. Feature-gated behind `--features attestation`. +- **Node.js (jacsnpm)**: Async attestation methods on `JacsClient` class plus sync convenience functions in `simple.ts`. Feature-gated behind `--features attestation`. +- **MCP server (jacs-mcp)**: Three new tools -- `jacs_attest_create`, `jacs_attest_verify`, `jacs_attest_lift`. Graceful degradation when attestation feature not compiled. + +### Attestation Testing + +- **Benchmarks**: Criterion benchmarks for create (~86us), verify-local (~46us), verify-full (~73us), lift (~80us). All well under performance targets. +- **Cross-language tests**: Rust generates attestation fixtures (Ed25519 + pq2025), Node.js verifies them. 14 cross-language attestation tests. +- **Hello-world examples**: `examples/attestation_hello_world.{py,js,sh}` for Python, Node.js, and CLI. + +### Documentation + +- **What Is an Attestation?**: Concept page explaining signing vs attestation (`getting-started/attestation.md`). +- **Sign vs Attest Decision Guide**: When to use each API (`guides/sign-vs-attest.md`). +- **Attestation Tutorial**: Step-by-step from agent creation to verified attestation (`guides/attestation-tutorial.md`). +- **Verification Results Reference**: Full error catalog for `AttestationVerificationResult` (`reference/attestation-errors.md`). + ## 0.6.0 ### Security audit (MVP) @@ -44,15 +80,29 @@ ### Security +- **Middleware auth replay protection (Node + Python adapters)**: Added opt-in replay defenses for auth-style signed requests in Express/Koa (`authReplay`) and FastAPI (`auth_replay_protection`, `auth_max_age_seconds`, `auth_clock_skew_seconds`). Enforcement includes signature timestamp freshness checks plus single-use `(signerId, signature)` dedupe via in-memory TTL cache. +- **Replay hardening test coverage**: Added lower-level replay tests for middleware future-timestamp rejection paths (Express, Koa, FastAPI) and explicit cache-instance isolation semantics in shared replay helpers (Node + Python), documenting current per-process cache behavior. - **Path traversal hardening**: Data and key directory paths built from untrusted input (e.g. `publicKeyHash`) are now validated via a single shared `require_relative_path_safe()` in `validation.rs`. Used in loaders (`make_data_directory_path`, `make_key_directory_path`) and trust store; prevents document-controlled path traversal (e.g. `../../etc/passwd`). - **Schema directory boundary hardening**: Filesystem schema loading now validates normalized/canonical path containment instead of string-prefix checks, preventing directory-prefix overlap bypasses (e.g. `allowed_evil` no longer matches `allowed`). - **Cross-platform path hardening**: `require_relative_path_safe()` now also rejects Windows drive-prefixed paths (e.g. `C:\...`, `D:/...`, `E:`) while still allowing UUID:UUID filenames used by JACS. - **HAI verification transport hardening**: `verify_hai_registration_sync()` now enforces HTTPS for `HAI_API_URL` (with `http://localhost` and `http://127.0.0.1` allowed for local testing), preventing insecure remote transport configuration. +- **Verification-claim schema alignment**: Agent schema now accepts canonical `verified-registry` and keeps legacy `verified-hai.ai` alias for backward compatibility; added regression coverage to ensure both claims validate. +- **DNS TXT version regression coverage**: DNS tests now assert canonical `v=jacs` emission while preserving legacy `v=hai.ai` parsing support with explicit regression tests. +- **HAI key lookup endpoint default**: Remote key fetch now defaults to `https://hai.ai` (instead of `https://keys.hai.ai`) and normalizes trailing slashes before building `/jacs/v1/agents/{jacs_id}/keys/{version}` URLs; added regression tests for env precedence and URL construction. - **Trust-store canonical ID handling**: `trust_agent()` now accepts canonical agent documents that provide `jacsId` and `jacsVersion` as separate fields, canonicalizes to `UUID:VERSION_UUID`, and keeps strict path-safe validation. - **Config and keystore logging**: Removed config debug log in loaders; keystore key generation no longer prints to stderr by default (uses `tracing::debug`). - **Example config**: `jacs.config.example.json` no longer contains `jacs_private_key_password`; use `JACS_PRIVATE_KEY_PASSWORD` environment variable only. - **Password redaction in diagnostics**: `check_env_vars()` now prints `REDACTED` instead of the actual `JACS_PRIVATE_KEY_PASSWORD` value, consistent with `Config::Display`. +### MCP State Access Management + +- **MCP state verify/load/update now JACS-document-first**: `jacs_verify_state`, `jacs_load_state`, and `jacs_update_state` now route through JACS document IDs (`jacs_id`, `uuid:version`) and JACS storage/document APIs rather than MCP-level direct filesystem reads/writes. +- **Path-based state access disabled at MCP layer**: File-path-only calls for verify/load/update now return `FILESYSTEM_ACCESS_DISABLED` in MCP handlers, reducing exposed filesystem attack surface while preserving JACS-internal filesystem behavior. +- **State lifecycle now persisted for MCP follow-up ops**: `jacs_sign_state` and `jacs_adopt_state` now persist signed state documents in JACS storage (instead of no-save flow) and default to embedded content for MCP document-centric lifecycle operations. +- **binding-core support added**: New `AgentWrapper::get_document_by_id()` API loads documents by `jacs_id` via agent/storage abstractions for MCP and wrapper reuse. +- **MCP state schema/docs updated**: `UpdateStateParams` now includes `jacs_id`; README/state tool docs updated to describe `jacs_id`-centric usage and file-path deprecation for verify/load/update. +- **Coverage added**: MCP tests now assert rejection of file-path-only verify/load/update calls and validate new `jacs_id` update parameter schema. + ### Documentation - **SECURITY.md**: Added short "Security model" subsection (password via env only, keys encrypted at rest, path validation, no secrets in config). diff --git a/Cargo.toml b/Cargo.toml index 99c69d08a..c883324e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,6 @@ homepage = "https://humanassisted.github.io/JACS" repository = "https://github.com/HumanAssisted/JACS" keywords = ["cryptography", "json", "ai", "data", "ml-ops"] categories = ["cryptography", "text-processing", "data-structures"] + +[workspace.metadata.dev-requirements] +cargo-audit = "0.22.1" diff --git a/Formula/haisdk.rb b/Formula/haisdk.rb new file mode 100644 index 000000000..640805f3d --- /dev/null +++ b/Formula/haisdk.rb @@ -0,0 +1,23 @@ +class Haisdk < Formula + include Language::Python::Virtualenv + + desc "HAI SDK CLI for JACS registration and attestation workflows" + homepage "https://github.com/HumanAssisted/haisdk" + head "https://github.com/HumanAssisted/haisdk.git", branch: "main" + license "MIT" + + depends_on "python@3.12" + depends_on "httpx" + depends_on "cryptography" + + def install + python = Formula["python@3.12"].opt_bin/"python3.12" + venv = virtualenv_create(libexec, python, system_site_packages: true) + venv.pip_install buildpath/"python" + bin.install_symlink libexec/"bin/haisdk" + end + + test do + assert_match "HAI SDK CLI", shell_output("#{bin}/haisdk --help") + end +end diff --git a/Formula/jacs.rb b/Formula/jacs.rb new file mode 100644 index 000000000..bcaba0106 --- /dev/null +++ b/Formula/jacs.rb @@ -0,0 +1,18 @@ +class Jacs < Formula + desc "JSON Agent Communication Standard command-line interface" + homepage "https://github.com/HumanAssisted/JACS" + url "https://crates.io/api/v1/crates/jacs/0.9.0/download" + sha256 "95a002c440eeea1fbd750b4521d3628751ae535fd51a9765e1a97f5ccd9dd8c1" + license "Apache-2.0" + + depends_on "rust" => :build + + def install + system "cargo", "install", *std_cargo_args(path: "."), "--features", "cli" + end + + test do + assert_match "jacs version: #{version}", shell_output("#{bin}/jacs version") + assert_match "Usage: jacs [COMMAND]", shell_output("#{bin}/jacs --help") + end +end diff --git a/LINES_OF_CODE.md b/LINES_OF_CODE.md index 2999ac39b..eb62edaa6 100644 --- a/LINES_OF_CODE.md +++ b/LINES_OF_CODE.md @@ -1,13 +1,13 @@ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Language Files Lines Code Comments Blanks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Go 13 4810 3571 587 652 - Python 155 34534 27061 1621 5852 - TypeScript 9 3134 1660 1236 238 + Go 11 4262 3126 543 593 + Python 183 44005 34447 1958 7600 + TypeScript 29 8244 5799 1755 690 ───────────────────────────────────────────────────────────────────────────────── - Rust 354 101187 83405 5735 12047 - |- Markdown 260 25449 667 18762 6020 - (Total) 126636 84072 24497 18067 + Rust 410 132278 109848 6806 15624 + |- Markdown 314 27133 661 20192 6280 + (Total) 159411 110509 26998 21904 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Total 531 169114 116364 27941 24809 + Total 633 215922 153881 31254 30787 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/Makefile b/Makefile index 1e3cc1396..117ec7cc9 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ -.PHONY: build-jacs build-jacsbook test test-jacs test-jacs-cli test-jacs-observability test-jacspy \ +.PHONY: build-jacs build-jacsbook build-jacsbook-pdf test test-jacs audit-jacs test-jacs-cli test-jacs-observability test-jacspy test-jacspy-parallel test-jacsnpm test-jacsnpm-parallel \ publish-jacs publish-jacspy publish-jacsnpm \ - release-jacs release-jacspy release-jacsnpm release-all \ + release-jacs release-jacspy release-jacsnpm release-cli release-all release-everything release-delete-tags \ retry-jacspy retry-jacsnpm \ - version versions check-versions check-version-jacs check-version-jacspy check-version-jacsnpm \ + version versions check-versions check-version-jacs check-version-jacspy check-version-jacsnpm check-version-cli \ install-githooks regen-cross-lang-fixtures \ help @@ -14,12 +14,27 @@ # Rust core library version (from jacs/Cargo.toml) JACS_VERSION := $(shell grep '^version' jacs/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') +# Rust MCP server version (from jacs-mcp/Cargo.toml) +JACS_MCP_VERSION := $(shell grep '^version' jacs-mcp/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + +# Shared Rust binding core version (from binding-core/Cargo.toml) +BINDING_CORE_VERSION := $(shell grep '^version' binding-core/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + # Python bindings version (from jacspy/pyproject.toml) JACSPY_VERSION := $(shell grep '^version' jacspy/pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/') +# Python Rust extension crate version (from jacspy/Cargo.toml) +JACSPY_RUST_VERSION := $(shell grep '^version' jacspy/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + # Node.js bindings version (from jacsnpm/package.json) JACSNPM_VERSION := $(shell grep '"version"' jacsnpm/package.json | head -1 | sed 's/.*: *"\(.*\)".*/\1/') +# Node.js Rust extension crate version (from jacsnpm/Cargo.toml) +JACSNPM_RUST_VERSION := $(shell grep '^version' jacsnpm/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + +# Go FFI Rust library version (from jacsgo/lib/Cargo.toml) +JACSGO_VERSION := $(shell grep '^version' jacsgo/lib/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + # ============================================================================ # BUILD # ============================================================================ @@ -38,6 +53,9 @@ build-jacsnpm: build-jacsbook: cd jacs/docs/jacsbook && mdbook build +build-jacsbook-pdf: + ./jacs/docs/jacsbook/scripts/build-pdf.sh + # ============================================================================ # TEST # ============================================================================ @@ -45,6 +63,10 @@ build-jacsbook: test-jacs: cd jacs && RUST_BACKTRACE=1 cargo test --features cli -- --nocapture +audit-jacs: + @command -v cargo-audit >/dev/null 2>&1 || (echo "cargo-audit is required. Install with: cargo install cargo-audit --locked --version 0.22.1"; exit 1) + cargo audit --ignore RUSTSEC-2023-0071 + test-jacs-cli: cd jacs && RUST_BACKTRACE=1 cargo test --features cli --test cli_tests -- --nocapture @@ -54,9 +76,15 @@ test-jacs-observability: test-jacspy: cd jacspy && maturin develop && python -m pytest tests/ -v +test-jacspy-parallel: + cd jacspy && PYTEST_XDIST_WORKERS=$${PYTEST_XDIST_WORKERS:-auto} make test-python-parallel + test-jacsnpm: cd jacsnpm && npm test +test-jacsnpm-parallel: + cd jacsnpm && npm run test:parallel + test: test-jacs # Regenerate all canonical cross-language fixtures in sequence. @@ -79,11 +107,22 @@ install-githooks: versions: @echo "Detected versions from source files:" @echo " jacs (Cargo.toml): $(JACS_VERSION)" + @echo " jacs-mcp (Cargo.toml): $(JACS_MCP_VERSION)" + @echo " binding-core (Cargo.toml):$(BINDING_CORE_VERSION)" @echo " jacspy (pyproject.toml): $(JACSPY_VERSION)" + @echo " jacspy (Cargo.toml): $(JACSPY_RUST_VERSION)" @echo " jacsnpm (package.json): $(JACSNPM_VERSION)" + @echo " jacsnpm (Cargo.toml): $(JACSNPM_RUST_VERSION)" + @echo " jacsgo/lib (Cargo.toml): $(JACSGO_VERSION)" @echo "" - @if [ "$(JACS_VERSION)" = "$(JACSPY_VERSION)" ] && [ "$(JACS_VERSION)" = "$(JACSNPM_VERSION)" ]; then \ - echo "✓ All versions match: $(JACS_VERSION)"; \ + @if [ "$(JACS_VERSION)" = "$(JACS_MCP_VERSION)" ] && \ + [ "$(JACS_VERSION)" = "$(BINDING_CORE_VERSION)" ] && \ + [ "$(JACS_VERSION)" = "$(JACSPY_VERSION)" ] && \ + [ "$(JACS_VERSION)" = "$(JACSPY_RUST_VERSION)" ] && \ + [ "$(JACS_VERSION)" = "$(JACSNPM_VERSION)" ] && \ + [ "$(JACS_VERSION)" = "$(JACSNPM_RUST_VERSION)" ] && \ + [ "$(JACS_VERSION)" = "$(JACSGO_VERSION)" ]; then \ + echo "✓ All release versions match: $(JACS_VERSION)"; \ else \ echo "⚠ WARNING: Versions do not match!"; \ fi @@ -92,15 +131,35 @@ version: versions # Check that all versions match (fails if they don't) check-versions: + @if [ "$(JACS_VERSION)" != "$(JACS_MCP_VERSION)" ]; then \ + echo "ERROR: jacs ($(JACS_VERSION)) != jacs-mcp ($(JACS_MCP_VERSION))"; \ + exit 1; \ + fi + @if [ "$(JACS_VERSION)" != "$(BINDING_CORE_VERSION)" ]; then \ + echo "ERROR: jacs ($(JACS_VERSION)) != binding-core ($(BINDING_CORE_VERSION))"; \ + exit 1; \ + fi @if [ "$(JACS_VERSION)" != "$(JACSPY_VERSION)" ]; then \ echo "ERROR: jacs ($(JACS_VERSION)) != jacspy ($(JACSPY_VERSION))"; \ exit 1; \ fi + @if [ "$(JACS_VERSION)" != "$(JACSPY_RUST_VERSION)" ]; then \ + echo "ERROR: jacs ($(JACS_VERSION)) != jacspy Cargo.toml ($(JACSPY_RUST_VERSION))"; \ + exit 1; \ + fi @if [ "$(JACS_VERSION)" != "$(JACSNPM_VERSION)" ]; then \ echo "ERROR: jacs ($(JACS_VERSION)) != jacsnpm ($(JACSNPM_VERSION))"; \ exit 1; \ fi - @echo "✓ All versions match: $(JACS_VERSION)" + @if [ "$(JACS_VERSION)" != "$(JACSNPM_RUST_VERSION)" ]; then \ + echo "ERROR: jacs ($(JACS_VERSION)) != jacsnpm Cargo.toml ($(JACSNPM_RUST_VERSION))"; \ + exit 1; \ + fi + @if [ "$(JACS_VERSION)" != "$(JACSGO_VERSION)" ]; then \ + echo "ERROR: jacs ($(JACS_VERSION)) != jacsgo/lib ($(JACSGO_VERSION))"; \ + exit 1; \ + fi + @echo "✓ All release versions match: $(JACS_VERSION)" # ============================================================================ # DIRECT PUBLISH (requires local credentials) @@ -169,6 +228,19 @@ check-version-jacsnpm: fi @echo "✓ Tag npm/v$(JACSNPM_VERSION) is available" +# Verify version and tag for CLI binary release +check-version-cli: + @echo "cli version: $(JACS_VERSION)" + @if [ "$(JACS_VERSION)" != "$(JACS_MCP_VERSION)" ]; then \ + echo "ERROR: jacs ($(JACS_VERSION)) != jacs-mcp ($(JACS_MCP_VERSION))"; \ + exit 1; \ + fi + @if git tag -l | grep -q "^cli/v$(JACS_VERSION)$$"; then \ + echo "ERROR: Tag cli/v$(JACS_VERSION) already exists"; \ + exit 1; \ + fi + @echo "✓ Tag cli/v$(JACS_VERSION) is available" + # Tag and push to trigger crates.io release via GitHub CI release-jacs: check-version-jacs git tag crate/v$(JACS_VERSION) @@ -187,15 +259,25 @@ release-jacsnpm: check-version-jacsnpm git push origin npm/v$(JACSNPM_VERSION) @echo "Tagged npm/v$(JACSNPM_VERSION) - GitHub CI will publish to npm" +# Tag and push to trigger CLI binary release via GitHub CI +release-cli: check-version-cli + git tag cli/v$(JACS_VERSION) + git push origin cli/v$(JACS_VERSION) + @echo "Tagged cli/v$(JACS_VERSION) - GitHub CI will publish GitHub release binaries" + # Release all packages via GitHub CI (verifies all versions match first) release-all: check-versions release-jacs release-jacspy release-jacsnpm @echo "All release tags pushed for v$(JACS_VERSION). GitHub CI will handle publishing." +# Release all packages plus CLI binaries via GitHub CI +release-everything: release-all release-cli + @echo "All release tags, including CLI binaries, pushed for v$(JACS_VERSION)." + # Delete release tags for current versions (use with caution - for fixing failed releases) release-delete-tags: @echo "Deleting tags for version $(JACS_VERSION)..." - -git tag -d crate/v$(JACS_VERSION) pypi/v$(JACSPY_VERSION) npm/v$(JACSNPM_VERSION) - -git push origin --delete crate/v$(JACS_VERSION) pypi/v$(JACSPY_VERSION) npm/v$(JACSNPM_VERSION) + -git tag -d crate/v$(JACS_VERSION) pypi/v$(JACSPY_VERSION) npm/v$(JACSNPM_VERSION) cli/v$(JACS_VERSION) + -git push origin --delete crate/v$(JACS_VERSION) pypi/v$(JACSPY_VERSION) npm/v$(JACSNPM_VERSION) cli/v$(JACS_VERSION) @echo "Deleted release tags" # Retry a failed PyPI release: delete old tags (local+remote), retag, push @@ -232,10 +314,12 @@ help: @echo " make build-jacspy Build Python bindings (dev mode)" @echo " make build-jacsnpm Build Node.js bindings" @echo " make build-jacsbook Generate jacsbook (mdbook build)" + @echo " make build-jacsbook-pdf Generate single PDF book at docs/jacsbook.pdf" @echo "" @echo "TEST:" @echo " make test Run all tests (alias for test-jacs)" @echo " make test-jacs Run Rust library tests" + @echo " make audit-jacs Run cargo-audit (required security gate)" @echo " make test-jacs-cli Run CLI integration tests" @echo " make test-jacspy Run Python binding tests" @echo " make test-jacsnpm Run Node.js binding tests" @@ -256,7 +340,9 @@ help: @echo " make release-jacs Tag crate/v -> triggers crates.io release" @echo " make release-jacspy Tag pypi/v -> triggers PyPI release" @echo " make release-jacsnpm Tag npm/v -> triggers npm release" - @echo " make release-all Verify versions match, then release all packages" + @echo " make release-cli Tag cli/v -> triggers CLI binary release" + @echo " make release-all Verify versions match, then release crates/PyPI/npm" + @echo " make release-everything Verify versions match, then release crates/PyPI/npm/CLI" @echo " make release-delete-tags Delete release tags (for fixing failed releases)" @echo " make retry-jacspy Retry failed PyPI release (delete tags, retag, push)" @echo " make retry-jacsnpm Retry failed npm release (delete tags, retag, push)" diff --git a/README.md b/README.md index cca2a1046..8a519f5d3 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,28 @@ Cryptographic signatures for AI agent outputs so anyone can verify who said what Zero-config -- one call creates a persistent agent with keys on disk. +### Password Setup + +Persistent agents use encrypted private keys. Rust/CLI flows require an explicit password source before `quickstart(...)`. Python/Node quickstart can auto-generate one, but production deployments should still set it explicitly. + +```bash +# Option A (recommended): direct env var +export JACS_PRIVATE_KEY_PASSWORD='use-a-strong-password' + +# Option B (CLI convenience): path to a file containing only the password +export JACS_PASSWORD_FILE=/secure/path/jacs-password.txt +``` + +Set exactly one explicit source for CLI. If both are set, CLI fails fast to avoid ambiguity. +For Python/Node library usage, set `JACS_PRIVATE_KEY_PASSWORD` in your process environment (recommended). + ### Python ```python import jacs.simple as jacs -jacs.quickstart() +info = jacs.quickstart(name="payments-agent", domain="payments.example.com") +print(info.config_path, info.public_key_path, info.private_key_path) signed = jacs.sign_message({"action": "approve", "amount": 100}) result = jacs.verify(signed.raw) print(f"Valid: {result.valid}, Signer: {result.signer_id}") @@ -27,7 +43,11 @@ print(f"Valid: {result.valid}, Signer: {result.signer_id}") const jacs = require('@hai.ai/jacs/simple'); async function main() { - await jacs.quickstart(); + const info = await jacs.quickstart({ + name: 'payments-agent', + domain: 'payments.example.com', + }); + console.log(info.configPath, info.publicKeyPath, info.privateKeyPath); const signed = await jacs.signMessage({ action: 'approve', amount: 100 }); const result = await jacs.verify(signed.raw); console.log(`Valid: ${result.valid}, Signer: ${result.signerId}`); @@ -45,8 +65,29 @@ cargo install jacs --features cli # Or download a prebuilt binary from GitHub Releases # https://github.com/HumanAssisted/JACS/releases -jacs quickstart +jacs quickstart --name payments-agent --domain payments.example.com jacs document create -f mydata.json + +# Install and run the Rust MCP server from the CLI +jacs mcp install +jacs mcp run + +# Optional fallback: install MCP via cargo instead of prebuilt assets +jacs mcp install --from-cargo +``` + +`jacs mcp run` is local stdio-only transport. Runtime transport override args are intentionally not accepted. + +### Homebrew (macOS) + +```bash +brew tap HumanAssisted/homebrew-jacs + +# Install JACS CLI +brew install jacs + +# Install HAI SDK CLI separately +brew install haisdk ``` **Signed your first document?** Next: [Verify it without an agent](#verify-a-signed-document) | [Pick your framework integration](#which-integration-should-i-use) | [Full quick start guide](https://humanassisted.github.io/JACS/getting-started/quick-start.html) @@ -82,12 +123,24 @@ Find the right path in under 2 minutes. [Full decision tree](https://humanassist | Node.js + Express | `require('@hai.ai/jacs/express')` | [Express Guide](https://humanassisted.github.io/JACS/nodejs/express.html) | | Node.js + Vercel AI SDK | `require('@hai.ai/jacs/vercel-ai')` | [Vercel AI Guide](https://humanassisted.github.io/JACS/nodejs/vercel-ai.html) | | Node.js + LangChain.js | `require('@hai.ai/jacs/langchain')` | [LangChain.js Guide](https://humanassisted.github.io/JACS/nodejs/langchain.html) | -| MCP Server (Python) | `from jacs.mcp import create_jacs_mcp_server` | [Python MCP Guide](https://humanassisted.github.io/JACS/python/mcp.html) | -| MCP Server (Node.js) | `require('@hai.ai/jacs/mcp')` | [Node.js MCP Guide](https://humanassisted.github.io/JACS/nodejs/mcp.html) | -| A2A Protocol | `from jacs.a2a import JACSA2AIntegration` | [A2A Guide](https://humanassisted.github.io/JACS/integrations/a2a.html) | -| Rust / CLI | `cargo install jacs --features cli` | [Rust Guide](https://humanassisted.github.io/JACS/rust/installation.html) | +| MCP Adapter (Python, partial) | `from jacs.mcp import create_jacs_mcp_server` | [Python MCP Guide](https://humanassisted.github.io/JACS/python/mcp.html) | +| MCP Adapter (Node.js, partial) | `require('@hai.ai/jacs/mcp')` | [Node.js MCP Guide](https://humanassisted.github.io/JACS/nodejs/mcp.html) | +| A2A Protocol | `client.get_a2a()` / `client.getA2A()` | [A2A Guide](https://humanassisted.github.io/JACS/integrations/a2a.html) | +| Rust / CLI (canonical MCP server) | `cargo install jacs --features cli` | [Rust Guide](https://humanassisted.github.io/JACS/rust/installation.html) | | Any language (standalone) | `import jacs.simple as jacs` | [Simple API](https://humanassisted.github.io/JACS/python/simple-api.html) | +## MCP Support Matrix + +| MCP surface | Rust `jacs-mcp` | Node `jacs/mcp` | Python `jacs.adapters.mcp` | Go | +|-------------|-----------------|-----------------|-----------------------------|----| +| Canonical full `jacs_*` server | Yes | No | No | No | +| Transport proxy / protocol glue | N/A | Yes | Yes | Demo only | +| Middleware / framework adapter | N/A | Yes | Yes | No | +| Language-native tool registration | Canonical | Partial compatibility layer | Partial compatibility layer | No | +| Demo / example only | No | No | No | Yes | + +The canonical full MCP contract is exported from Rust at [`jacs-mcp/contract/jacs-mcp-contract.json`](jacs-mcp/contract/jacs-mcp-contract.json). Node and Python adapters are tested against that snapshot so contract drift is explicit. + ## Who Is JACS For? **Platform teams** building multi-agent systems where agents from different services -- or different organizations -- need to trust each other's outputs. @@ -118,10 +171,45 @@ JACS provides the missing trust layer: identity (who produced this?), integrity ## Post-Quantum Ready -JACS supports ML-DSA-87 (FIPS-204) post-quantum signatures alongside classical algorithms (Ed25519, ECDSA P-256/P-384, RSA-PSS). The `pq2025` algorithm preset gives you quantum-resistant signing today, with zero code changes from the standard API. +JACS supports ML-DSA-87 (FIPS-204) post-quantum signatures alongside classical algorithms (Ed25519 and RSA-PSS). The `pq2025` algorithm preset is the default across quickstart/create paths. [Algorithm Selection Guide](https://humanassisted.github.io/JACS/advanced/algorithm-guide.html) +## A2A Interoperability + +Every JACS agent is an A2A agent -- zero additional configuration. JACS implements the [Agent-to-Agent (A2A)](https://github.com/a2aproject/A2A) protocol with cryptographic trust built in. For details on how signing, A2A, and attestation relate, see the [Trust Layers Guide](https://humanassisted.github.io/JACS/getting-started/trust-layers.html). + +Built-in trust policies control how your agent handles foreign signatures: `open` (accept all), `verified` (require key resolution, **default**), or `strict` (require local trust store entry). + +```python +from jacs.client import JacsClient + +client = JacsClient.quickstart(name="a2a-agent", domain="a2a.example.com") +card = client.export_agent_card("http://localhost:8080") +signed = client.sign_artifact({"action": "classify", "input": "hello"}, "task") +``` + +```javascript +const { JacsClient } = require('@hai.ai/jacs/client'); + +const client = await JacsClient.quickstart({ + name: 'a2a-agent', + domain: 'a2a.example.com', +}); +const card = client.exportAgentCard(); +const signed = await client.signArtifact({ action: 'classify', input: 'hello' }, 'task'); +``` + +For trust bootstrap flows, use a signed agent document plus an explicit public key exchange: + +```python +agent_json = client.share_agent() +public_key_pem = client.share_public_key() +client.trust_agent_with_key(agent_json, public_key_pem) +``` + +[A2A Guide](https://humanassisted.github.io/JACS/integrations/a2a.html) | [Python A2A](https://humanassisted.github.io/JACS/python/a2a.html) | [Node.js A2A](https://humanassisted.github.io/JACS/nodejs/a2a.html) + ## Cross-Language Compatibility A document signed by a Rust agent can be verified by a Python or Node.js agent, and vice versa. The signature format is language-agnostic -- any JACS binding produces and consumes the same signed JSON. @@ -132,7 +220,7 @@ Cross-language interoperability is tested on every commit with both Ed25519 and **Prove that pipeline outputs are authentic.** A build service signs every JSON artifact it emits. Downstream teams and auditors verify with a single call; tampering or forgery is caught immediately. [Full scenario](USECASES.md#1-verifying-that-json-files-came-from-a-specific-program) -**Run a public agent without exposing the operator.** An AI agent signs every message but only publishes the public key via DNS or HAI. Recipients verify origin and integrity cryptographically; the operator's identity never touches the internet. [Full scenario](USECASES.md#2-protecting-your-agents-identity-on-the-internet) +**Run a public agent without exposing the operator.** An AI agent signs every message but only publishes the public key via DNS. Recipients verify origin and integrity cryptographically; the operator's identity never touches the internet. [Full scenario](USECASES.md#2-protecting-your-agents-identity-on-the-internet) **Add cryptographic provenance in any language.** Finance, healthcare, or any regulated environment: sign every output with `sign_message()`, verify with `verify()`. The same three-line pattern works identically in Python, Node.js, Rust, and Go. [Full scenario](USECASES.md#4-a-go-node-or-python-agent-with-strong-data-provenance) @@ -145,4 +233,4 @@ Cross-language interoperability is tested on every commit with both Ed25519 and --- -v0.8.0 | [Apache 2.0 with Common Clause](./LICENSE) | [hai.ai](https://hai.ai) +v0.9.0 | [Apache 2.0 with Common Clause](./LICENSE) diff --git a/Test_Suite_Review_Report.md b/Test_Suite_Review_Report.md deleted file mode 100644 index 4294b4fc5..000000000 --- a/Test_Suite_Review_Report.md +++ /dev/null @@ -1,60 +0,0 @@ -# Test Suite Review Report for JACS Project - -## Introduction -This report outlines the findings and recommendations from a thorough code review of the JACS project's test suite. The review focused on assessing test coverage, quality, adherence to best practices, and documentation. - -## Findings - -### Test Coverage and Quality -- The test suite covers a range of functionalities, including agent creation, document handling, and key hashing. -- Both positive and negative test cases are present, ensuring robustness. - -### Debugging Statements -- Several tests include `println!` statements for debugging. These should be removed or replaced with a proper logging mechanism for cleaner test output. - -### Descriptive Comments -- Tests lack descriptive comments explaining the purpose and expected outcomes, which could improve maintainability and clarity for new contributors. - -### Assertions and Error Handling -- Tests use `unwrap()` and `expect()` for error handling, which is appropriate for test scenarios. However, more detailed messages with `expect()` would provide better context in case of failures. - -### Code Duplication -- There is a pattern of code duplication, particularly in setup and teardown processes. Refactoring to reduce duplication would improve maintainability. - -### Ignored Tests -- Some tests are marked with `#[ignore]`, which means they are not executed by default. These tests should be reviewed to ensure they are included in the test suite if relevant. - -### Hardcoded Values -- Tests contain hardcoded values and file paths. Making these values configurable could improve the flexibility of the test suite. - -### Documentation and Readability -- The test suite would benefit from a documentation review to ensure that instructions for running and understanding tests are clear and up-to-date. - -## Recommendations - -### Improve Test Documentation -- Add more descriptive comments to each test case to explain their purpose and expected outcomes. - -### Enhance Assertions -- Include more detailed assertions to verify the expected outcomes of the tests, especially after updating the agent. - -### Implement a Logging Mechanism -- Replace `println!` and `eprintln!` statements with a proper logging mechanism that can be toggled on or off. - -### Refactor Tests -- Reduce code duplication by refactoring common setup and teardown processes into utility functions or fixtures. - -### Review Ignored Tests -- Review and include tests marked with `#[ignore]` if they are relevant to the current functionality. - -### Configurable Test Values -- Make hardcoded values and file paths configurable to improve the test suite's flexibility and adaptability to different environments. - -### Negative Test Cases -- Ensure that negative test cases are included and expanded upon to test error handling and edge cases thoroughly. - -### Test Suite Execution -- Review the execution of the entire test suite to ensure all tests are run and passing as expected. - -## Conclusion -The JACS project's test suite is comprehensive but can be improved in several areas. The recommendations provided aim to enhance the quality, maintainability, and readability of the tests, ensuring that they continue to serve as a reliable measure of the project's health and functionality. diff --git a/USECASES.md b/USECASES.md index ecccdad2d..2000e9ca0 100644 --- a/USECASES.md +++ b/USECASES.md @@ -56,6 +56,9 @@ This document describes fictional but detailed scenarios for using JACS. Each se 1. **Create a JACS agent.** Locally create and configure the agent (e.g. `jacs init` or Python/Node/Go `create`/`load`). Ensure you have the agent’s public key and identity (e.g. agent ID, public key hash). 2. **Get an HAI API key.** Obtain an API key from HAI.ai (e.g. https://hai.ai or https://hai.ai/developers). Set `HAI_API_KEY` in the environment or pass it to the registration call. 3. **Register the agent.** Use the HAI registration flow: + + > **Note:** The registration functions below (`register_with_hai`, `registerWithHai`, etc.) are provided by the separate [haisdk](https://github.com/HumanAssisted/haisdk) package, not by the core `jacs` library. Install `haisdk` alongside `jacs` for HAI platform workflows. + - **Python:** Use the `register_with_hai` example or `register_new_agent()` from `jacs.hai` (see `jacspy/examples/register_with_hai.py` and `jacspy/examples/hai_quickstart.py`). Quick path: `hai_quickstart.py` can create and register in one step. - **Node:** `registerWithHai()` (jacsnpm). - **Go:** `RegisterWithHai()` (jacsgo). diff --git a/binding-core/Cargo.toml b/binding-core/Cargo.toml index 9a169ef28..6265bfbbb 100644 --- a/binding-core/Cargo.toml +++ b/binding-core/Cargo.toml @@ -1,25 +1,21 @@ [package] name = "jacs-binding-core" -version = "0.8.0" +version = "0.9.0" edition = "2024" rust-version = "1.93" resolver = "3" description = "Shared core logic for JACS language bindings (Python, Node.js, etc.)" readme = "../README.md" -authors = ["HAI.AI "] +authors = ["JACS Contributors"] license-file = "../LICENSE" [features] -default = ["hai"] -hai = ["dep:reqwest", "dep:tokio", "dep:serde", "dep:futures-util"] +default = [] +attestation = ["jacs/attestation"] [dependencies] jacs = { path = "../jacs" } serde_json = "1.0" base64 = "0.22.1" - -# HAI client dependencies (optional) -reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"], optional = true } -tokio = { version = "1.0", features = ["rt", "sync", "time"], optional = true } -serde = { version = "1.0", features = ["derive"], optional = true } -futures-util = { version = "0.3", optional = true } +serde = { version = "1.0", features = ["derive"] } +tracing = "0.1" diff --git a/binding-core/src/hai.rs b/binding-core/src/hai.rs deleted file mode 100644 index 1dfc261d5..000000000 --- a/binding-core/src/hai.rs +++ /dev/null @@ -1,1239 +0,0 @@ -//! HAI client for interacting with HAI.ai -//! -//! This module provides a complete, clean API for connecting to HAI services: -//! -//! ## Construction -//! - `HaiClient::new()` - create client with endpoint URL -//! - `with_api_key()` - set API key for authentication -//! -//! ## Core Methods -//! - `testconnection()` - verify connectivity to the HAI server -//! - `register()` - register a JACS agent with HAI -//! - `status()` - check registration status of an agent -//! - `benchmark()` - run a benchmark suite on an agent -//! -//! ## SSE Streaming -//! - `connect()` / `disconnect()` - SSE event streaming for real-time updates -//! - `is_connected()` / `connection_state()` - check connection status -//! -//! # Example -//! -//! ```rust,ignore -//! use jacs_binding_core::hai::{HaiClient, HaiError, HaiEvent}; -//! use jacs_binding_core::AgentWrapper; -//! -//! #[tokio::main] -//! async fn main() -> Result<(), HaiError> { -//! let client = HaiClient::new("https://api.hai.ai") -//! .with_api_key("your-api-key"); -//! -//! // Test connectivity -//! if client.testconnection().await? { -//! println!("Connected to HAI"); -//! } -//! -//! // Register an agent -//! let agent = AgentWrapper::new(); -//! agent.load("/path/to/config.json".to_string()).unwrap(); -//! let result = client.register(&agent).await?; -//! println!("Registered: {}", result.agent_id); -//! -//! // Connect to SSE stream and handle events -//! let mut receiver = client.connect().await?; -//! while let Some(event) = receiver.recv().await { -//! match event { -//! HaiEvent::BenchmarkJob(job) => println!("Job: {}", job.job_id), -//! HaiEvent::Heartbeat(hb) => println!("Heartbeat: {}", hb.timestamp), -//! HaiEvent::Unknown { event, data } => println!("Unknown: {} - {}", event, data), -//! } -//! } -//! -//! client.disconnect().await; -//! Ok(()) -//! } -//! ``` - -use crate::AgentWrapper; -use futures_util::StreamExt; -use serde::{Deserialize, Serialize}; -use std::fmt; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::{RwLock, mpsc}; - -// ============================================================================= -// Error Types -// ============================================================================= - -/// Errors that can occur when interacting with HAI services. -#[derive(Debug)] -pub enum HaiError { - /// Failed to connect to the HAI server. - ConnectionFailed(String), - /// Agent registration failed. - RegistrationFailed(String), - /// Authentication is required but not provided. - AuthRequired, - /// Invalid response from server. - InvalidResponse(String), - /// SSE stream disconnected. - StreamDisconnected(String), - /// Already connected to SSE stream. - AlreadyConnected, - /// Not connected to SSE stream. - NotConnected, - /// Validation error (e.g. verify link would exceed max URL length). - ValidationError(String), -} - -impl fmt::Display for HaiError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - HaiError::ConnectionFailed(msg) => write!(f, "Connection failed: {}", msg), - HaiError::RegistrationFailed(msg) => write!(f, "Registration failed: {}", msg), - HaiError::AuthRequired => write!(f, "Authentication required: provide an API key"), - HaiError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg), - HaiError::StreamDisconnected(msg) => write!(f, "SSE stream disconnected: {}", msg), - HaiError::AlreadyConnected => write!(f, "Already connected to SSE stream"), - HaiError::NotConnected => write!(f, "Not connected to SSE stream"), - HaiError::ValidationError(msg) => write!(f, "Validation error: {}", msg), - } - } -} - -// ============================================================================= -// Verify link (HAI / public verification URLs) -// ============================================================================= - -/// Maximum length for a full verify URL. Re-exported from jacs::simple for bindings. -pub const MAX_VERIFY_URL_LEN: usize = jacs::simple::MAX_VERIFY_URL_LEN; - -/// Maximum document size (UTF-8 bytes) for a verify link. Re-exported from jacs::simple. -pub const MAX_VERIFY_DOCUMENT_BYTES: usize = jacs::simple::MAX_VERIFY_DOCUMENT_BYTES; - -/// Build a verification URL for a signed JACS document (e.g. https://hai.ai/jacs/verify?s=...). -/// -/// Encodes `document` as URL-safe base64. Returns an error if the URL would exceed [`MAX_VERIFY_URL_LEN`]. -pub fn generate_verify_link(document: &str, base_url: &str) -> Result { - jacs::simple::generate_verify_link(document, base_url) - .map_err(|e| HaiError::ValidationError(e.to_string())) -} - -impl std::error::Error for HaiError {} - -// ============================================================================= -// SSE Event Types -// ============================================================================= - -/// A benchmark job received from the HAI event stream. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BenchmarkJob { - /// Unique identifier for the job. - pub job_id: String, - /// The benchmark scenario to run. - pub scenario: String, - /// Optional additional parameters for the job. - #[serde(default)] - pub params: serde_json::Value, -} - -/// A heartbeat event from the HAI event stream. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Heartbeat { - /// ISO 8601 timestamp of the heartbeat. - pub timestamp: String, -} - -/// Events received from the HAI SSE stream. -#[derive(Debug, Clone)] -pub enum HaiEvent { - /// A new benchmark job to execute. - BenchmarkJob(BenchmarkJob), - /// Heartbeat to confirm connection is alive. - Heartbeat(Heartbeat), - /// Unknown event type (forward compatibility). - Unknown { - /// The event type name. - event: String, - /// The raw JSON data. - data: String, - }, -} - -// ============================================================================= -// Response Types -// ============================================================================= - -/// Signature information returned from HAI registration. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HaiSignature { - /// Key identifier used for signing. - pub key_id: String, - /// Algorithm used (e.g., "Ed25519", "ECDSA-P256"). - pub algorithm: String, - /// Base64-encoded signature. - pub signature: String, - /// ISO 8601 timestamp of when the signature was created. - pub signed_at: String, -} - -/// Result of a successful agent registration with HAI. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegistrationResult { - /// The agent's unique identifier. - pub agent_id: String, - /// The JACS document ID assigned by HAI. - pub jacs_id: String, - /// Whether DNS verification was successful. - pub dns_verified: bool, - /// Signatures from HAI attesting to the registration. - pub signatures: Vec, -} - -/// Result of checking agent registration status with HAI. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StatusResult { - /// Whether the agent is registered with HAI.ai. - pub registered: bool, - /// The agent's JACS ID (if registered). - #[serde(default)] - pub agent_id: String, - /// HAI.ai registration ID (if registered). - #[serde(default)] - pub registration_id: String, - /// When the agent was registered (if registered), as ISO 8601 timestamp. - #[serde(default)] - pub registered_at: String, - /// List of HAI signature IDs (if registered). - #[serde(default)] - pub hai_signatures: Vec, -} - -/// Result of a benchmark run. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BenchmarkResult { - /// Unique identifier for the benchmark run. - pub run_id: String, - /// The benchmark suite that was run. - pub suite: String, - /// Overall score (0.0 to 1.0). - pub score: f64, - /// Individual test results within the suite. - #[serde(default)] - pub results: Vec, - /// ISO 8601 timestamp of when the benchmark completed. - pub completed_at: String, -} - -/// Individual test result within a benchmark run. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BenchmarkTestResult { - /// Test name. - pub name: String, - /// Whether the test passed. - pub passed: bool, - /// Test score (0.0 to 1.0). - pub score: f64, - /// Optional message (e.g., error details). - #[serde(default)] - pub message: String, -} - -// ============================================================================= -// Internal Request/Response Types -// ============================================================================= - -#[derive(Serialize)] -struct RegisterRequest { - agent_json: String, -} - -#[derive(Serialize)] -struct BenchmarkRequest { - agent_id: String, - suite: String, -} - -#[derive(Deserialize)] -struct HealthResponse { - status: String, -} - -// ============================================================================= -// HAI Client -// ============================================================================= - -/// Handle to control an active SSE connection. -/// -/// Drop this handle or call `abort()` to stop the SSE stream. -#[derive(Clone)] -pub struct SseHandle { - shutdown_tx: mpsc::Sender<()>, -} - -impl SseHandle { - /// Signal the SSE stream to disconnect. - pub async fn abort(&self) { - let _ = self.shutdown_tx.send(()).await; - } -} - -/// SSE connection state. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConnectionState { - /// Not connected to SSE stream. - Disconnected, - /// Attempting to connect. - Connecting, - /// Connected and receiving events. - Connected, - /// Reconnecting after a disconnect. - Reconnecting, -} - -/// Client for interacting with HAI.ai services. -/// -/// Use the builder pattern to configure the client: -/// ```rust,ignore -/// let client = HaiClient::new("https://api.hai.ai") -/// .with_api_key("your-key"); -/// ``` -pub struct HaiClient { - endpoint: String, - api_key: Option, - client: reqwest::Client, - /// Current SSE connection state. - connection_state: Arc>, - /// Handle to shutdown the SSE stream. - sse_handle: Arc>>, - /// Maximum reconnection attempts (0 = infinite). - max_reconnect_attempts: u32, - /// Base delay between reconnection attempts. - reconnect_delay: Duration, -} - -impl HaiClient { - /// Create a new HAI client targeting the specified endpoint. - /// - /// # Arguments - /// - /// * `endpoint` - Base URL of the HAI API (e.g., "https://api.hai.ai") - pub fn new(endpoint: &str) -> Self { - Self { - endpoint: endpoint.trim_end_matches('/').to_string(), - api_key: None, - client: reqwest::Client::new(), - connection_state: Arc::new(RwLock::new(ConnectionState::Disconnected)), - sse_handle: Arc::new(RwLock::new(None)), - max_reconnect_attempts: 0, // Infinite by default - reconnect_delay: Duration::from_secs(1), - } - } - - /// Set the API key for authentication. - /// - /// This is required for most operations. - pub fn with_api_key(mut self, api_key: &str) -> Self { - self.api_key = Some(api_key.to_string()); - self - } - - /// Set the maximum number of reconnection attempts. - /// - /// Set to 0 for infinite retries (default). - pub fn with_max_reconnect_attempts(mut self, attempts: u32) -> Self { - self.max_reconnect_attempts = attempts; - self - } - - /// Set the base delay between reconnection attempts. - /// - /// Default is 1 second. Uses exponential backoff up to 30 seconds. - pub fn with_reconnect_delay(mut self, delay: Duration) -> Self { - self.reconnect_delay = delay; - self - } - - /// Get the endpoint URL. - pub fn endpoint(&self) -> &str { - &self.endpoint - } - - /// Get the current SSE connection state. - pub async fn connection_state(&self) -> ConnectionState { - *self.connection_state.read().await - } - - /// Test connectivity to the HAI server. - /// - /// Returns `Ok(true)` if the server is reachable and healthy. - /// - /// # Errors - /// - /// Returns `HaiError::ConnectionFailed` if the server cannot be reached - /// or returns an unhealthy status. - pub async fn testconnection(&self) -> Result { - let url = format!("{}/health", self.endpoint); - - let response = self - .client - .get(&url) - .send() - .await - .map_err(|e| HaiError::ConnectionFailed(e.to_string()))?; - - if !response.status().is_success() { - return Err(HaiError::ConnectionFailed(format!( - "Server returned status: {}", - response.status() - ))); - } - - // Try to parse health response, but accept any 2xx as success - match response.json::().await { - Ok(health) => Ok(health.status == "ok" || health.status == "healthy"), - Err(_) => Ok(true), // 2xx without JSON body is still success - } - } - - /// Register a JACS agent with HAI. - /// - /// The agent must be loaded and have valid keys before registration. - /// - /// # Arguments - /// - /// * `agent` - A loaded `AgentWrapper` with valid cryptographic keys - /// - /// # Errors - /// - /// - `HaiError::AuthRequired` - No API key was provided - /// - `HaiError::RegistrationFailed` - The agent could not be registered - /// - `HaiError::InvalidResponse` - The server returned an unexpected response - pub async fn register(&self, agent: &AgentWrapper) -> Result { - let api_key = self.api_key.as_ref().ok_or(HaiError::AuthRequired)?; - - // Get the agent JSON from the wrapper - let agent_json = agent - .get_agent_json() - .map_err(|e| HaiError::RegistrationFailed(e.to_string()))?; - - let url = format!("{}/api/v1/agents/register", self.endpoint); - - let request = RegisterRequest { agent_json }; - - let response = self - .client - .post(&url) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Content-Type", "application/json") - .json(&request) - .send() - .await - .map_err(|e| HaiError::ConnectionFailed(e.to_string()))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response - .text() - .await - .unwrap_or_else(|_| "No response body".to_string()); - return Err(HaiError::RegistrationFailed(format!( - "Status {}: {}", - status, body - ))); - } - - response - .json::() - .await - .map_err(|e| HaiError::InvalidResponse(e.to_string())) - } - - /// Check registration status of an agent with HAI. - /// - /// Queries the HAI API to determine if the agent is registered - /// and retrieves registration details if so. - /// - /// # Arguments - /// - /// * `agent` - A loaded `AgentWrapper` to check status for - /// - /// # Returns - /// - /// `StatusResult` with registration details. If the agent is not registered, - /// `registered` will be `false`. - /// - /// # Errors - /// - /// - `HaiError::AuthRequired` - No API key was provided - /// - `HaiError::ConnectionFailed` - Could not connect to HAI server - /// - `HaiError::InvalidResponse` - The server returned an unexpected response - pub async fn status(&self, agent: &AgentWrapper) -> Result { - let api_key = self.api_key.as_ref().ok_or(HaiError::AuthRequired)?; - - // Get the agent JSON and extract the ID - let agent_json = agent - .get_agent_json() - .map_err(|e| HaiError::InvalidResponse(format!("Failed to get agent JSON: {}", e)))?; - - let agent_value: serde_json::Value = serde_json::from_str(&agent_json) - .map_err(|e| HaiError::InvalidResponse(format!("Failed to parse agent JSON: {}", e)))?; - - let agent_id = agent_value - .get("jacsId") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - HaiError::InvalidResponse("Agent JSON missing jacsId field".to_string()) - })? - .to_string(); - - let url = format!("{}/api/v1/agents/{}/status", self.endpoint, agent_id); - - let response = self - .client - .get(&url) - .header("Authorization", format!("Bearer {}", api_key)) - .send() - .await - .map_err(|e| HaiError::ConnectionFailed(e.to_string()))?; - - // Handle 404 as "not registered" - if response.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(StatusResult { - registered: false, - agent_id, - registration_id: String::new(), - registered_at: String::new(), - hai_signatures: Vec::new(), - }); - } - - if !response.status().is_success() { - let status = response.status(); - let body = response - .text() - .await - .unwrap_or_else(|_| "No response body".to_string()); - return Err(HaiError::InvalidResponse(format!( - "Status {}: {}", - status, body - ))); - } - - response - .json::() - .await - .map(|mut result| { - // Ensure registered is true for successful responses - result.registered = true; - if result.agent_id.is_empty() { - result.agent_id = agent_id; - } - result - }) - .map_err(|e| HaiError::InvalidResponse(e.to_string())) - } - - /// Run a benchmark suite for an agent. - /// - /// Submits the agent to run a specific benchmark suite and waits for results. - /// - /// # Arguments - /// - /// * `agent` - A loaded `AgentWrapper` to benchmark - /// * `suite` - The benchmark suite name (e.g., "latency", "accuracy", "safety") - /// - /// # Returns - /// - /// `BenchmarkResult` with the benchmark run details and scores. - /// - /// # Errors - /// - /// - `HaiError::AuthRequired` - No API key was provided - /// - `HaiError::ConnectionFailed` - Could not connect to HAI server - /// - `HaiError::InvalidResponse` - The server returned an unexpected response - pub async fn benchmark( - &self, - agent: &AgentWrapper, - suite: &str, - ) -> Result { - let api_key = self.api_key.as_ref().ok_or(HaiError::AuthRequired)?; - - // Get the agent ID from the wrapper - let agent_json = agent - .get_agent_json() - .map_err(|e| HaiError::InvalidResponse(format!("Failed to get agent JSON: {}", e)))?; - - let agent_value: serde_json::Value = serde_json::from_str(&agent_json) - .map_err(|e| HaiError::InvalidResponse(format!("Failed to parse agent JSON: {}", e)))?; - - let agent_id = agent_value - .get("jacsId") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - HaiError::InvalidResponse("Agent JSON missing jacsId field".to_string()) - })? - .to_string(); - - let url = format!("{}/api/v1/benchmarks/run", self.endpoint); - - let request = BenchmarkRequest { - agent_id, - suite: suite.to_string(), - }; - - let response = self - .client - .post(&url) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Content-Type", "application/json") - .json(&request) - .send() - .await - .map_err(|e| HaiError::ConnectionFailed(e.to_string()))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response - .text() - .await - .unwrap_or_else(|_| "No response body".to_string()); - return Err(HaiError::InvalidResponse(format!( - "Status {}: {}", - status, body - ))); - } - - response - .json::() - .await - .map_err(|e| HaiError::InvalidResponse(e.to_string())) - } - - // ========================================================================= - // SSE Connection Methods - // ========================================================================= - - /// Connect to the HAI SSE event stream. - /// - /// Returns a channel receiver that yields `HaiEvent`s as they arrive. - /// The connection will automatically attempt to reconnect on disconnection. - /// - /// # Errors - /// - /// - `HaiError::AuthRequired` - No API key was provided - /// - `HaiError::AlreadyConnected` - Already connected to SSE stream - /// - `HaiError::ConnectionFailed` - Initial connection failed - /// - /// # Example - /// - /// ```rust,ignore - /// let mut receiver = client.connect().await?; - /// while let Some(event) = receiver.recv().await { - /// println!("Received: {:?}", event); - /// } - /// ``` - pub async fn connect(&self) -> Result, HaiError> { - self.connect_to_url(&format!("{}/api/v1/agents/events", self.endpoint)) - .await - } - - /// Connect to a custom SSE endpoint URL. - /// - /// This is useful for testing or connecting to alternative event streams. - pub async fn connect_to_url(&self, url: &str) -> Result, HaiError> { - let api_key = self.api_key.as_ref().ok_or(HaiError::AuthRequired)?; - - // Check if already connected - { - let state = self.connection_state.read().await; - if *state != ConnectionState::Disconnected { - return Err(HaiError::AlreadyConnected); - } - } - - // Update state to connecting - { - let mut state = self.connection_state.write().await; - *state = ConnectionState::Connecting; - } - - // Create channels - let (event_tx, event_rx) = mpsc::channel::(100); - let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); - - // Store the handle - { - let mut handle = self.sse_handle.write().await; - *handle = Some(SseHandle { - shutdown_tx: shutdown_tx.clone(), - }); - } - - // Clone values for the spawned task - let client = self.client.clone(); - let url = url.to_string(); - let api_key = api_key.clone(); - let connection_state = self.connection_state.clone(); - let max_attempts = self.max_reconnect_attempts; - let base_delay = self.reconnect_delay; - - // Spawn the SSE reader task - tokio::spawn(async move { - let mut reconnect_attempts = 0u32; - - 'reconnect: loop { - // Attempt connection - let response = client - .get(&url) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Accept", "text/event-stream") - .header("Cache-Control", "no-cache") - .send() - .await; - - let response = match response { - Ok(r) if r.status().is_success() => { - // Reset reconnect attempts on successful connection - reconnect_attempts = 0; - { - let mut state = connection_state.write().await; - *state = ConnectionState::Connected; - } - r - } - Ok(r) => { - // Non-success status - let status = r.status(); - eprintln!("SSE connection failed with status: {}", status); - - if should_reconnect(max_attempts, reconnect_attempts) { - reconnect_attempts += 1; - let delay = calculate_backoff(base_delay, reconnect_attempts); - { - let mut state = connection_state.write().await; - *state = ConnectionState::Reconnecting; - } - - tokio::select! { - _ = tokio::time::sleep(delay) => continue 'reconnect, - _ = shutdown_rx.recv() => break 'reconnect, - } - } else { - break 'reconnect; - } - } - Err(e) => { - eprintln!("SSE connection error: {}", e); - - if should_reconnect(max_attempts, reconnect_attempts) { - reconnect_attempts += 1; - let delay = calculate_backoff(base_delay, reconnect_attempts); - { - let mut state = connection_state.write().await; - *state = ConnectionState::Reconnecting; - } - - tokio::select! { - _ = tokio::time::sleep(delay) => continue 'reconnect, - _ = shutdown_rx.recv() => break 'reconnect, - } - } else { - break 'reconnect; - } - } - }; - - // Process the SSE stream - let mut stream = response.bytes_stream(); - let mut buffer = String::new(); - let mut current_event = String::new(); - let mut current_data = String::new(); - - loop { - tokio::select! { - chunk = stream.next() => { - match chunk { - Some(Ok(bytes)) => { - buffer.push_str(&String::from_utf8_lossy(&bytes)); - - // Process complete lines - while let Some(newline_pos) = buffer.find('\n') { - let line = buffer[..newline_pos].trim_end_matches('\r').to_string(); - buffer = buffer[newline_pos + 1..].to_string(); - - if line.is_empty() { - // Empty line = end of event - if !current_data.is_empty() { - let event = parse_sse_event(¤t_event, ¤t_data); - if event_tx.send(event).await.is_err() { - // Receiver dropped, exit - break 'reconnect; - } - } - current_event.clear(); - current_data.clear(); - } else if let Some(value) = line.strip_prefix("event:") { - current_event = value.trim().to_string(); - } else if let Some(value) = line.strip_prefix("data:") { - if !current_data.is_empty() { - current_data.push('\n'); - } - current_data.push_str(value.trim()); - } - // Ignore id: and retry: fields for simplicity - } - } - Some(Err(e)) => { - eprintln!("SSE stream error: {}", e); - break; // Break inner loop to attempt reconnect - } - None => { - // Stream ended - break; // Break inner loop to attempt reconnect - } - } - } - _ = shutdown_rx.recv() => { - break 'reconnect; - } - } - } - - // Stream ended, attempt reconnect - if should_reconnect(max_attempts, reconnect_attempts) { - reconnect_attempts += 1; - let delay = calculate_backoff(base_delay, reconnect_attempts); - { - let mut state = connection_state.write().await; - *state = ConnectionState::Reconnecting; - } - - tokio::select! { - _ = tokio::time::sleep(delay) => continue 'reconnect, - _ = shutdown_rx.recv() => break 'reconnect, - } - } else { - break 'reconnect; - } - } - - // Clean up - { - let mut state = connection_state.write().await; - *state = ConnectionState::Disconnected; - } - }); - - Ok(event_rx) - } - - /// Disconnect from the SSE event stream. - /// - /// This is a no-op if not connected. - pub async fn disconnect(&self) { - let handle = { - let mut guard = self.sse_handle.write().await; - guard.take() - }; - - if let Some(h) = handle { - h.abort().await; - } - - // Wait for state to become disconnected - loop { - let state = *self.connection_state.read().await; - if state == ConnectionState::Disconnected { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - } - - /// Check if currently connected to the SSE stream. - pub async fn is_connected(&self) -> bool { - let state = *self.connection_state.read().await; - matches!( - state, - ConnectionState::Connected | ConnectionState::Reconnecting - ) - } -} - -// ============================================================================= -// SSE Helper Functions -// ============================================================================= - -/// Parse an SSE event into a `HaiEvent`. -fn parse_sse_event(event_type: &str, data: &str) -> HaiEvent { - match event_type { - "benchmark_job" => match serde_json::from_str::(data) { - Ok(job) => HaiEvent::BenchmarkJob(job), - Err(_) => HaiEvent::Unknown { - event: event_type.to_string(), - data: data.to_string(), - }, - }, - "heartbeat" => match serde_json::from_str::(data) { - Ok(hb) => HaiEvent::Heartbeat(hb), - Err(_) => HaiEvent::Unknown { - event: event_type.to_string(), - data: data.to_string(), - }, - }, - _ => HaiEvent::Unknown { - event: if event_type.is_empty() { - "message".to_string() - } else { - event_type.to_string() - }, - data: data.to_string(), - }, - } -} - -/// Determine if reconnection should be attempted. -fn should_reconnect(max_attempts: u32, current_attempts: u32) -> bool { - max_attempts == 0 || current_attempts < max_attempts -} - -/// Calculate exponential backoff delay. -fn calculate_backoff(base: Duration, attempt: u32) -> Duration { - let multiplier = 2u64.saturating_pow(attempt.min(5)); // Cap at 2^5 = 32x - let delay = base.saturating_mul(multiplier as u32); - delay.min(Duration::from_secs(30)) // Cap at 30 seconds -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_client_builder() { - let client = HaiClient::new("https://api.hai.ai").with_api_key("test-key"); - - assert_eq!(client.endpoint, "https://api.hai.ai"); - assert_eq!(client.api_key, Some("test-key".to_string())); - } - - #[test] - fn test_endpoint_normalization() { - let client = HaiClient::new("https://api.hai.ai/"); - assert_eq!(client.endpoint, "https://api.hai.ai"); - } - - #[test] - fn test_error_display() { - let err = HaiError::ConnectionFailed("timeout".to_string()); - assert_eq!(format!("{}", err), "Connection failed: timeout"); - - let err = HaiError::AuthRequired; - assert_eq!( - format!("{}", err), - "Authentication required: provide an API key" - ); - } - - #[test] - fn test_registration_result_serialization() { - let result = RegistrationResult { - agent_id: "agent-123".to_string(), - jacs_id: "jacs-456".to_string(), - dns_verified: true, - signatures: vec![HaiSignature { - key_id: "key-1".to_string(), - algorithm: "Ed25519".to_string(), - signature: "c2lnbmF0dXJl".to_string(), - signed_at: "2024-01-15T10:30:00Z".to_string(), - }], - }; - - let json = serde_json::to_string(&result).unwrap(); - let parsed: RegistrationResult = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.agent_id, "agent-123"); - assert_eq!(parsed.signatures.len(), 1); - } - - #[test] - fn test_status_result_serialization() { - let result = StatusResult { - registered: true, - agent_id: "agent-123".to_string(), - registration_id: "reg-456".to_string(), - registered_at: "2024-01-15T10:30:00Z".to_string(), - hai_signatures: vec!["sig-1".to_string(), "sig-2".to_string()], - }; - - let json = serde_json::to_string(&result).unwrap(); - let parsed: StatusResult = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.registered, true); - assert_eq!(parsed.agent_id, "agent-123"); - assert_eq!(parsed.registration_id, "reg-456"); - assert_eq!(parsed.hai_signatures.len(), 2); - } - - #[test] - fn test_status_result_not_registered() { - let result = StatusResult { - registered: false, - agent_id: "agent-123".to_string(), - registration_id: String::new(), - registered_at: String::new(), - hai_signatures: Vec::new(), - }; - - let json = serde_json::to_string(&result).unwrap(); - let parsed: StatusResult = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.registered, false); - assert_eq!(parsed.agent_id, "agent-123"); - assert!(parsed.registration_id.is_empty()); - } - - // ========================================================================= - // SSE Tests - // ========================================================================= - - #[test] - fn test_parse_sse_event_benchmark_job() { - let data = r#"{"job_id": "job-123", "scenario": "latency-test"}"#; - let event = parse_sse_event("benchmark_job", data); - - match event { - HaiEvent::BenchmarkJob(job) => { - assert_eq!(job.job_id, "job-123"); - assert_eq!(job.scenario, "latency-test"); - } - _ => panic!("Expected BenchmarkJob event"), - } - } - - #[test] - fn test_parse_sse_event_heartbeat() { - let data = r#"{"timestamp": "2024-01-15T10:30:00Z"}"#; - let event = parse_sse_event("heartbeat", data); - - match event { - HaiEvent::Heartbeat(hb) => { - assert_eq!(hb.timestamp, "2024-01-15T10:30:00Z"); - } - _ => panic!("Expected Heartbeat event"), - } - } - - #[test] - fn test_parse_sse_event_unknown() { - let data = r#"{"custom": "data"}"#; - let event = parse_sse_event("custom_event", data); - - match event { - HaiEvent::Unknown { event, data: d } => { - assert_eq!(event, "custom_event"); - assert_eq!(d, r#"{"custom": "data"}"#); - } - _ => panic!("Expected Unknown event"), - } - } - - #[test] - fn test_parse_sse_event_empty_type_defaults_to_message() { - let data = r#"{"some": "data"}"#; - let event = parse_sse_event("", data); - - match event { - HaiEvent::Unknown { event, .. } => { - assert_eq!(event, "message"); - } - _ => panic!("Expected Unknown event with 'message' type"), - } - } - - #[test] - fn test_parse_sse_event_invalid_json_becomes_unknown() { - let data = "not valid json"; - let event = parse_sse_event("benchmark_job", data); - - match event { - HaiEvent::Unknown { event, data: d } => { - assert_eq!(event, "benchmark_job"); - assert_eq!(d, "not valid json"); - } - _ => panic!("Expected Unknown event due to invalid JSON"), - } - } - - #[test] - fn test_should_reconnect_infinite() { - // max_attempts = 0 means infinite retries - assert!(should_reconnect(0, 0)); - assert!(should_reconnect(0, 100)); - assert!(should_reconnect(0, u32::MAX - 1)); - } - - #[test] - fn test_should_reconnect_limited() { - assert!(should_reconnect(3, 0)); - assert!(should_reconnect(3, 1)); - assert!(should_reconnect(3, 2)); - assert!(!should_reconnect(3, 3)); - assert!(!should_reconnect(3, 4)); - } - - #[test] - fn test_calculate_backoff() { - let base = Duration::from_secs(1); - - // First attempt: 1 * 2^1 = 2 seconds - assert_eq!(calculate_backoff(base, 1), Duration::from_secs(2)); - - // Second attempt: 1 * 2^2 = 4 seconds - assert_eq!(calculate_backoff(base, 2), Duration::from_secs(4)); - - // Third attempt: 1 * 2^3 = 8 seconds - assert_eq!(calculate_backoff(base, 3), Duration::from_secs(8)); - - // High attempts should cap at 30 seconds - assert_eq!(calculate_backoff(base, 10), Duration::from_secs(30)); - assert_eq!(calculate_backoff(base, 100), Duration::from_secs(30)); - } - - #[test] - fn test_benchmark_job_serialization() { - let job = BenchmarkJob { - job_id: "job-123".to_string(), - scenario: "latency".to_string(), - params: serde_json::json!({"timeout": 30}), - }; - - let json = serde_json::to_string(&job).unwrap(); - let parsed: BenchmarkJob = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.job_id, "job-123"); - assert_eq!(parsed.scenario, "latency"); - assert_eq!(parsed.params["timeout"], 30); - } - - #[test] - fn test_benchmark_result_serialization() { - let result = BenchmarkResult { - run_id: "run-123".to_string(), - suite: "accuracy".to_string(), - score: 0.95, - results: vec![ - BenchmarkTestResult { - name: "test-1".to_string(), - passed: true, - score: 1.0, - message: String::new(), - }, - BenchmarkTestResult { - name: "test-2".to_string(), - passed: false, - score: 0.9, - message: "Minor deviation".to_string(), - }, - ], - completed_at: "2024-01-15T10:30:00Z".to_string(), - }; - - let json = serde_json::to_string(&result).unwrap(); - let parsed: BenchmarkResult = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.run_id, "run-123"); - assert_eq!(parsed.suite, "accuracy"); - assert!((parsed.score - 0.95).abs() < f64::EPSILON); - assert_eq!(parsed.results.len(), 2); - assert_eq!(parsed.results[0].name, "test-1"); - assert!(parsed.results[0].passed); - assert!(!parsed.results[1].passed); - assert_eq!(parsed.results[1].message, "Minor deviation"); - } - - #[test] - fn test_heartbeat_serialization() { - let hb = Heartbeat { - timestamp: "2024-01-15T10:30:00Z".to_string(), - }; - - let json = serde_json::to_string(&hb).unwrap(); - let parsed: Heartbeat = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.timestamp, "2024-01-15T10:30:00Z"); - } - - #[test] - fn test_sse_error_display() { - let err = HaiError::StreamDisconnected("timeout".to_string()); - assert_eq!(format!("{}", err), "SSE stream disconnected: timeout"); - - let err = HaiError::AlreadyConnected; - assert_eq!(format!("{}", err), "Already connected to SSE stream"); - - let err = HaiError::NotConnected; - assert_eq!(format!("{}", err), "Not connected to SSE stream"); - } - - #[test] - fn test_connection_state_default() { - let client = HaiClient::new("https://api.hai.ai"); - - // Can't test async state easily in sync test, but we can verify - // the client was created with default settings - assert_eq!(client.max_reconnect_attempts, 0); - assert_eq!(client.reconnect_delay, Duration::from_secs(1)); - } - - #[test] - fn test_client_builder_with_sse_options() { - let client = HaiClient::new("https://api.hai.ai") - .with_api_key("test-key") - .with_max_reconnect_attempts(5) - .with_reconnect_delay(Duration::from_millis(500)); - - assert_eq!(client.max_reconnect_attempts, 5); - assert_eq!(client.reconnect_delay, Duration::from_millis(500)); - } - - #[tokio::test] - async fn test_connect_requires_api_key() { - let client = HaiClient::new("https://api.hai.ai"); - // No API key set - - let result = client.connect().await; - assert!(matches!(result, Err(HaiError::AuthRequired))); - } - - #[tokio::test] - async fn test_connection_state_starts_disconnected() { - let client = HaiClient::new("https://api.hai.ai").with_api_key("test-key"); - - let state = client.connection_state().await; - assert_eq!(state, ConnectionState::Disconnected); - } - - #[tokio::test] - async fn test_is_connected_when_disconnected() { - let client = HaiClient::new("https://api.hai.ai").with_api_key("test-key"); - - assert!(!client.is_connected().await); - } - - #[tokio::test] - async fn test_disconnect_when_not_connected() { - let client = HaiClient::new("https://api.hai.ai").with_api_key("test-key"); - - // Should be a no-op, not panic - client.disconnect().await; - assert_eq!( - client.connection_state().await, - ConnectionState::Disconnected - ); - } -} diff --git a/binding-core/src/lib.rs b/binding-core/src/lib.rs index 92648555d..6e263c28b 100644 --- a/binding-core/src/lib.rs +++ b/binding-core/src/lib.rs @@ -7,6 +7,7 @@ //! to convert errors to their native format. use jacs::agent::agreement::Agreement; +use jacs::agent::boilerplate::BoilerPlate; use jacs::agent::document::{DocumentTraits, JACSDocument}; use jacs::agent::payloads::PayloadTraits; use jacs::agent::{ @@ -22,9 +23,6 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; pub mod conversion; -#[cfg(feature = "hai")] -pub mod hai; - /// Error type for binding core operations. /// /// This is the internal error type that binding implementations convert @@ -253,6 +251,14 @@ impl AgentWrapper { } } + /// Create an agent wrapper from an existing Arc>. + /// + /// This is used by the Go FFI to share the agent handle's inner agent + /// with binding-core's attestation methods. + pub fn from_inner(inner: Arc>) -> Self { + Self { inner } + } + /// Get a locked reference to the inner agent. fn lock(&self) -> BindingResult> { self.inner.lock().map_err(BindingCoreError::from) @@ -432,14 +438,22 @@ impl AgentWrapper { BindingCoreError::verification_failed(format!("Failed to verify document hash: {}", e)) })?; - agent - .verify_external_document_signature(&document_key) - .map_err(|e| { - BindingCoreError::verification_failed(format!( - "Failed to verify document signature: {}", - e - )) - })?; + // Prefer the currently loaded agent's public key first. This keeps + // local self-verification fast and avoids falling through to remote key + // resolution for documents we just signed in the same workspace. + if agent + .verify_document_signature(&document_key, None, None, None, None) + .is_err() + { + agent + .verify_external_document_signature(&document_key) + .map_err(|e| { + BindingCoreError::verification_failed(format!( + "Failed to verify document signature: {}", + e + )) + })?; + } Ok(true) } @@ -607,6 +621,36 @@ impl AgentWrapper { .map_err(|e| BindingCoreError::document_failed(format!("Failed to create document: {}", e))) } + /// Persist an already-signed JACS document and return its lookup key. + pub fn save_signed_document( + &self, + document_string: &str, + outputfilename: Option, + export_embedded: Option, + extract_only: Option, + ) -> BindingResult { + let mut agent = self.lock()?; + let doc = agent.load_document(document_string).map_err(|e| { + BindingCoreError::document_failed(format!("Failed to load signed document: {}", e)) + })?; + let document_key = doc.getkey(); + agent + .save_document(&document_key, outputfilename, export_embedded, extract_only) + .map_err(|e| { + BindingCoreError::document_failed(format!( + "Failed to persist signed document '{}': {}", + document_key, e + )) + })?; + Ok(document_key) + } + + /// Return all known document lookup keys from the agent's configured storage. + pub fn list_document_keys(&self) -> BindingResult> { + let mut agent = self.lock()?; + Ok(agent.get_document_keys()) + } + /// Check an agreement on a document. pub fn check_agreement( &self, @@ -780,6 +824,51 @@ impl AgentWrapper { self.verify_document(&doc_str) } + /// Load a document by ID from the agent's configured storage. + /// + /// The document ID should be in "uuid:version" format. + pub fn get_document_by_id(&self, document_id: &str) -> BindingResult { + if !document_id.contains(':') { + return Err(BindingCoreError::invalid_argument(format!( + "Document ID must be in 'uuid:version' format, got '{}'.", + document_id + ))); + } + + let agent = self.lock()?; + let doc = agent.get_document(document_id).map_err(|e| { + BindingCoreError::document_failed(format!( + "Failed to load document '{}' from storage: {}", + document_id, e + )) + })?; + + serde_json::to_string(&doc.value).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize document '{}': {}", + document_id, e + )) + }) + } + + /// Get the loaded agent's canonical JACS identifier. + pub fn get_agent_id(&self) -> BindingResult { + let agent = self.lock()?; + let value = agent + .get_value() + .ok_or_else(|| BindingCoreError::agent_load("Agent not loaded. Call load() first."))?; + value + .get("jacsId") + .and_then(|v| v.as_str()) + .map(str::to_string) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + BindingCoreError::agent_load( + "Agent not loaded or has no jacsId. Call load() first.", + ) + }) + } + /// Re-encrypt the agent's private key with a new password. /// /// Reads the encrypted private key file, decrypts with old_password, @@ -828,10 +917,10 @@ impl AgentWrapper { /// /// Replaces the inner agent with a freshly created ephemeral agent that /// lives entirely in memory. Returns a JSON string with agent info - /// (agent_id, name, version, algorithm). + /// (agent_id, name, version, algorithm). Default algorithm is `pq2025`. pub fn ephemeral(&self, algorithm: Option<&str>) -> BindingResult { // Map user-friendly names to internal algorithm strings - let algo = match algorithm.unwrap_or("ed25519") { + let algo = match algorithm.unwrap_or("pq2025") { "ed25519" => "ring-Ed25519", "rsa-pss" => "RSA-PSS", "pq2025" => "pq2025", @@ -920,8 +1009,7 @@ impl AgentWrapper { serde_json::to_string_pretty(&info).unwrap_or_default() } - /// Returns setup instructions for publishing DNS records, enabling DNSSEC, - /// and registering with HAI.ai. + /// Returns setup instructions for publishing DNS records and enabling DNSSEC. /// /// Requires a loaded agent (call `load()` first). pub fn get_setup_instructions(&self, domain: &str, ttl: u32) -> BindingResult { @@ -981,21 +1069,6 @@ impl AgentWrapper { }); let well_known_json = serde_json::to_string_pretty(&well_known).unwrap_or_default(); - let hai_url = - std::env::var("HAI_API_URL").unwrap_or_else(|_| "https://api.hai.ai".to_string()); - let hai_registration_url = format!("{}/v1/agents", hai_url.trim_end_matches('/')); - let hai_payload = json!({ - "agent_id": agent_id, - "public_key_hash": digest, - "domain": domain, - }); - let hai_registration_payload = - serde_json::to_string_pretty(&hai_payload).unwrap_or_default(); - let hai_registration_instructions = format!( - "POST the payload to {} with your HAI API key in the Authorization header.", - hai_registration_url - ); - let summary = format!( "Setup instructions for agent {agent_id} on domain {domain}:\n\ \n\ @@ -1006,15 +1079,12 @@ impl AgentWrapper { \n\ 3. Domain requirement: {tld}\n\ \n\ - 4. .well-known: Serve the well-known JSON at /.well-known/jacs-agent.json\n\ - \n\ - 5. HAI registration: {hai_instr}", + 4. .well-known: Serve the well-known JSON at /.well-known/jacs-agent.json", agent_id = agent_id, domain = domain, bind = dns_record_bind, dnssec = dnssec_guidance("aws"), tld = tld_requirement, - hai_instr = hai_registration_instructions, ); let result = json!({ @@ -1025,9 +1095,6 @@ impl AgentWrapper { "dnssec_instructions": dnssec_instructions, "tld_requirement": tld_requirement, "well_known_json": well_known_json, - "hai_registration_url": hai_registration_url, - "hai_registration_payload": hai_registration_payload, - "hai_registration_instructions": hai_registration_instructions, "summary": summary, }); @@ -1039,99 +1106,460 @@ impl AgentWrapper { }) } - /// Register this agent with HAI.ai. + /// Get the agent's JSON representation as a string. /// - /// If `preview` is true, returns a preview without actually registering. - #[cfg(not(target_arch = "wasm32"))] - pub fn register_with_hai( + /// Returns the agent's full JSON document. + pub fn get_agent_json(&self) -> BindingResult { + let agent = self.lock()?; + match agent.get_value() { + Some(value) => Ok(value.to_string()), + None => Err(BindingCoreError::agent_load( + "Agent not loaded. Call load() first.", + )), + } + } + + // ========================================================================= + // A2A Protocol Methods + // ========================================================================= + + /// Export this agent as an A2A Agent Card (v0.4.0). + /// + /// Returns the Agent Card as a JSON string. + pub fn export_agent_card(&self) -> BindingResult { + let agent = self.lock()?; + let card = jacs::a2a::agent_card::export_agent_card(&agent).map_err(|e| { + BindingCoreError::generic(format!("Failed to export agent card: {}", e)) + })?; + serde_json::to_string_pretty(&card).map_err(|e| { + BindingCoreError::serialization_failed(format!("Failed to serialize agent card: {}", e)) + }) + } + + /// Generate all .well-known documents for A2A discovery. + /// + /// Returns a JSON string containing an array of [path, document] pairs. + pub fn generate_well_known_documents( &self, - api_key: Option<&str>, - hai_url: &str, - preview: bool, + a2a_algorithm: Option<&str>, ) -> BindingResult { - if preview { - let result = json!({ - "hai_registered": false, - "hai_error": "preview mode", - "dns_record": "", - "dns_route53": "", - }); - return serde_json::to_string_pretty(&result).map_err(|e| { - BindingCoreError::serialization_failed(format!("Failed to serialize: {}", e)) - }); + let agent = self.lock()?; + let card = jacs::a2a::agent_card::export_agent_card(&agent).map_err(|e| { + BindingCoreError::generic(format!("Failed to export agent card: {}", e)) + })?; + + let a2a_alg = a2a_algorithm.unwrap_or("ring-Ed25519"); + let dual_keys = jacs::a2a::keys::create_jwk_keys(None, Some(a2a_alg)).map_err(|e| { + BindingCoreError::generic(format!("Failed to generate A2A keys: {}", e)) + })?; + + let agent_id = agent + .get_id() + .map_err(|e| BindingCoreError::generic(format!("Failed to get agent ID: {}", e)))?; + + let jws = jacs::a2a::extension::sign_agent_card_jws( + &card, + &dual_keys.a2a_private_key, + &dual_keys.a2a_algorithm, + &agent_id, + ) + .map_err(|e| BindingCoreError::generic(format!("Failed to sign Agent Card: {}", e)))?; + + let documents = jacs::a2a::extension::generate_well_known_documents( + &agent, + &card, + &dual_keys.a2a_public_key, + &dual_keys.a2a_algorithm, + &jws, + ) + .map_err(|e| { + BindingCoreError::generic(format!("Failed to generate well-known documents: {}", e)) + })?; + + // Serialize as JSON array of [path, document] pairs + let pairs: Vec = documents + .into_iter() + .map(|(path, doc)| serde_json::json!({ "path": path, "document": doc })) + .collect(); + serde_json::to_string_pretty(&pairs).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize well-known documents: {}", + e + )) + }) + } + + /// Wrap an A2A artifact with JACS provenance signature. + /// + /// Returns the signed wrapped artifact as a JSON string. + #[deprecated(since = "0.9.0", note = "Use sign_artifact() instead")] + pub fn wrap_a2a_artifact( + &self, + artifact_json: &str, + artifact_type: &str, + parent_signatures_json: Option<&str>, + ) -> BindingResult { + if std::env::var("JACS_SHOW_DEPRECATIONS").is_ok() { + tracing::warn!("wrap_a2a_artifact is deprecated, use sign_artifact instead"); } - let key = match api_key { - Some(k) => k.to_string(), - None => std::env::var("HAI_API_KEY").map_err(|_| { - BindingCoreError::invalid_argument( - "No API key provided and HAI_API_KEY environment variable not set", + let artifact: Value = serde_json::from_str(artifact_json).map_err(|e| { + BindingCoreError::invalid_argument(format!("Invalid artifact JSON: {}", e)) + })?; + + let parent_signatures: Option> = match parent_signatures_json { + Some(json_str) => { + let parsed: Vec = serde_json::from_str(json_str).map_err(|e| { + BindingCoreError::invalid_argument(format!( + "Invalid parent signatures JSON array: {}", + e + )) + })?; + Some(parsed) + } + None => None, + }; + + let mut agent = self.lock()?; + let wrapped = jacs::a2a::provenance::wrap_artifact_with_provenance( + &mut agent, + artifact, + artifact_type, + parent_signatures, + ) + .map_err(|e| BindingCoreError::signing_failed(format!("Failed to wrap artifact: {}", e)))?; + + serde_json::to_string_pretty(&wrapped).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize wrapped artifact: {}", + e + )) + }) + } + + /// Sign an A2A artifact with JACS provenance. + /// + /// This is the recommended primary API, replacing the deprecated + /// [`wrap_a2a_artifact`](Self::wrap_a2a_artifact). + pub fn sign_artifact( + &self, + artifact_json: &str, + artifact_type: &str, + parent_signatures_json: Option<&str>, + ) -> BindingResult { + #[allow(deprecated)] + self.wrap_a2a_artifact(artifact_json, artifact_type, parent_signatures_json) + } + + /// Verify a JACS-wrapped A2A artifact. + /// + /// Returns the verification result as a JSON string. + pub fn verify_a2a_artifact(&self, wrapped_json: &str) -> BindingResult { + let wrapped: Value = serde_json::from_str(wrapped_json).map_err(|e| { + BindingCoreError::invalid_argument(format!("Invalid wrapped artifact JSON: {}", e)) + })?; + + let agent = self.lock()?; + let result = + jacs::a2a::provenance::verify_wrapped_artifact(&agent, &wrapped).map_err(|e| { + BindingCoreError::verification_failed(format!( + "A2A artifact verification error: {}", + e + )) + })?; + + serde_json::to_string_pretty(&result).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize verification result: {}", + e + )) + }) + } + /// Assess trust level of a remote A2A agent given its Agent Card JSON. + /// + /// Returns the trust assessment as a JSON string. + pub fn assess_a2a_agent(&self, agent_card_json: &str, policy: &str) -> BindingResult { + use jacs::a2a::AgentCard; + use jacs::a2a::trust::{A2ATrustPolicy, assess_a2a_agent}; + + let card: AgentCard = serde_json::from_str(agent_card_json).map_err(|e| { + BindingCoreError::invalid_argument(format!("Invalid Agent Card JSON: {}", e)) + })?; + + let trust_policy = A2ATrustPolicy::from_str_loose(policy).map_err(|e| { + BindingCoreError::invalid_argument(format!("Invalid trust policy '{}': {}", policy, e)) + })?; + + let agent = self.lock()?; + let assessment = assess_a2a_agent(&agent, &card, trust_policy); + + serde_json::to_string_pretty(&assessment).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize trust assessment: {}", + e + )) + }) + } + + /// Verify a JACS-wrapped A2A artifact with trust policy enforcement. + /// + /// Combines cryptographic signature verification with trust policy evaluation. + /// The remote agent's Agent Card is assessed against the specified policy, + /// and the trust level is included in the verification result. + /// + /// # Arguments + /// + /// * `wrapped_json` - JSON string of the JACS-wrapped artifact + /// * `agent_card_json` - JSON string of the remote agent's A2A Agent Card + /// * `policy` - Trust policy name: "open", "verified", or "strict" + /// + /// # Returns + /// + /// JSON string containing the verification result with trust information. + pub fn verify_a2a_artifact_with_policy( + &self, + wrapped_json: &str, + agent_card_json: &str, + policy: &str, + ) -> BindingResult { + use jacs::a2a::AgentCard; + use jacs::a2a::trust::A2ATrustPolicy; + + let wrapped: Value = serde_json::from_str(wrapped_json).map_err(|e| { + BindingCoreError::invalid_argument(format!("Invalid wrapped artifact JSON: {}", e)) + })?; + + let card: AgentCard = serde_json::from_str(agent_card_json).map_err(|e| { + BindingCoreError::invalid_argument(format!("Invalid Agent Card JSON: {}", e)) + })?; + + let trust_policy = A2ATrustPolicy::from_str_loose(policy).map_err(|e| { + BindingCoreError::invalid_argument(format!("Invalid trust policy '{}': {}", policy, e)) + })?; + + let agent = self.lock()?; + let result = jacs::a2a::provenance::verify_wrapped_artifact_with_policy( + &agent, + &wrapped, + &card, + trust_policy, + ) + .map_err(|e| { + BindingCoreError::verification_failed(format!( + "A2A artifact verification with policy error: {}", + e + )) + })?; + + serde_json::to_string_pretty(&result).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize verification result: {}", + e + )) + }) + } + + // ========================================================================= + // Attestation API (gated behind `attestation` feature) + // ========================================================================= + + /// Create a signed attestation document from JSON parameters. + /// + /// The `params_json` string must be a JSON object with: + /// - `subject` (required): `{ type, id, digests: { sha256, ... } }` + /// - `claims` (required): `[{ name, value, confidence?, assuranceLevel?, ... }]` + /// - `evidence` (optional): array of evidence references + /// - `derivation` (optional): derivation/transform receipt + /// - `policyContext` (optional): policy evaluation context + /// + /// Returns the signed attestation document as a JSON string. + #[cfg(feature = "attestation")] + pub fn create_attestation(&self, params_json: &str) -> BindingResult { + use jacs::attestation::AttestationTraits; + use jacs::attestation::types::*; + + let params: Value = serde_json::from_str(params_json).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to parse attestation params JSON: {}. \ + Provide a valid JSON object with 'subject' and 'claims' fields.", + e + )) + })?; + + // Parse subject (required) + let subject: AttestationSubject = + serde_json::from_value(params.get("subject").cloned().ok_or_else(|| { + BindingCoreError::validation( + "Missing required 'subject' field in attestation params", + ) + })?) + .map_err(|e| BindingCoreError::validation(format!("Invalid 'subject' field: {}", e)))?; + + // Parse claims (required, at least 1) + let claims: Vec = + serde_json::from_value(params.get("claims").cloned().ok_or_else(|| { + BindingCoreError::validation( + "Missing required 'claims' field in attestation params", ) - })?, + })?) + .map_err(|e| BindingCoreError::validation(format!("Invalid 'claims' field: {}", e)))?; + + // Parse optional evidence + let evidence: Vec = if let Some(ev) = params.get("evidence") { + serde_json::from_value(ev.clone()).map_err(|e| { + BindingCoreError::validation(format!("Invalid 'evidence' field: {}", e)) + })? + } else { + vec![] }; - let agent_json = self.get_agent_json()?; - let url = format!("{}/api/v1/agents/register", hai_url.trim_end_matches('/')); + // Parse optional derivation + let derivation: Option = if let Some(d) = params.get("derivation") { + Some(serde_json::from_value(d.clone()).map_err(|e| { + BindingCoreError::validation(format!("Invalid 'derivation' field: {}", e)) + })?) + } else { + None + }; + + // Parse optional policyContext + let policy_context: Option = if let Some(p) = params.get("policyContext") { + Some(serde_json::from_value(p.clone()).map_err(|e| { + BindingCoreError::validation(format!("Invalid 'policyContext' field: {}", e)) + })?) + } else { + None + }; - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() + let mut agent = self.lock()?; + let jacs_doc = agent + .create_attestation( + &subject, + &claims, + &evidence, + derivation.as_ref(), + policy_context.as_ref(), + ) .map_err(|e| { - BindingCoreError::network_failed(format!("Failed to build HTTP client: {}", e)) + BindingCoreError::document_failed(format!("Failed to create attestation: {}", e)) })?; - let response = client - .post(&url) - .header("Authorization", format!("Bearer {}", key)) - .header("Content-Type", "application/json") - .json(&json!({ "agent_json": agent_json })) - .send() + serde_json::to_string_pretty(&jacs_doc.value).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize attestation: {}", + e + )) + }) + } + + /// Verify an attestation using local (crypto-only) verification. + /// + /// Takes the attestation document key in "id:version" format. + /// Returns the verification result as a JSON string. + #[cfg(feature = "attestation")] + pub fn verify_attestation(&self, document_key: &str) -> BindingResult { + let agent = self.lock()?; + let result = agent + .verify_attestation_local_impl(document_key) .map_err(|e| { - BindingCoreError::network_failed(format!("HAI registration request failed: {}", e)) + BindingCoreError::verification_failed(format!( + "Attestation local verification failed: {}", + e + )) })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().unwrap_or_default(); - let result = json!({ - "hai_registered": false, - "hai_error": format!("HTTP {}: {}", status, body), - "dns_record": "", - "dns_route53": "", - }); - return serde_json::to_string_pretty(&result).map_err(|e| { - BindingCoreError::serialization_failed(format!("Failed to serialize: {}", e)) - }); - } - - let body: Value = response.json().map_err(|e| { - BindingCoreError::network_failed(format!("Failed to parse HAI response: {}", e)) - })?; + serde_json::to_string_pretty(&result).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize verification result: {}", + e + )) + }) + } - let result = json!({ - "hai_registered": true, - "hai_error": "", - "dns_record": body.get("dns_record").and_then(|v| v.as_str()).unwrap_or_default(), - "dns_route53": body.get("dns_route53").and_then(|v| v.as_str()).unwrap_or_default(), - }); + /// Verify an attestation using full verification (evidence + chain). + /// + /// Takes the attestation document key in "id:version" format. + /// Returns the verification result as a JSON string. + #[cfg(feature = "attestation")] + pub fn verify_attestation_full(&self, document_key: &str) -> BindingResult { + let agent = self.lock()?; + let result = agent + .verify_attestation_full_impl(document_key) + .map_err(|e| { + BindingCoreError::verification_failed(format!( + "Attestation full verification failed: {}", + e + )) + })?; serde_json::to_string_pretty(&result).map_err(|e| { - BindingCoreError::serialization_failed(format!("Failed to serialize: {}", e)) + BindingCoreError::serialization_failed(format!( + "Failed to serialize verification result: {}", + e + )) }) } - /// Get the agent's JSON representation as a string. + /// Lift an existing signed JACS document into an attestation. /// - /// Returns the agent's full JSON document, suitable for registration - /// with external services like HAI. - pub fn get_agent_json(&self) -> BindingResult { - let agent = self.lock()?; - match agent.get_value() { - Some(value) => Ok(value.to_string()), - None => Err(BindingCoreError::agent_load( - "Agent not loaded. Call load() first.", - )), - } + /// Takes a signed document JSON string and claims JSON string. + /// Returns the signed attestation document as a JSON string. + #[cfg(feature = "attestation")] + pub fn lift_to_attestation( + &self, + signed_doc_json: &str, + claims_json: &str, + ) -> BindingResult { + use jacs::attestation::types::Claim; + + let claims: Vec = serde_json::from_str(claims_json).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to parse claims JSON: {}. \ + Provide a valid JSON array of claim objects.", + e + )) + })?; + + let mut agent = self.lock()?; + let jacs_doc = + jacs::attestation::migration::lift_to_attestation(&mut agent, signed_doc_json, &claims) + .map_err(|e| { + BindingCoreError::document_failed(format!( + "Failed to lift document to attestation: {}", + e + )) + })?; + + serde_json::to_string_pretty(&jacs_doc.value).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize attestation: {}", + e + )) + }) + } + + /// Export a signed attestation as a DSSE (Dead Simple Signing Envelope). + /// + /// Takes the attestation JSON string and returns a DSSE envelope JSON string. + #[cfg(feature = "attestation")] + pub fn export_attestation_dsse(&self, attestation_json: &str) -> BindingResult { + let att_value: Value = serde_json::from_str(attestation_json).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to parse attestation JSON: {}", + e + )) + })?; + + let envelope = jacs::attestation::dsse::export_dsse(&att_value).map_err(|e| { + BindingCoreError::document_failed(format!("Failed to export DSSE envelope: {}", e)) + })?; + + serde_json::to_string_pretty(&envelope).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize DSSE envelope: {}", + e + )) + }) } } @@ -1171,7 +1599,7 @@ pub struct VerificationResult { /// # Arguments /// /// * `signed_document` - Full signed JACS document JSON string. -/// * `key_resolution` - Optional key resolution order, e.g. "local" or "local,hai" (default "local"). +/// * `key_resolution` - Optional key resolution order, e.g. "local" or "local,remote" (default "local"). /// * `data_directory` - Optional path for data/trust store (defaults to temp/cwd). /// * `key_directory` - Optional path for public keys (defaults to temp/cwd). /// @@ -1186,13 +1614,17 @@ pub fn verify_document_standalone( data_directory: Option<&str>, key_directory: Option<&str>, ) -> BindingResult { + use std::collections::HashSet; + use std::path::{Path, PathBuf}; + use std::sync::{Mutex, OnceLock}; + fn absolutize_dir(raw: &str) -> String { - let p = std::path::PathBuf::from(raw); + let p = PathBuf::from(raw); if p.is_absolute() { p.to_string_lossy().to_string() } else { std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .unwrap_or_else(|_| PathBuf::from(".")) .join(p) .to_string_lossy() .to_string() @@ -1211,9 +1643,108 @@ pub fn verify_document_standalone( .unwrap_or_default() } + fn has_local_key_cache(root: &Path, key_hash: &str) -> bool { + if key_hash.is_empty() { + return false; + } + root.join("public_keys") + .join(format!("{}.pem", key_hash)) + .exists() + && root + .join("public_keys") + .join(format!("{}.enc_type", key_hash)) + .exists() + } + + fn build_fixture_key_cache(cache_root: &Path, source_dirs: &[PathBuf]) -> usize { + let public_keys_dir = cache_root.join("public_keys"); + if std::fs::create_dir_all(&public_keys_dir).is_err() { + return 0; + } + + let mut written: HashSet = HashSet::new(); + for dir in source_dirs { + let entries = match std::fs::read_dir(dir) { + Ok(v) => v, + Err(_) => continue, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + let Some(prefix) = name.strip_suffix("_metadata.json") else { + continue; + }; + + let metadata = match std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + { + Some(v) => v, + None => continue, + }; + let key_hash = metadata + .get("public_key_hash") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let signing_algorithm = metadata + .get("signing_algorithm") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if key_hash.is_empty() || signing_algorithm.is_empty() { + continue; + } + if written.contains(key_hash) { + continue; + } + + let key_path = dir.join(format!("{}_public_key.pem", prefix)); + let key_bytes = match std::fs::read(&key_path) { + Ok(v) => v, + Err(_) => continue, + }; + + if std::fs::write(public_keys_dir.join(format!("{}.pem", key_hash)), key_bytes) + .is_err() + { + continue; + } + if std::fs::write( + public_keys_dir.join(format!("{}.enc_type", key_hash)), + signing_algorithm.as_bytes(), + ) + .is_err() + { + continue; + } + + written.insert(key_hash.to_string()); + } + } + + written.len() + } + + fn standalone_verify_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + let _lock = standalone_verify_lock() + .lock() + .map_err(|e| BindingCoreError::generic(format!("Failed to lock standalone verify: {e}")))?; + let signer_id = sig_field(signed_document, "agentID"); let timestamp = sig_field(signed_document, "date"); let agent_version = sig_field(signed_document, "agentVersion"); + let signer_public_key_hash = sig_field(signed_document, "publicKeyHash"); // Always resolve caller-provided directories to absolute paths so relative // inputs like "../fixtures" work regardless of process CWD. @@ -1230,16 +1761,65 @@ pub fn verify_document_standalone( // Verification loads public keys from {data_directory}/public_keys. // If only key_directory is supplied, use it as the storage root fallback. - let storage_root = if data_directory.is_some() { + let mut effective_storage_root = if data_directory.is_some() { absolute_data_dir.clone() } else if key_directory.is_some() { absolute_key_dir.clone() } else { absolute_data_dir.clone() }; + let mut temp_cache_root: Option = None; + + // Many cross-language fixture directories store keys as: + // _metadata.json + _public_key.pem + // rather than public_keys/{hash}.pem. + // Build a deterministic temp cache when local key files are missing. + let local_requested = key_resolution.map_or(true, |kr| { + kr.split(',') + .any(|part| part.trim().eq_ignore_ascii_case("local")) + }); + if local_requested && !signer_public_key_hash.is_empty() { + let current_root = PathBuf::from(&effective_storage_root); + if !has_local_key_cache(¤t_root, &signer_public_key_hash) { + let mut source_dirs = Vec::new(); + let data_path = PathBuf::from(&absolute_data_dir); + let key_path = PathBuf::from(&absolute_key_dir); + if data_path.exists() { + source_dirs.push(data_path); + } + if key_path.exists() && !source_dirs.iter().any(|p| p == &key_path) { + source_dirs.push(key_path); + } - // Re-root storage and keep config dirs empty so path construction remains - // relative to storage_root (e.g., "public_keys/.pem"). + if !source_dirs.is_empty() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let cache_root = std::env::temp_dir().join(format!( + "jacs_standalone_keycache_{}_{}", + std::process::id(), + nonce + )); + let _ = build_fixture_key_cache(&cache_root, &source_dirs); + if has_local_key_cache(&cache_root, &signer_public_key_hash) { + effective_storage_root = cache_root.to_string_lossy().to_string(); + temp_cache_root = Some(cache_root); + } else { + let _ = std::fs::remove_dir_all(&cache_root); + } + } + } + } + let explicit_local_key_available = local_requested + && !signer_public_key_hash.is_empty() + && has_local_key_cache( + &PathBuf::from(&effective_storage_root), + &signer_public_key_hash, + ); + + // Re-root storage and keep config dirs empty so path construction stays + // relative to storage root (e.g. "public_keys/.pem"). let data_dir = String::new(); let key_dir = String::new(); @@ -1335,9 +1915,63 @@ pub fn verify_document_standalone( let result: BindingResult = (|| { let wrapper = AgentWrapper::new(); wrapper.load(config_path.to_string_lossy().to_string())?; - // If re-rooting fails (e.g. directory doesn't exist), fall through to - // return valid=false from the verification step. - let _ = wrapper.set_storage_root(std::path::PathBuf::from(&storage_root)); + let _ = wrapper.set_storage_root(PathBuf::from(&effective_storage_root)); + + if explicit_local_key_available { + let key_base = PathBuf::from(&effective_storage_root) + .join("public_keys") + .join(&signer_public_key_hash); + let public_key = std::fs::read(key_base.with_extension("pem")).map_err(|e| { + BindingCoreError::verification_failed(format!( + "Failed to load local public key for hash '{}': {}", + signer_public_key_hash, e + )) + })?; + let enc_type = std::fs::read_to_string(key_base.with_extension("enc_type")) + .map_err(|e| { + BindingCoreError::verification_failed(format!( + "Failed to load local public key type for hash '{}': {}", + signer_public_key_hash, e + )) + })? + .trim() + .to_string(); + + let mut agent = wrapper.lock()?; + let doc = agent.load_document(signed_document).map_err(|e| { + BindingCoreError::document_failed(format!("Failed to load document: {}", e)) + })?; + let document_key = doc.getkey(); + let value = doc.getvalue(); + agent.verify_hash(value).map_err(|e| { + BindingCoreError::verification_failed(format!( + "Failed to verify document hash: {}", + e + )) + })?; + agent + .verify_document_signature( + &document_key, + None, + None, + Some(public_key), + Some(enc_type.clone()), + ) + .map_err(|e| { + BindingCoreError::verification_failed(format!( + "Failed to verify document signature (enc_type={}): {}", + enc_type, e + )) + })?; + + return Ok(VerificationResult { + valid: true, + signer_id: signer_id.clone(), + timestamp: timestamp.clone(), + agent_version: agent_version.clone(), + }); + } + let valid = wrapper.verify_document(signed_document)?; Ok(VerificationResult { valid, @@ -1349,6 +1983,9 @@ pub fn verify_document_standalone( // Clean up temp config file let _ = std::fs::remove_file(&config_path); + if let Some(cache_root) = temp_cache_root { + let _ = std::fs::remove_dir_all(cache_root); + } match result { Ok(r) => Ok(r), @@ -1418,6 +2055,20 @@ pub fn trust_agent(agent_json: &str) -> BindingResult { .map_err(|e| BindingCoreError::trust_failed(format!("Failed to trust agent: {}", e))) } +/// Add an agent to the local trust store using an explicitly provided public key. +/// +/// This is the recommended first-contact bootstrap for secure trust establishment. +pub fn trust_agent_with_key(agent_json: &str, public_key_pem: &str) -> BindingResult { + if public_key_pem.trim().is_empty() { + return Err(BindingCoreError::invalid_argument( + "public_key_pem cannot be empty", + )); + } + jacs::trust::trust_agent_with_key(agent_json, Some(public_key_pem)).map_err(|e| { + BindingCoreError::trust_failed(format!("Failed to trust agent with explicit key: {}", e)) + }) +} + /// List all trusted agent IDs. pub fn list_trusted_agents() -> BindingResult> { jacs::trust::list_trusted_agents().map_err(|e| { @@ -1497,8 +2148,6 @@ pub fn create_agent_programmatic( description: description.unwrap_or("").to_string(), domain: domain.unwrap_or("").to_string(), default_storage: default_storage.unwrap_or("fs").to_string(), - hai_api_key: String::new(), - hai_endpoint: String::new(), }; let (_agent, info) = SimpleAgent::create_with_params(params) @@ -1521,103 +2170,6 @@ pub fn handle_config_create() -> BindingResult<()> { .map_err(|e| BindingCoreError::generic(e.to_string())) } -// ============================================================================= -// Remote Key Fetch Functions -// ============================================================================= - -/// Information about a public key fetched from HAI key service. -/// -/// This struct contains the public key data and metadata returned by -/// the HAI key distribution service. -#[derive(Debug, Clone)] -pub struct RemotePublicKeyInfo { - /// The raw public key bytes (DER encoded). - pub public_key: Vec, - /// The cryptographic algorithm (e.g., "ed25519", "rsa-pss-sha256"). - pub algorithm: String, - /// The hash of the public key (SHA-256). - pub public_key_hash: String, - /// The agent ID the key belongs to. - pub agent_id: String, - /// The version of the key. - pub version: String, -} - -/// Fetch a public key from HAI's key distribution service. -/// -/// This function retrieves the public key for a specific agent and version -/// from the HAI key distribution service. It is used to obtain trusted public -/// keys for verifying agent signatures without requiring local key storage. -/// -/// # Arguments -/// -/// * `agent_id` - The unique identifier of the agent whose key to fetch. -/// * `version` - The version of the agent's key to fetch. Use "latest" for -/// the most recent version. -/// -/// # Returns -/// -/// Returns `Ok(RemotePublicKeyInfo)` containing the public key, algorithm, and hash -/// on success. -/// -/// # Errors -/// -/// * `ErrorKind::KeyNotFound` - The agent or key version was not found (404). -/// * `ErrorKind::NetworkFailed` - Connection, timeout, or other HTTP errors. -/// * `ErrorKind::Generic` - The returned key has invalid encoding. -/// -/// # Environment Variables -/// -/// * `HAI_KEYS_BASE_URL` - Base URL for the key service. Defaults to `https://keys.hai.ai`. -/// * `JACS_KEY_RESOLUTION` - Controls key resolution order. Options: -/// - "hai-only" - Only use HAI key service (default when set) -/// - "local-first" - Try local trust store, fall back to HAI -/// - "hai-first" - Try HAI first, fall back to local trust store -/// -/// # Example -/// -/// ```rust,ignore -/// use jacs_binding_core::fetch_remote_key; -/// -/// let key_info = fetch_remote_key( -/// "550e8400-e29b-41d4-a716-446655440000", -/// "latest" -/// )?; -/// -/// println!("Algorithm: {}", key_info.algorithm); -/// println!("Hash: {}", key_info.public_key_hash); -/// ``` -#[cfg(not(target_arch = "wasm32"))] -pub fn fetch_remote_key(agent_id: &str, version: &str) -> BindingResult { - use jacs::agent::loaders::fetch_public_key_from_hai; - - let key_info = fetch_public_key_from_hai(agent_id, version).map_err(|e| { - // Map JacsError to appropriate BindingCoreError - let error_str = e.to_string(); - if error_str.contains("not found") || error_str.contains("404") { - BindingCoreError::key_not_found(format!( - "Public key not found for agent '{}' version '{}': {}", - agent_id, version, e - )) - } else if error_str.contains("network") - || error_str.contains("connect") - || error_str.contains("timeout") - { - BindingCoreError::network_failed(format!("Failed to fetch public key from HAI: {}", e)) - } else { - BindingCoreError::generic(format!("Failed to fetch public key: {}", e)) - } - })?; - - Ok(RemotePublicKeyInfo { - public_key: key_info.public_key, - algorithm: key_info.algorithm, - public_key_hash: key_info.hash, - agent_id: agent_id.to_string(), - version: version.to_string(), - }) -} - // ============================================================================= // DNS Verification // ============================================================================= @@ -1861,7 +2413,7 @@ mod tests { std::env::set_var("JACS_DATA_DIRECTORY", "/tmp/does-not-exist"); std::env::set_var("JACS_KEY_DIRECTORY", "/tmp/does-not-exist"); std::env::set_var("JACS_DEFAULT_STORAGE", "memory"); - std::env::set_var("JACS_KEY_RESOLUTION", "hai"); + std::env::set_var("JACS_KEY_RESOLUTION", "remote"); } let result = verify_document_standalone( @@ -1888,4 +2440,301 @@ mod tests { "audit JSON should have health_checks" ); } + + // ========================================================================= + // A2A Protocol Tests + // ========================================================================= + + /// Helper: create an ephemeral AgentWrapper for A2A tests. + fn ephemeral_wrapper() -> AgentWrapper { + let wrapper = AgentWrapper::new(); + wrapper.ephemeral(Some("ed25519")).unwrap(); + wrapper + } + + #[test] + fn test_export_agent_card_returns_valid_json() { + let wrapper = ephemeral_wrapper(); + let card_json = wrapper.export_agent_card().unwrap(); + let card: Value = serde_json::from_str(&card_json).unwrap(); + assert!(card.get("name").is_some()); + assert!(card.get("protocolVersions").is_some()); + assert_eq!(card["protocolVersions"][0], "0.4.0"); + } + + #[test] + #[allow(deprecated)] + fn test_wrap_and_verify_a2a_artifact() { + let wrapper = ephemeral_wrapper(); + let artifact = r#"{"content": "hello A2A"}"#; + + let wrapped = wrapper + .wrap_a2a_artifact(artifact, "message", None) + .unwrap(); + let wrapped_value: Value = serde_json::from_str(&wrapped).unwrap(); + assert!(wrapped_value.get("jacsId").is_some()); + assert_eq!(wrapped_value["jacsType"], "a2a-message"); + + let result_json = wrapper.verify_a2a_artifact(&wrapped).unwrap(); + let result: Value = serde_json::from_str(&result_json).unwrap(); + assert_eq!(result["valid"], true); + assert_eq!(result["status"], "SelfSigned"); + } + + #[test] + fn test_sign_artifact_alias_matches_wrap() { + let wrapper = ephemeral_wrapper(); + let artifact = r#"{"data": 42}"#; + + let signed = wrapper.sign_artifact(artifact, "artifact", None).unwrap(); + let value: Value = serde_json::from_str(&signed).unwrap(); + assert_eq!(value["jacsType"], "a2a-artifact"); + + let result_json = wrapper.verify_a2a_artifact(&signed).unwrap(); + let result: Value = serde_json::from_str(&result_json).unwrap(); + assert_eq!(result["valid"], true); + } + + #[test] + #[allow(deprecated)] + fn test_wrap_a2a_artifact_with_parent_chain() { + let wrapper = ephemeral_wrapper(); + + let first = wrapper + .wrap_a2a_artifact(r#"{"step": 1}"#, "task", None) + .unwrap(); + let parents = format!("[{}]", first); + let second = wrapper + .wrap_a2a_artifact(r#"{"step": 2}"#, "task", Some(&parents)) + .unwrap(); + + let second_value: Value = serde_json::from_str(&second).unwrap(); + let parent_sigs = second_value["jacsParentSignatures"].as_array().unwrap(); + assert_eq!(parent_sigs.len(), 1); + } + + #[test] + #[allow(deprecated)] + fn test_wrap_a2a_artifact_invalid_json_error() { + let wrapper = ephemeral_wrapper(); + let result = wrapper.wrap_a2a_artifact("not json", "artifact", None); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind, ErrorKind::InvalidArgument); + } + + #[test] + fn test_verify_a2a_artifact_invalid_json_error() { + let wrapper = ephemeral_wrapper(); + let result = wrapper.verify_a2a_artifact("not json"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind, ErrorKind::InvalidArgument); + } + + #[test] + fn test_export_agent_card_unloaded_agent_error() { + let wrapper = AgentWrapper::new(); + let result = wrapper.export_agent_card(); + assert!(result.is_err()); + } + + // ========================================================================= + // Attestation API Tests + // ========================================================================= + + #[cfg(feature = "attestation")] + mod attestation_tests { + use super::*; + + fn attestation_wrapper() -> AgentWrapper { + let wrapper = AgentWrapper::new(); + wrapper.ephemeral(Some("ed25519")).unwrap(); + wrapper + } + + fn basic_attestation_params() -> String { + json!({ + "subject": { + "type": "artifact", + "id": "test-artifact-001", + "digests": { "sha256": "abc123" } + }, + "claims": [{ + "name": "reviewed", + "value": true, + "confidence": 0.95, + "assuranceLevel": "verified" + }] + }) + .to_string() + } + + #[test] + fn binding_create_attestation_json() { + let wrapper = attestation_wrapper(); + let result = wrapper.create_attestation(&basic_attestation_params()); + assert!( + result.is_ok(), + "create_attestation should succeed: {:?}", + result.err() + ); + + let json_str = result.unwrap(); + let doc: Value = serde_json::from_str(&json_str).unwrap(); + assert!( + doc.get("attestation").is_some(), + "returned JSON should contain 'attestation' key" + ); + assert!( + doc.get("jacsSignature").is_some(), + "returned JSON should be signed" + ); + } + + #[test] + fn binding_verify_attestation_json() { + let wrapper = attestation_wrapper(); + let att_json = wrapper + .create_attestation(&basic_attestation_params()) + .unwrap(); + let doc: Value = serde_json::from_str(&att_json).unwrap(); + let key = format!( + "{}:{}", + doc["jacsId"].as_str().unwrap(), + doc["jacsVersion"].as_str().unwrap() + ); + + let result = wrapper.verify_attestation(&key); + assert!( + result.is_ok(), + "verify_attestation should succeed: {:?}", + result.err() + ); + + let result_json = result.unwrap(); + let result_value: Value = serde_json::from_str(&result_json).unwrap(); + assert_eq!( + result_value["valid"], true, + "attestation should verify as valid" + ); + } + + #[test] + fn binding_verify_attestation_full_json() { + let wrapper = attestation_wrapper(); + let att_json = wrapper + .create_attestation(&basic_attestation_params()) + .unwrap(); + let doc: Value = serde_json::from_str(&att_json).unwrap(); + let key = format!( + "{}:{}", + doc["jacsId"].as_str().unwrap(), + doc["jacsVersion"].as_str().unwrap() + ); + + let result = wrapper.verify_attestation_full(&key); + assert!( + result.is_ok(), + "verify_attestation_full should succeed: {:?}", + result.err() + ); + + let result_json = result.unwrap(); + let result_value: Value = serde_json::from_str(&result_json).unwrap(); + assert_eq!( + result_value["valid"], true, + "full attestation should verify as valid" + ); + assert!( + result_value.get("evidence").is_some(), + "full verification result should contain 'evidence' array" + ); + } + + #[test] + fn binding_lift_to_attestation_json() { + let wrapper = attestation_wrapper(); + + // Create a proper signed JACS document + let doc_json = json!({"title": "Test Document", "content": "Some content"}).to_string(); + let signed = wrapper + .create_document(&doc_json, None, None, true, None, None) + .unwrap(); + + let claims_json = json!([{ + "name": "reviewed", + "value": true + }]) + .to_string(); + + let result = wrapper.lift_to_attestation(&signed, &claims_json); + assert!( + result.is_ok(), + "lift_to_attestation should succeed: {:?}", + result.err() + ); + + let att_json = result.unwrap(); + let doc: Value = serde_json::from_str(&att_json).unwrap(); + assert!( + doc.get("attestation").is_some(), + "lifted result should contain 'attestation' key" + ); + assert!( + doc.get("jacsSignature").is_some(), + "lifted result should be signed" + ); + } + + #[test] + fn binding_create_attestation_error_on_bad_json() { + let wrapper = attestation_wrapper(); + let result = wrapper.create_attestation("not valid json {{{"); + assert!(result.is_err(), "bad JSON should error"); + assert_eq!( + result.unwrap_err().kind, + ErrorKind::SerializationFailed, + "should be SerializationFailed error" + ); + } + + #[test] + fn binding_create_attestation_error_on_missing_fields() { + let wrapper = attestation_wrapper(); + // Valid JSON but missing required 'subject' field + let params = json!({ + "claims": [{"name": "test", "value": true}] + }) + .to_string(); + + let result = wrapper.create_attestation(¶ms); + assert!(result.is_err(), "missing subject should error"); + assert_eq!( + result.unwrap_err().kind, + ErrorKind::Validation, + "should be Validation error" + ); + } + + #[test] + fn binding_export_attestation_dsse() { + let wrapper = attestation_wrapper(); + let att_json = wrapper + .create_attestation(&basic_attestation_params()) + .unwrap(); + + let result = wrapper.export_attestation_dsse(&att_json); + assert!( + result.is_ok(), + "export_attestation_dsse should succeed: {:?}", + result.err() + ); + + let dsse_json = result.unwrap(); + let envelope: Value = serde_json::from_str(&dsse_json).unwrap(); + assert_eq!( + envelope["payloadType"].as_str().unwrap(), + "application/vnd.in-toto+json" + ); + } + } } diff --git a/binding-core/tests/document_sign_verify.rs b/binding-core/tests/document_sign_verify.rs index ef8299797..00e85fcec 100644 --- a/binding-core/tests/document_sign_verify.rs +++ b/binding-core/tests/document_sign_verify.rs @@ -68,13 +68,12 @@ fn test_verify_document_tampered() { .create_document(&content.to_string(), None, None, true, None, None) .expect("create_document should succeed"); - // Tamper with the signed document by modifying content + // Tamper with the signed document by modifying the signed payload. let mut parsed: Value = serde_json::from_str(&signed).unwrap(); - if let Some(doc) = parsed.get_mut("jacsDocument") { - if let Some(content_obj) = doc.get_mut("content") { - *content_obj = json!({"data": "tampered"}); - } - } + let content_obj = parsed + .get_mut("content") + .expect("signed document should contain a top-level content field"); + *content_obj = json!({"data": "tampered"}); let tampered = serde_json::to_string(&parsed).unwrap(); // Tampered document should fail hash verification diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 000000000..b11b3cfc9 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,47 @@ +## Storage backend test infrastructure +## +## Usage: +## docker compose -f docker-compose.test.yml up -d +## cargo test --features "database-tests,s3-tests" +## docker compose -f docker-compose.test.yml down + +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: jacs + POSTGRES_PASSWORD: jacs_test + POSTGRES_DB: jacs_test + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U jacs"] + interval: 5s + timeout: 3s + retries: 10 + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 3s + retries: 10 + + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 minioadmin minioadmin && + mc mb --ignore-existing local/jacs-test + " diff --git a/docs/MCP_REFACTOR.md b/docs/MCP_REFACTOR.md new file mode 100644 index 000000000..7541e19e8 --- /dev/null +++ b/docs/MCP_REFACTOR.md @@ -0,0 +1,318 @@ +# JACS Rust MCP Refactor Plan + +## Purpose + +This document defines the near-term refactor required in `jacs-mcp` so the +Rust MCP server can be reused in-process by `haisdk`. + +The immediate consumer is `hai-mcp` in `~/personal/haisdk`. Today that server +cannot correctly extend the JACS MCP server because `jacs-mcp` is packaged as a +binary crate and is therefore consumed through a subprocess bridge. That bridge +is the wrong architecture for a local-only, stdio-only MCP server that must be +complete, DRY, and heavily tested in Rust. + +The goal of this refactor is to make the Rust JACS MCP server reusable as a +library without changing ownership boundaries: + +1. `jacs` still owns identity, signatures, verification, provenance, trust, + A2A, and related crypto-backed workflows. +2. `jacs-mcp` still owns the canonical MCP packaging of those JACS operations. +3. `haisdk` will extend `jacs-mcp` in-process with HAI connectivity operations + such as registration, authenticated API access, and agent email. + +## Why HAISDK Needs This + +`haisdk` needs to run a single MCP server process that: + +1. is local only +2. is stdio only +3. exposes the full JACS MCP tool surface +4. adds HAI-specific tools in the same server +5. does not spawn `jacs-mcp` as a child process +6. does not duplicate JACS MCP protocol logic + +Without a reusable JACS MCP library, `hai-mcp` is forced into one of two bad +options: + +1. subprocess bridging to `jacs-mcp`, which creates extra process management, + duplicated MCP wiring, weak tests, and state fragmentation +2. reimplementing JACS tools inside `haisdk`, which violates DRY and ownership + boundaries + +This refactor removes both failure modes. + +## Current Problem + +Current Rust layout: + +1. `~/personal/JACS/jacs-mcp` is a binary crate. +2. `JacsMcpServer` exists and is already a real RMCP server implementation. +3. The tool router is internal to `JacsMcpServer`. +4. `hai-mcp` currently uses a subprocess bridge instead of importing JACS MCP + directly. + +That means the best Rust MCP implementation cannot be extended directly even +though the source is available locally. + +## Non-Negotiable Requirements + +### Functional Requirements + +1. `jacs-mcp` must be usable as a Rust library crate. +2. The library must export the canonical `JacsMcpServer`. +3. The existing `jacs-mcp` binary must remain available as a thin stdio entry + point. +4. The refactor must not change the meaning of existing `jacs_*` tools. +5. The refactor must preserve current environment-based agent loading behavior. + +### Architecture Requirements + +1. No HAI-specific behavior should be added to `jacs-mcp`. +2. No JACS crypto or provenance logic should move into `haisdk`. +3. The reusable surface should be small and stable. +4. The binary should become a thin wrapper over library code. + +### Testing Requirements + +1. The library surface must have direct tests. +2. The binary must keep a smoke test over stdio. +3. Tool listing and tool dispatch behavior must remain stable. +4. The refactor must improve, not weaken, the confidence of downstream MCP + embedding. + +## Recommended Near-Term Design + +The near-term design is to make `jacs-mcp` a reusable library and keep +`JacsMcpServer` as the canonical concrete server type. + +This is intentionally the least invasive change that unlocks HAISDK. + +### Why This Is The Right Near-Term Design + +1. `JacsMcpServer` already exists and already implements `ServerHandler`. +2. The JACS tool router is already complete and tested in Rust. +3. HAISDK can wrap the concrete `JacsMcpServer` in-process immediately. +4. This avoids a larger generic-tooling refactor before it is actually needed. + +### What This Does Not Require + +This plan does not require: + +1. extracting JACS MCP tools into a separate generic crate right away +2. redesigning all RMCP routing abstractions +3. exposing internal router implementation details publicly + +Those may become worthwhile later, but they are not required to unblock HAISDK. + +## Required Public Library Surface + +The library target should export a small public API, roughly equivalent to: + +```rust +pub use crate::jacs_tools::JacsMcpServer; + +pub fn load_agent_from_config_env() -> anyhow::Result; +pub fn load_agent_from_config_path(path: &Path) -> anyhow::Result; + +#[cfg(feature = "mcp")] +pub async fn serve_stdio(server: JacsMcpServer) -> anyhow::Result<()>; +``` + +Notes: + +1. Exact naming may differ, but the intent should remain the same. +2. `JacsMcpServer` should remain the canonical implementation. +3. Agent/config loading should move out of `main.rs` and into reusable library + code. +4. The stdio serving helper should keep binary setup DRY. + +## Detailed Refactor Plan + +## Phase 1: Add A Library Target + +### Deliverables + +1. Add `src/lib.rs` to `jacs-mcp`. +2. Move reusable code out of `src/main.rs` into library modules. +3. Re-export `JacsMcpServer` from the library. +4. Re-export or define the config-loading helpers in the library. +5. Keep `src/main.rs` as a thin binary wrapper. + +### Suggested File Layout + +1. `src/lib.rs` +2. `src/jacs_tools.rs` +3. `src/config.rs` or equivalent helper module +4. `src/main.rs` + +### Main Binary Responsibilities After Refactor + +`src/main.rs` should only do: + +1. initialize logging +2. load the agent from config +3. construct `JacsMcpServer` +4. serve it over stdio + +It should not own business logic. + +## Phase 2: Stabilize The Embedding Surface + +### Deliverables + +1. Make `JacsMcpServer::new(agent)` the canonical constructor. +2. Keep `JacsMcpServer::tools()` as the canonical tool inventory helper. +3. Ensure downstream crates can import and instantiate the server without + depending on the binary entrypoint. +4. Document the supported embedding pattern. + +### Embedding Pattern To Support + +Downstream crates such as `hai-mcp` should be able to do this: + +```rust +use jacs_mcp::JacsMcpServer; + +let agent = jacs_mcp::load_agent_from_config_env()?; +let jacs = JacsMcpServer::new(agent); +``` + +The downstream server can then delegate `list_tools` and `call_tool` to this +in-process JACS server. + +## Phase 3: Keep The Binary Thin + +### Deliverables + +1. Preserve `cargo run -p jacs-mcp` behavior. +2. Preserve stdio transport as the binary mode. +3. Preserve environment-variable based config loading. +4. Keep server info, tool definitions, and instructions consistent with the + existing implementation. + +## Optional Phase 4: Generic Tooling Extraction + +This is not required for the near-term HAISDK integration, but it may be a good +follow-up if multiple downstream Rust MCP servers need to compose JACS routes on +their own state type. + +Possible future direction: + +1. extract JACS MCP business logic behind traits over shared JACS state +2. allow a downstream `ToolRouter` to include JACS routes directly +3. keep `JacsMcpServer` as the canonical assembled server for simple use cases + +Do not block the near-term library refactor on this. + +## DRY Constraints + +The refactor must preserve these DRY rules: + +1. one canonical implementation of each `jacs_*` tool +2. one canonical source of server instructions and tool metadata +3. one canonical agent/config loading path for the binary and embedders +4. no duplicated MCP JSON-RPC handling outside RMCP + +## API Stability Expectations + +The initial public API can be explicitly marked as "embedding support for +HAISDK, subject to refinement", but the following should be treated as stable +enough for immediate downstream use: + +1. `JacsMcpServer` +2. the agent/config loading helpers +3. the stdio serve helper + +Avoid exposing internal router fields publicly unless there is a clear need. + +## TDD Plan + +This refactor should be done test-first. + +## Stage 1: Library Construction Tests + +Write failing tests first for: + +1. constructing `JacsMcpServer` from a loaded `AgentWrapper` +2. importing `JacsMcpServer` through the library crate root +3. loading agent config through the new library helpers + +Green condition: + +1. the library API compiles and the tests pass without using `main.rs` + +## Stage 2: Tool Surface Regression Tests + +Write failing tests first for: + +1. `JacsMcpServer::tools()` returns the expected canonical tool names +2. tool count does not regress unexpectedly +3. server metadata still identifies itself as `jacs-mcp` + +Green condition: + +1. the refactor preserves the current tool surface + +## Stage 3: Binary Smoke Tests + +Write failing tests first for: + +1. the binary still starts over stdio +2. the binary still loads agent config from the expected environment +3. the binary still answers basic MCP initialize/list-tools flow + +Green condition: + +1. the binary remains a thin wrapper with unchanged external behavior + +## Stage 4: Embedding Smoke Test + +Write a failing test that mimics the downstream use case: + +1. instantiate `JacsMcpServer` from library code +2. call `list_tools` or equivalent handler path without launching a subprocess + +Green condition: + +1. there is a direct proof that the server is embeddable in-process + +## Suggested Test Inventory + +1. `tests/library_exports.rs` +2. `tests/config_loading.rs` +3. `tests/tool_surface.rs` +4. `tests/binary_stdio_smoke.rs` +5. `tests/embedding_smoke.rs` + +Exact filenames may differ, but this separation should be preserved. + +## Acceptance Criteria + +This refactor is complete when: + +1. `jacs-mcp` builds as both a library and a binary +2. `JacsMcpServer` is importable from another Rust crate +3. the binary is a thin stdio wrapper over library code +4. existing `jacs_*` tool behavior is preserved +5. downstream code no longer needs to shell out to `jacs-mcp` + +## Explicit Non-Goals + +This refactor is not intended to: + +1. add HAI API logic to JACS +2. redesign JACS ownership boundaries +3. expand the JACS tool surface +4. switch the JACS MCP binary away from stdio + +## Downstream Consumer Expectation + +Once this refactor lands, `haisdk` will: + +1. depend on the `jacs-mcp` library directly from source +2. construct `JacsMcpServer` in-process +3. expose all `jacs_*` tools unchanged +4. add `hai_*` tools in the same MCP server process +5. remove the current subprocess bridge entirely + +That is the near-term path to the correct Rust MCP architecture. diff --git a/docs/jacsbook.pdf b/docs/jacsbook.pdf new file mode 100644 index 000000000..5e878b93d Binary files /dev/null and b/docs/jacsbook.pdf differ diff --git a/examples/a2a_trust_demo.py b/examples/a2a_trust_demo.py new file mode 100644 index 000000000..98f867ec1 --- /dev/null +++ b/examples/a2a_trust_demo.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +Multi-agent A2A trust verification demo -- zero setup. + +Three agents interact via the A2A protocol: + Agent A (JACS) -- Signs a task artifact and sends it + Agent B (JACS) -- Receives, verifies, and countersigns with chain of custody + Agent C (plain) -- Attempts to participate but is blocked by trust policy + +Demonstrates: + - A2A artifact signing with JACS provenance + - Cross-agent verification and trust assessment + - Chain of custody across multiple signers + - Trust policy enforcement (verified rejects non-JACS, strict requires trust store) + - Agent Card export and JACS extension detection + +Run: + python examples/a2a_trust_demo.py +""" + +import json +import sys + +from jacs.client import JacsClient +from jacs.a2a import JACSA2AIntegration + + +def main() -> None: + # -- Step 1: Create JACS agents (A and B) ----------------------------------- + print("Step 1 -- Create agents") + agent_a = JacsClient.ephemeral("ring-Ed25519") + agent_b = JacsClient.ephemeral("ring-Ed25519") + + print(f" Agent A (JACS) : {agent_a.agent_id}") + print(f" Agent B (JACS) : {agent_b.agent_id}") + print(" Agent C (plain): no JACS identity -- standard A2A only") + + # -- Step 2: Agent A signs a task artifact ---------------------------------- + print("\nStep 2 -- Agent A signs a task artifact") + task_payload = { + "action": "classify", + "input": "Analyze quarterly revenue data", + "priority": "high", + } + + signed_task = agent_a.sign_artifact(task_payload, "task") + print(f" Artifact ID : {signed_task.get('jacsId', 'N/A')}") + print(f" Type : {signed_task.get('jacsType', 'N/A')}") + sig = signed_task.get("jacsSignature", {}) + signer_id = sig.get("agentID", "unknown") if isinstance(sig, dict) else "unknown" + print(f" Signer : {signer_id[:12]}...") + + # -- Step 3: Agent B verifies the artifact ---------------------------------- + print("\nStep 3 -- Agent B verifies the artifact from Agent A") + a2a_b = JACSA2AIntegration(agent_b, trust_policy="verified") + verify_result = a2a_b.verify_wrapped_artifact(signed_task, assess_trust=True) + + print(f" Valid : {verify_result.get('valid')}") + signer = verify_result.get("signer_id", "unknown") or "unknown" + print(f" Signer ID : {signer[:12]}...") + trust = verify_result.get("trust", {}) + print(f" Trust level : {trust.get('trust_level', 'N/A')}") + print(f" Allowed : {trust.get('allowed', 'N/A')}") + + # -- Step 4: Agent B countersigns with chain of custody --------------------- + print("\nStep 4 -- Agent B countersigns (chain of custody)") + result_payload = { + "action": "classify_result", + "output": {"category": "financial", "confidence": 0.97}, + "parentTaskId": signed_task.get("jacsId"), + } + + signed_result = agent_b.sign_artifact( + result_payload, "result", parent_signatures=[signed_task] + ) + print(f" Result ID : {signed_result.get('jacsId', 'N/A')}") + parents = signed_result.get("jacsParentSignatures", []) + print(f" Parents : {len(parents) if isinstance(parents, list) else 0}") + sig_b = signed_result.get("jacsSignature", {}) + signer_b = sig_b.get("agentID", "unknown") if isinstance(sig_b, dict) else "unknown" + print(f" Signer : {signer_b[:12]}...") + + # -- Step 5: Verify the full chain ------------------------------------------ + print("\nStep 5 -- Verify the full chain of custody") + a2a_a = JACSA2AIntegration(agent_a, trust_policy="verified") + chain_result = a2a_a.verify_wrapped_artifact(signed_result) + + print(f" Chain valid : {chain_result.get('valid')}") + print(f" Parent sigs valid : {chain_result.get('parent_signatures_valid')}") + parent_count = chain_result.get("parent_signatures_count", 0) + print(f" Parent sigs count : {parent_count}") + + # -- Step 6: Agent C (non-JACS) is blocked by trust policy ------------------ + print("\nStep 6 -- Agent C (plain A2A, no JACS) tries to join") + + # Simulate Agent C's agent card -- a standard A2A card with no JACS extension + agent_c_card = json.dumps({ + "name": "Agent C", + "description": "A plain A2A agent without JACS", + "version": "1.0", + "protocolVersions": ["0.4.0"], + "skills": [ + {"id": "chat", "name": "Chat", "description": "General chat", "tags": ["chat"]} + ], + "capabilities": {"streaming": True}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + }) + + # Agent B assesses Agent C under "verified" policy (default) + assess_verified = a2a_b.assess_remote_agent(agent_c_card) + print(" Verified policy:") + print(f" JACS registered : {assess_verified.get('jacs_registered')}") + print(f" Allowed : {assess_verified.get('allowed')}") + print(f" Reason : {assess_verified.get('reason', 'N/A')}") + + # Under "strict" policy, even Agent A would be rejected without trust store entry + a2a_strict = JACSA2AIntegration(agent_b, trust_policy="strict") + card_a = agent_a.export_agent_card() + # export_agent_card returns an A2AAgentCard dataclass; convert to JSON string + card_a_json = json.dumps({ + "name": card_a.name, + "description": card_a.description, + "version": card_a.version, + "skills": [ + {"id": s.id, "name": s.name, "description": s.description, "tags": s.tags} + for s in (card_a.skills or []) + ], + "capabilities": { + "extensions": [ + {"uri": e.uri, "description": e.description, "required": e.required} + for e in (card_a.capabilities.extensions if card_a.capabilities else []) + ] + } if card_a.capabilities else {}, + }) + assess_strict = a2a_strict.assess_remote_agent(card_a_json) + print(" Strict policy (Agent A):") + print(f" JACS registered : {assess_strict.get('jacs_registered')}") + print(f" In trust store : {assess_strict.get('in_trust_store')}") + print(f" Allowed : {assess_strict.get('allowed')}") + print(f" Reason : {assess_strict.get('reason', 'N/A')}") + + # -- Step 7: Export Agent Cards for A2A discovery --------------------------- + print("\nStep 7 -- Export Agent Cards") + card_agent_a = agent_a.export_agent_card() + card_agent_b = agent_b.export_agent_card() + + print(f" Agent A card: name=\"{card_agent_a.name}\", skills={len(card_agent_a.skills or [])}") + print(f" Agent B card: name=\"{card_agent_b.name}\", skills={len(card_agent_b.skills or [])}") + + has_jacs_ext_a = any( + "jacs" in (e.uri or "") + for e in (card_agent_a.capabilities.extensions if card_agent_a.capabilities else []) + ) + has_jacs_ext_b = any( + "jacs" in (e.uri or "") + for e in (card_agent_b.capabilities.extensions if card_agent_b.capabilities else []) + ) + print(f" Both declare JACS extension: {has_jacs_ext_a and has_jacs_ext_b}") + + print("\nDone.") + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/examples/a2a_trust_demo.ts b/examples/a2a_trust_demo.ts new file mode 100644 index 000000000..58024ae89 --- /dev/null +++ b/examples/a2a_trust_demo.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env npx ts-node +/** + * Multi-agent A2A trust verification demo -- zero setup. + * + * Three agents interact via the A2A protocol: + * Agent A (JACS) -- Signs a task artifact and sends it + * Agent B (JACS) -- Receives, verifies, and countersigns with chain of custody + * Agent C (plain) -- Attempts to participate but is blocked by trust policy + * + * Demonstrates: + * - A2A artifact signing with JACS provenance + * - Cross-agent verification and trust assessment + * - Chain of custody across multiple signers + * - Trust policy enforcement (verified rejects non-JACS, strict requires trust store) + * - Agent Card export and JACS extension detection + * + * Run: + * npx ts-node --compiler-options '{"module":"commonjs","moduleResolution":"node","esModuleInterop":true}' examples/a2a_trust_demo.ts + */ + +import { JacsClient } from '../jacsnpm/client'; +import { JACSA2AIntegration, TRUST_POLICIES } from '../jacsnpm/a2a'; + +async function main(): Promise { + // -- Step 1: Create JACS agents (A and B) ----------------------------------- + console.log('Step 1 -- Create agents'); + const agentA = await JacsClient.ephemeral('ring-Ed25519'); + const agentB = await JacsClient.ephemeral('ring-Ed25519'); + + console.log(` Agent A (JACS) : ${agentA.agentId}`); + console.log(` Agent B (JACS) : ${agentB.agentId}`); + console.log(' Agent C (plain): no JACS identity -- standard A2A only'); + + // -- Step 2: Agent A signs a task artifact ---------------------------------- + console.log('\nStep 2 -- Agent A signs a task artifact'); + const taskPayload = { + action: 'classify', + input: 'Analyze quarterly revenue data', + priority: 'high', + }; + + const signedTask = await agentA.signArtifact(taskPayload, 'task'); + console.log(` Artifact ID : ${signedTask.jacsId}`); + console.log(` Type : ${signedTask.jacsType}`); + console.log(` Signer : ${(signedTask.jacsSignature as any)?.agentID?.substring(0, 12)}...`); + + // -- Step 3: Agent B verifies the artifact ---------------------------------- + console.log('\nStep 3 -- Agent B verifies the artifact from Agent A'); + const a2aB = new JACSA2AIntegration(agentB, TRUST_POLICIES.VERIFIED); + const verifyResult = await a2aB.verifyWrappedArtifact(signedTask); + + console.log(` Valid : ${verifyResult.valid}`); + console.log(` Signer ID : ${verifyResult.signerId?.substring(0, 12)}...`); + console.log(` Trust level : ${verifyResult.trustAssessment?.trustLevel}`); + console.log(` Allowed : ${verifyResult.trustAssessment?.allowed}`); + + // -- Step 4: Agent B countersigns with chain of custody --------------------- + console.log('\nStep 4 -- Agent B countersigns (chain of custody)'); + const resultPayload = { + action: 'classify_result', + output: { category: 'financial', confidence: 0.97 }, + parentTaskId: signedTask.jacsId, + }; + + const signedResult = await agentB.signArtifact(resultPayload, 'result', [signedTask]); + console.log(` Result ID : ${signedResult.jacsId}`); + console.log(` Parents : ${(signedResult.jacsParentSignatures as any[])?.length ?? 0}`); + console.log(` Signer : ${(signedResult.jacsSignature as any)?.agentID?.substring(0, 12)}...`); + + // -- Step 5: Verify the full chain ------------------------------------------ + console.log('\nStep 5 -- Verify the full chain of custody'); + const a2aA = new JACSA2AIntegration(agentA, TRUST_POLICIES.VERIFIED); + const chainResult = await a2aA.verifyWrappedArtifact(signedResult); + + console.log(` Chain valid : ${chainResult.valid}`); + console.log(` Parent sigs valid : ${chainResult.parentSignaturesValid}`); + console.log(` Parent sigs count : ${chainResult.parentSignaturesCount}`); + + // -- Step 6: Agent C (non-JACS) is blocked by trust policy ------------------ + console.log('\nStep 6 -- Agent C (plain A2A, no JACS) tries to join'); + + // Simulate Agent C's agent card -- a standard A2A card with no JACS extension + const agentCCard = { + name: 'Agent C', + description: 'A plain A2A agent without JACS', + version: '1.0', + protocolVersions: ['0.4.0'], + skills: [{ id: 'chat', name: 'Chat', description: 'General chat', tags: ['chat'] }], + capabilities: { streaming: true }, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + }; + + // Agent B assesses Agent C under "verified" policy (default) + const assessVerified = a2aB.assessRemoteAgent(agentCCard); + console.log(` Verified policy:`); + console.log(` JACS registered : ${assessVerified.jacsRegistered}`); + console.log(` Allowed : ${assessVerified.allowed}`); + console.log(` Reason : ${assessVerified.reason}`); + + // Under "strict" policy, even Agent A would be rejected without trust store entry + const a2aStrict = new JACSA2AIntegration(agentB, TRUST_POLICIES.STRICT); + const cardA = agentA.exportAgentCard(); + const assessStrict = a2aStrict.assessRemoteAgent(JSON.stringify(cardA)); + console.log(` Strict policy (Agent A):`); + console.log(` JACS registered : ${assessStrict.jacsRegistered}`); + console.log(` In trust store : ${assessStrict.inTrustStore}`); + console.log(` Allowed : ${assessStrict.allowed}`); + console.log(` Reason : ${assessStrict.reason}`); + + // -- Step 7: Export Agent Cards for A2A discovery --------------------------- + console.log('\nStep 7 -- Export Agent Cards'); + const cardAgentA = agentA.exportAgentCard(); + const cardAgentB = agentB.exportAgentCard(); + + console.log(` Agent A card: name="${cardAgentA.name}", skills=${cardAgentA.skills?.length ?? 0}`); + console.log(` Agent B card: name="${cardAgentB.name}", skills=${cardAgentB.skills?.length ?? 0}`); + console.log(` Both declare JACS extension: ${ + cardAgentA.capabilities?.extensions?.some((e: any) => e.uri?.includes('jacs')) && + cardAgentB.capabilities?.extensions?.some((e: any) => e.uri?.includes('jacs')) + }`); + + // -- Cleanup ---------------------------------------------------------------- + agentA.dispose(); + agentB.dispose(); + console.log('\nDone.'); +} + +main().catch(console.error); diff --git a/examples/attestation_hello_world.js b/examples/attestation_hello_world.js new file mode 100644 index 000000000..d54c207f8 --- /dev/null +++ b/examples/attestation_hello_world.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * Attestation hello world -- create and verify your first attestation. + * + * Demonstrates the core attestation flow: sign a document, attest WHY it is + * trustworthy, then verify the attestation. Uses an ephemeral agent (in-memory + * keys, no files on disk) for the simplest possible setup. + * + * Run: + * npm install @hai.ai/jacs + * node examples/attestation_hello_world.js + */ + +const { JacsClient } = require('@hai.ai/jacs/client'); +const { createHash } = require('crypto'); + +async function main() { + // 1. Create an ephemeral agent (in-memory keys, no files) + const client = await JacsClient.ephemeral('ring-Ed25519'); + + // 2. Sign a document + const signed = await client.signMessage({ action: 'approve', amount: 100 }); + console.log(`Signed document: ${signed.documentId}`); + + // 3. Attest WHY this document is trustworthy + const contentHash = createHash('sha256').update(signed.raw).digest('hex'); + const attestation = await client.createAttestation({ + subject: { + type: 'artifact', + id: signed.documentId, + digests: { sha256: contentHash }, + }, + claims: [{ name: 'reviewed_by', value: 'human', confidence: 0.95 }], + }); + console.log(`Attestation created: ${attestation.documentId}`); + + // 4. Verify the attestation + const result = await client.verifyAttestation(attestation.raw); + console.log(`Valid: ${result.valid}`); + console.log(`Signature OK: ${result.crypto.signature_valid}`); + console.log(`Hash OK: ${result.crypto.hash_valid}`); + + // 5. Full verification (includes evidence checks) + const fullResult = await client.verifyAttestation(attestation.raw, { full: true }); + console.log(`Full verify valid: ${fullResult.valid}`); + console.log(`Evidence items: ${(fullResult.evidence || []).length}`); +} + +main().catch(console.error); diff --git a/examples/attestation_hello_world.py b/examples/attestation_hello_world.py new file mode 100644 index 000000000..d9878ffcd --- /dev/null +++ b/examples/attestation_hello_world.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Attestation hello world -- create and verify your first attestation. + +Demonstrates the core attestation flow: sign a document, attest WHY it is +trustworthy, then verify the attestation. Uses an ephemeral agent (no files +on disk) for the simplest possible setup. + +Run: + pip install jacs[attestation] + python examples/attestation_hello_world.py +""" + +import hashlib +import json +from jacs.client import JacsClient + +# 1. Create an ephemeral agent (in-memory keys, no files) +client = JacsClient.ephemeral(algorithm="ed25519") + +# 2. Sign a document +signed = client.sign_message({"action": "approve", "amount": 100}) +print(f"Signed document: {signed.document_id}") + +# 3. Attest WHY this document is trustworthy +content_hash = hashlib.sha256(signed.raw_json.encode()).hexdigest() +attestation = client.create_attestation( + subject={ + "type": "artifact", + "id": signed.document_id, + "digests": {"sha256": content_hash}, + }, + claims=[{"name": "reviewed_by", "value": "human", "confidence": 0.95}], +) +print(f"Attestation created: {attestation.document_id}") + +# 4. Verify the attestation +result = client.verify_attestation(attestation.raw_json) +print(f"Valid: {result['valid']}") +print(f"Signature OK: {result['crypto']['signature_valid']}") +print(f"Hash OK: {result['crypto']['hash_valid']}") + +# 5. Full verification (includes evidence checks) +full_result = client.verify_attestation(attestation.raw_json, full=True) +print(f"Full verify valid: {full_result['valid']}") +print(f"Evidence items: {len(full_result.get('evidence', []))}") diff --git a/examples/attestation_hello_world.sh b/examples/attestation_hello_world.sh new file mode 100755 index 000000000..b47b2dfda --- /dev/null +++ b/examples/attestation_hello_world.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Attestation hello world -- create and verify your first attestation via CLI. +# +# Demonstrates the core attestation flow using the JACS CLI: +# sign a document, create an attestation, then verify it. +# +# Prerequisites: +# cargo install jacs --features attestation +# OR download the binary from releases +# +# Run: +# bash examples/attestation_hello_world.sh +set -e + +# Create a temporary workspace +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT +cd "$WORK_DIR" + +echo "=== 1. Create a quickstart agent ===" +export JACS_PRIVATE_KEY_PASSWORD="HelloWorld!P@ss42" +jacs quickstart --algorithm ed25519 + +echo "" +echo "=== 2. Create a test document ===" +cat > mydata.json << 'ENDJSON' +{ + "action": "approve", + "amount": 100, + "reviewer": "human-operator" +} +ENDJSON + +echo "" +echo "=== 3. Sign the document ===" +jacs document create -f mydata.json +echo "Document signed." + +echo "" +echo "=== 4. Create an attestation ===" +DOC_HASH=$(sha256sum mydata.json | awk '{print $1}') +jacs attest create \ + --subject-type artifact \ + --subject-id "mydata-001" \ + --subject-digest "sha256:${DOC_HASH}" \ + --claims '[{"name": "reviewed_by", "value": "human", "confidence": 0.95}]' +echo "Attestation created." + +echo "" +echo "=== 5. Verify the attestation ===" +# Find the most recent attestation file +ATT_FILE=$(ls -t jacs_data/documents/*.json 2>/dev/null | head -1) +if [ -n "$ATT_FILE" ]; then + jacs attest verify "$ATT_FILE" --json + echo "Verification complete." +else + echo "No attestation file found -- check jacs_data/documents/" +fi + +echo "" +echo "Done! Your first attestation has been created and verified." diff --git a/jacs-mcp/Cargo.toml b/jacs-mcp/Cargo.toml index 87084f641..231e2331c 100644 --- a/jacs-mcp/Cargo.toml +++ b/jacs-mcp/Cargo.toml @@ -1,35 +1,35 @@ [package] name = "jacs-mcp" -version = "0.8.0" +version = "0.9.0" edition = "2024" rust-version = "1.93" -description = "MCP server for JACS: data provenance and cryptographic signing of agent state, with optional HAI.ai integration" +description = "MCP server for JACS: data provenance and cryptographic signing of agent state" readme = "README.md" license-file = "../LICENSE" homepage = "https://humanassisted.github.io/JACS" repository = "https://github.com/HumanAssisted/JACS" -authors = ["HAI.AI "] +authors = ["JACS Contributors"] keywords = ["mcp", "jacs", "ai", "agents", "signing", "provenance"] categories = ["cryptography", "development-tools"] [features] -default = ["mcp"] +default = ["mcp", "attestation"] mcp = ["dep:rmcp", "dep:tokio", "jacs/mcp-server"] http = [] +attestation = ["jacs/attestation", "jacs-binding-core/attestation"] [dependencies] anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } -rmcp = { version = "0.12", features = ["server", "transport-io", "macros"], optional = true } -tokio = { version = "1", features = ["rt-multi-thread", "macros"], optional = true } +rmcp = { version = "0.12", features = ["client", "server", "transport-io", "transport-child-process", "macros"], optional = true } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time"], optional = true } jacs = { path = "../jacs", default-features = true } -jacs-binding-core = { path = "../binding-core", features = ["hai"] } +jacs-binding-core = { path = "../binding-core" } serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1.0" uuid = { version = "1", features = ["v4"] } -url = "2" sha2 = "0.10.8" chrono = { version = "0.4", features = ["serde"] } diff --git a/jacs-mcp/README.md b/jacs-mcp/README.md index af7c79d22..2b145629b 100644 --- a/jacs-mcp/README.md +++ b/jacs-mcp/README.md @@ -1,12 +1,14 @@ # JACS MCP Server -A Model Context Protocol (MCP) server for **data provenance and cryptographic signing** of agent state, plus optional [HAI.ai](https://hai.ai) integration for cross-organization key discovery and attestation. +A Model Context Protocol (MCP) server for **data provenance and cryptographic signing** of agent state, messaging, agreements, and A2A interoperability. + +This is the canonical full JACS MCP server. The checked-in contract snapshot for downstream adapters lives at [`contract/jacs-mcp-contract.json`](contract/jacs-mcp-contract.json). JACS (JSON Agent Communication Standard) ensures that every file, memory, or configuration an AI agent touches can be signed, verified, and traced back to its origin -- no server required. ## What can it do? -The server exposes **21 tools** in five categories: +The server exposes **33 tools** in eight categories: ### Agent State (Data Provenance) @@ -15,9 +17,9 @@ Sign, verify, and manage files that represent agent state (memories, skills, pla | Tool | Description | |------|-------------| | `jacs_sign_state` | Sign a file to create a cryptographically signed JACS document | -| `jacs_verify_state` | Verify file integrity and signature authenticity (by file path or JACS document ID). For one-off verification without loading an agent, use `verify_standalone()` in the language bindings (jacspy, jacsnpm, jacsgo). | -| `jacs_load_state` | Load a signed state document, optionally verifying before returning content | -| `jacs_update_state` | Update a previously signed file -- re-hashes and re-signs | +| `jacs_verify_state` | Verify state document integrity/signature by JACS document ID (`jacs_id`). Path-based verification is deprecated for MCP security. | +| `jacs_load_state` | Load a signed state document by JACS document ID (`jacs_id`), optionally verifying first | +| `jacs_update_state` | Update a signed state document by JACS document ID (`jacs_id`) and re-sign | | `jacs_list_state` | List signed agent state documents with optional filtering | | `jacs_adopt_state` | Adopt an external file as signed state, recording its origin | @@ -70,25 +72,55 @@ Create multi-party cryptographic agreements — multiple agents formally commit - Enforce that only post-quantum algorithms are used for signing - Set a deadline after which the agreement expires -### HAI Integration (Optional) +### A2A Discovery -Register with [HAI.ai](https://hai.ai) for cross-organization trust and key distribution: +Export Agent Cards and well-known documents for [A2A protocol](https://github.com/a2aproject/A2A) interoperability: | Tool | Description | |------|-------------| -| `fetch_agent_key` | Fetch a public key from HAI's key distribution service | -| `register_agent` | Register the local agent with HAI (disabled by default) | -| `verify_agent` | Verify another agent's attestation level (0-3) | -| `check_agent_status` | Check registration status with HAI | -| `unregister_agent` | Unregister from HAI (disabled by default, not yet implemented) | +| `jacs_export_agent_card` | Export the local agent's A2A Agent Card (includes identity, skills, JACS extension) | +| `jacs_generate_well_known` | Generate all `.well-known` documents for A2A discovery (agent-card.json, jwks.json, jacs-agent.json, jacs-pubkey.json, jacs-extension.json) | +| `jacs_export_agent` | Export the local agent's full JACS JSON document (identity, public key hash, signed metadata) | + +### A2A Artifacts + +Sign, verify, and assess trust for A2A artifacts with JACS provenance: + +| Tool | Description | +|------|-------------| +| `jacs_wrap_a2a_artifact` | Wrap an A2A artifact with JACS provenance signature (supports chain-of-custody via parent signatures) | +| `jacs_verify_a2a_artifact` | Verify a JACS-wrapped A2A artifact's signature and hash | +| `jacs_assess_a2a_agent` | Assess the trust level of a remote A2A agent given its Agent Card | + +**Use A2A artifact tools to:** +- Sign task results, messages, or any A2A payload with cryptographic provenance +- Verify artifacts received from other agents before acting on them +- Assess whether a remote agent meets your trust policy before exchanging data +- Build chain-of-custody trails by referencing parent signatures + +### Trust Store + +Manage the local trust store -- which agents your agent trusts for signature verification: + +| Tool | Description | +|------|-------------| +| `jacs_trust_agent` | Add an agent to the local trust store (self-signature is verified first) | +| `jacs_untrust_agent` | Remove an agent from the trust store (requires `JACS_MCP_ALLOW_UNTRUST=true`) | +| `jacs_list_trusted_agents` | List all agent IDs currently in the local trust store | +| `jacs_is_trusted` | Check whether a specific agent is in the trust store | +| `jacs_get_trusted_agent` | Retrieve the full agent JSON document for a trusted agent | + +**Use the trust store to:** +- Build a list of known collaborators before exchanging signed artifacts +- Gate A2A interactions with `strict` trust policy (only trust-store agents accepted) +- Inspect a remote agent's full identity document before trusting ## Quick Start ### Step 1: Install JACS CLI ```bash -# From the JACS repository root -cargo install --path jacs +cargo install jacs --features cli ``` ### Step 2: Create Agent and Keys @@ -101,18 +133,19 @@ jacs init Or programmatically: ```bash -export JACS_AGENT_PRIVATE_KEY_PASSWORD="Your-Str0ng-P@ss!" +export JACS_PRIVATE_KEY_PASSWORD="Your-Str0ng-P@ss!" jacs agent create --create-keys true ``` -### Step 3: Build the MCP Server +### Step 3: Install or Run the MCP Server ```bash -cd jacs-mcp -cargo build --release -``` +# Install prebuilt jacs-mcp for your platform (default behavior) +jacs mcp install -The binary will be at `target/release/jacs-mcp`. +# Start stdio MCP server +jacs mcp run +``` ### Step 4: Configure Your MCP Client @@ -132,23 +165,6 @@ Add to your MCP client configuration (e.g., Claude Desktop): } ``` -To enable HAI integration, add `HAI_API_KEY`: - -```json -{ - "mcpServers": { - "jacs": { - "command": "/path/to/jacs-mcp", - "env": { - "JACS_CONFIG": "/path/to/jacs.config.json", - "JACS_PRIVATE_KEY_PASSWORD": "your-secure-password", - "HAI_API_KEY": "your-hai-api-key" - } - } - } -} -``` - ## Configuration ### Required Environment Variables @@ -158,20 +174,17 @@ To enable HAI integration, add `HAI_API_KEY`: ### Optional Environment Variables -- `HAI_ENDPOINT` - HAI API endpoint (default: `https://api.hai.ai`). Validated against an allowlist. -- `HAI_API_KEY` - API key for HAI authentication - `RUST_LOG` - Logging level (default: `info,rmcp=warn`) ### Security Options -- `JACS_MCP_ALLOW_REGISTRATION` - Set to `true` to enable `register_agent` (default: disabled) -- `JACS_MCP_ALLOW_UNREGISTRATION` - Set to `true` to enable `unregister_agent` (default: disabled) +- `JACS_MCP_ALLOW_REGISTRATION` - Set to `true` to enable `jacs_create_agent` (default: disabled) +- `JACS_MCP_ALLOW_UNTRUST` - Set to `true` to enable `jacs_untrust_agent` (default: disabled). Prevents prompt injection attacks from removing trusted agents without user consent. ### Example jacs.config.json ```json { - "$schema": "https://hai.ai/schemas/jacs.config.schema.json", "jacs_data_directory": "./jacs_data", "jacs_key_directory": "./jacs_keys", "jacs_agent_private_key_filename": "jacs.private.pem.enc", @@ -187,6 +200,7 @@ To enable HAI integration, add `HAI_API_KEY`: ### jacs_sign_state Sign an agent state file to create a cryptographically signed JACS document. +The resulting document is persisted in JACS storage for ID-based follow-up operations. **Parameters:** - `file_path` (required): Path to the file to sign @@ -195,34 +209,35 @@ Sign an agent state file to create a cryptographically signed JACS document. - `description` (optional): Description of the state document - `framework` (optional): Framework identifier (e.g., `claude-code`, `openclaw`) - `tags` (optional): Tags for categorization -- `embed` (optional): Whether to embed file content inline (always true for hooks) +- `embed` (optional): Whether to embed file content inline (defaults to true in MCP; always true for hooks) ### jacs_verify_state Verify the integrity and authenticity of a signed agent state. **Parameters:** -- `file_path` (optional): Path to the file to verify -- `jacs_id` (optional): JACS document ID to verify +- `jacs_id` (required in MCP usage): JACS document ID (`uuid:version`) to verify +- `file_path` (deprecated): Path-based verification is disabled for MCP security policy -At least one of `file_path` or `jacs_id` must be provided. +Use `jacs_id` from `jacs_sign_state` or `jacs_adopt_state`. ### jacs_load_state Load a signed agent state document, optionally verifying before returning content. **Parameters:** -- `file_path` (optional): Path to the file to load -- `jacs_id` (optional): JACS document ID to load +- `jacs_id` (required in MCP usage): JACS document ID (`uuid:version`) to load +- `file_path` (deprecated): Path-based loading is disabled for MCP security policy - `require_verified` (optional): Whether to require verification before loading (default: true) ### jacs_update_state -Update a previously signed agent state file with new content and re-sign. +Update a previously signed agent state document with new embedded content and re-sign. **Parameters:** -- `file_path` (required): Path to the file to update -- `new_content` (optional): New content to write. If omitted, re-signs current content. +- `jacs_id` (required in MCP usage): JACS document ID (`uuid:version`) to update +- `file_path` (deprecated): Path-based updates are disabled for MCP security policy +- `new_content` (optional): New embedded content. If omitted, re-signs current content. ### jacs_list_state @@ -255,8 +270,8 @@ Create a multi-party cryptographic agreement that other agents can co-sign. - `context` (optional): Additional context to help signers decide - `timeout` (optional): ISO 8601 deadline after which the agreement expires (e.g., "2025-12-31T23:59:59Z") - `quorum` (optional): Minimum signatures required (M-of-N). If omitted, all agents must sign. -- `required_algorithms` (optional): Only allow these signing algorithms: `RSA-PSS`, `ring-Ed25519`, `pq-dilithium`, `pq2025` -- `minimum_strength` (optional): `classical` (any algorithm) or `post-quantum` (pq-dilithium/pq2025 only) +- `required_algorithms` (optional): Only allow these signing algorithms: `RSA-PSS`, `ring-Ed25519`, `pq2025` +- `minimum_strength` (optional): `classical` (any algorithm) or `post-quantum` (`pq2025` only) ### jacs_sign_agreement @@ -295,54 +310,126 @@ Verify a signed JACS document given its full JSON string. Checks both the conten **Returns:** `success`, `valid`, `signer_id` (optional -- extracted from document if available), `message` -### fetch_agent_key +### jacs_export_agent_card + +Export the local agent's A2A Agent Card. The Agent Card follows the A2A v0.4.0 format and includes the JACS provenance extension. + +**Parameters:** None. + +**Returns:** `success`, `agent_card` (JSON string of the A2A Agent Card) + +### jacs_generate_well_known -Fetch a public key from HAI's key distribution service. +Generate all `.well-known` documents for A2A discovery. Returns an array of `{path, document}` objects that can be served at each path. **Parameters:** -- `agent_id` (required): The JACS agent ID (UUID format) -- `version` (optional): Key version to fetch, or `latest` +- `a2a_algorithm` (optional): A2A signing algorithm override (default: `ring-Ed25519`) -### register_agent +**Returns:** `success`, `documents` (JSON array of `{path, document}` objects), `count` -Register the local agent with HAI. **Requires `JACS_MCP_ALLOW_REGISTRATION=true`.** +### jacs_export_agent + +Export the local agent's full JACS JSON document, including identity, public key hash, and signed metadata. + +**Parameters:** None. + +**Returns:** `success`, `agent_json` (full agent JSON document), `agent_id` + +### jacs_trust_agent + +Add an agent to the local trust store. The agent's self-signature is cryptographically verified before it is added. If verification fails, the agent is NOT trusted. **Parameters:** -- `preview` (optional): If true (default), validates without actually registering +- `agent_json` (required): The full JACS agent JSON document to add to the trust store -### verify_agent +**Returns:** `success`, `agent_id`, `message` -Verify another agent's attestation level with HAI. +### jacs_untrust_agent + +Remove an agent from the local trust store. **Requires `JACS_MCP_ALLOW_UNTRUST=true`.** This security gate prevents prompt injection attacks from removing trusted agents without user consent. **Parameters:** -- `agent_id` (required): The JACS agent ID to verify -- `version` (optional): Agent version to verify, or `latest` +- `agent_id` (required): The JACS agent ID (UUID) to remove from the trust store + +**Returns:** `success`, `agent_id`, `message` + +### jacs_list_trusted_agents + +List all agent IDs currently in the local trust store. -**Attestation levels:** -- Level 0: No attestation -- Level 1: Key registered with HAI -- Level 2: DNS verified -- Level 3: Full HAI signature attestation +**Parameters:** None. -### check_agent_status +**Returns:** `success`, `agent_ids` (list of UUIDs), `count`, `message` -Check registration status of an agent with HAI. +### jacs_is_trusted + +Check whether a specific agent is in the local trust store. + +**Parameters:** +- `agent_id` (required): The JACS agent ID (UUID) to check trust status for + +**Returns:** `success`, `agent_id`, `trusted` (boolean), `message` + +### jacs_get_trusted_agent + +Retrieve the full agent JSON document for a trusted agent from the local trust store. Fails if the agent is not trusted. **Parameters:** -- `agent_id` (optional): Agent ID to check. If omitted, checks the local agent. +- `agent_id` (required): The JACS agent ID (UUID) to retrieve from the trust store -### unregister_agent +**Returns:** `success`, `agent_id`, `agent_json` (full agent document), `message` -Unregister the local agent from HAI. **Requires `JACS_MCP_ALLOW_UNREGISTRATION=true`.** +### jacs_wrap_a2a_artifact + +Wrap an A2A artifact with JACS provenance signature. Supports chain-of-custody by optionally referencing parent signatures from previous steps in a multi-agent workflow. **Parameters:** -- `preview` (optional): If true (default), validates without actually unregistering +- `artifact_json` (required): The A2A artifact JSON content to wrap with JACS provenance +- `artifact_type` (required): Artifact type identifier (e.g., `a2a-artifact`, `message`, `task-result`) +- `parent_signatures` (optional): JSON array of parent signatures for chain-of-custody provenance + +**Returns:** `success`, `wrapped_artifact` (JSON string with JACS provenance envelope), `message` + +### jacs_verify_a2a_artifact + +Verify a JACS-wrapped A2A artifact's signature and content hash. Checks that the artifact has not been tampered with and that the signature is valid. + +**Parameters:** +- `wrapped_artifact` (required): The JACS-wrapped A2A artifact JSON to verify + +**Returns:** `success`, `valid` (boolean), `verification_details` (JSON with signer info, hash check, parent chain status), `message` + +### jacs_assess_a2a_agent + +Assess the trust level of a remote A2A agent given its Agent Card. Applies a trust policy to determine whether your agent should interact with the remote agent. + +**Parameters:** +- `agent_card_json` (required): The A2A Agent Card JSON of the remote agent to assess +- `policy` (optional): Trust policy to apply: `open` (accept all), `verified` (require JACS extension, **default**), or `strict` (require trust store entry) + +**Returns:** `success`, `allowed` (boolean), `trust_level` (`Untrusted`, `JacsVerified`, or `ExplicitlyTrusted`), `policy`, `reason`, `message` + +## A2A Workflow Example + +Use the A2A discovery, trust store, and artifact tools together to establish trust and exchange signed artifacts: + +``` +1. Agent A: jacs_generate_well_known -> Serve .well-known documents +2. Agent B: jacs_export_agent_card -> Get Agent B's card +3. Agent A: jacs_assess_a2a_agent(agent_b_card) -> Check trust level before interacting +4. Agent A: jacs_trust_agent(agent_b_json) -> Add Agent B to trust store +5. Agent A: jacs_wrap_a2a_artifact(task, "task") -> Sign a task artifact for Agent B +6. Agent B: jacs_verify_a2a_artifact(wrapped_task) -> Verify Agent A's artifact +7. Agent B: jacs_wrap_a2a_artifact(result, "task-result", + parent_signatures=[step5]) -> Sign result with chain-of-custody +8. Agent A: jacs_verify_a2a_artifact(wrapped_result) -> Verify result + parent chain +``` + +For the full A2A quickstart guide, see the [A2A Quickstart](https://humanassisted.github.io/JACS/guides/a2a-quickstart.html) in the JACS Book. ## Security -- **Registration disabled by default**: `register_agent` and `unregister_agent` require explicit opt-in via environment variables, preventing prompt injection attacks. -- **Preview mode by default**: Even when enabled, registration defaults to preview mode. -- **Endpoint validation**: `HAI_ENDPOINT` is validated against an allowlist (`*.hai.ai`, localhost). +- **Destructive actions disabled by default**: `jacs_create_agent` and `jacs_untrust_agent` require explicit opt-in via environment variables, preventing prompt injection attacks. - **Password protection**: Private keys are encrypted. Never store passwords in config files. - **Stdio transport**: No network exposure -- communicates over stdin/stdout. @@ -365,6 +452,8 @@ cargo run - [JACS Book](https://humanassisted.github.io/JACS/) - Full documentation (published book) - [Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html) +- [A2A Quickstart](https://humanassisted.github.io/JACS/guides/a2a-quickstart.html) - A2A interoperability guide +- [A2A Interoperability](https://humanassisted.github.io/JACS/integrations/a2a.html) - Full A2A reference - [Source](https://github.com/HumanAssisted/JACS) - GitHub repository ## License diff --git a/jacs-mcp/contract/jacs-mcp-contract.json b/jacs-mcp/contract/jacs-mcp-contract.json new file mode 100644 index 000000000..ae1199de7 --- /dev/null +++ b/jacs-mcp/contract/jacs-mcp-contract.json @@ -0,0 +1,912 @@ +{ + "schema_version": 1, + "server": { + "name": "jacs-mcp", + "title": "JACS MCP Server", + "version": "0.9.0", + "website_url": "https://humanassisted.github.io/JACS/", + "instructions": "This MCP server provides data provenance and cryptographic signing for agent state files and agent-to-agent messaging. Agent state tools: jacs_sign_state (sign files), jacs_verify_state (verify integrity), jacs_load_state (load with verification), jacs_update_state (update and re-sign), jacs_list_state (list signed docs), jacs_adopt_state (adopt external files). Messaging tools: jacs_message_send (create and sign a message), jacs_message_update (update and re-sign a message), jacs_message_agree (co-sign/agree to a message), jacs_message_receive (verify and extract a received message). Agent management: jacs_create_agent (create new agent with keys), jacs_reencrypt_key (rotate private key password). A2A artifacts: jacs_wrap_a2a_artifact (sign artifact with provenance), jacs_verify_a2a_artifact (verify wrapped artifact), jacs_assess_a2a_agent (assess remote agent trust level). A2A discovery: jacs_export_agent_card (export Agent Card), jacs_generate_well_known (generate .well-known documents), jacs_export_agent (export full agent JSON). Trust store: jacs_trust_agent (add agent to trust store), jacs_untrust_agent (remove from trust store, requires JACS_MCP_ALLOW_UNTRUST=true), jacs_list_trusted_agents (list all trusted agent IDs), jacs_is_trusted (check if agent is trusted), jacs_get_trusted_agent (get trusted agent JSON). Attestation: jacs_attest_create (create signed attestation with claims), jacs_attest_verify (verify attestation, optionally with evidence checks), jacs_attest_lift (lift signed document into attestation), jacs_attest_export_dsse (export attestation as DSSE envelope). Security: jacs_audit (read-only security audit and health checks)." + }, + "tools": [ + { + "name": "jacs_adopt_state", + "description": "Adopt an external file as signed agent state. Like sign_state but marks the origin as 'adopted' and optionally records the source URL.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for adopting an external agent state file.", + "properties": { + "description": { + "description": "Optional description of what this adopted state document contains", + "type": [ + "string", + "null" + ] + }, + "file_path": { + "description": "Path to the file to adopt and sign as agent state", + "type": "string" + }, + "name": { + "description": "Human-readable name for this adopted state document", + "type": "string" + }, + "source_url": { + "description": "Optional URL where the content was originally obtained", + "type": [ + "string", + "null" + ] + }, + "state_type": { + "description": "Type of agent state: memory, skill, plan, config, or hook", + "type": "string" + } + }, + "required": [ + "file_path", + "state_type", + "name" + ], + "title": "AdoptStateParams", + "type": "object" + } + }, + { + "name": "jacs_assess_a2a_agent", + "description": "Assess the trust level of a remote A2A agent given its Agent Card. Applies a trust policy (open, verified, or strict) and returns whether the agent is allowed and at what trust level.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for assessing trust level of a remote A2A agent.", + "properties": { + "agent_card_json": { + "description": "The A2A Agent Card JSON of the remote agent to assess", + "type": "string" + }, + "policy": { + "description": "Trust policy: 'open' (accept all), 'verified' (require JACS), or 'strict' (require trust store)", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agent_card_json" + ], + "title": "AssessA2aAgentParams", + "type": "object" + } + }, + { + "name": "jacs_attest_create", + "description": "Create a signed attestation document. Provide a JSON string with: subject (type, id, digests), claims (name, value, confidence, assuranceLevel), and optional evidence, derivation, and policyContext. Requires the attestation feature.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for creating an attestation.", + "properties": { + "params_json": { + "description": "JSON string containing attestation parameters: { subject: { type, id, digests }, claims: [{ name, value, confidence?, assuranceLevel? }], evidence?: [...], derivation?: {...}, policyContext?: {...} }", + "type": "string" + } + }, + "required": [ + "params_json" + ], + "title": "AttestCreateParams", + "type": "object" + } + }, + { + "name": "jacs_attest_export_dsse", + "description": "Export an attestation as a DSSE envelope for in-toto/SLSA compatibility. Provide the signed attestation document JSON. Returns a DSSE envelope with payloadType, payload, and signatures. Requires the attestation feature.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for exporting an attestation as a DSSE envelope.", + "properties": { + "attestation_json": { + "description": "JSON string of the signed attestation document to export as DSSE", + "type": "string" + } + }, + "required": [ + "attestation_json" + ], + "title": "AttestExportDsseParams", + "type": "object" + } + }, + { + "name": "jacs_attest_lift", + "description": "Lift an existing signed JACS document into an attestation. Provide the signed document JSON and a JSON array of claims to attach. Requires the attestation feature.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for lifting a signed document to an attestation.", + "properties": { + "claims_json": { + "description": "JSON array of claim objects: [{ name, value, confidence?, assuranceLevel? }]", + "type": "string" + }, + "signed_doc_json": { + "description": "JSON string of the existing signed JACS document to lift", + "type": "string" + } + }, + "required": [ + "signed_doc_json", + "claims_json" + ], + "title": "AttestLiftParams", + "type": "object" + } + }, + { + "name": "jacs_attest_verify", + "description": "Verify an attestation document. Provide a document_key in 'jacsId:jacsVersion' format. Set full=true for full-tier verification including evidence and derivation chain checks. Requires the attestation feature.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for verifying an attestation.", + "properties": { + "document_key": { + "description": "Document key in 'jacsId:jacsVersion' format", + "type": "string" + }, + "full": { + "default": false, + "description": "Set to true for full-tier verification (evidence + chain checks)", + "type": "boolean" + } + }, + "required": [ + "document_key" + ], + "title": "AttestVerifyParams", + "type": "object" + } + }, + { + "name": "jacs_audit", + "description": "Run a read-only JACS security audit and health checks. Returns a JSON report with risks, health_checks, summary, and overall_status. Does not modify state. Optional: config_path, recent_n (number of recent documents to re-verify).", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for the JACS security audit tool.", + "properties": { + "config_path": { + "description": "Optional path to jacs.config.json", + "type": [ + "string", + "null" + ] + }, + "recent_n": { + "description": "Number of recent documents to re-verify (default from config)", + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "JacsAuditParams", + "type": "object" + } + }, + { + "name": "jacs_check_agreement", + "description": "Check the status of an agreement: how many agents have signed, whether quorum is met, whether it has expired, and which agents still need to sign. Use this to decide whether an agreement is complete and ready to act on.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for checking agreement status.\n\nUse this to see how many agents have signed, whether quorum is met,\nand whether the agreement has expired.", + "properties": { + "agreement_fieldname": { + "description": "Custom agreement field name (default: 'jacsAgreement')", + "type": [ + "string", + "null" + ] + }, + "signed_agreement": { + "description": "The agreement JSON to check status of", + "type": "string" + } + }, + "required": [ + "signed_agreement" + ], + "title": "CheckAgreementParams", + "type": "object" + } + }, + { + "name": "jacs_create_agent", + "description": "Create a new JACS agent with cryptographic keys. This is the programmatic equivalent of 'jacs create'. Returns agent ID and key paths. SECURITY: Requires JACS_MCP_ALLOW_REGISTRATION=true environment variable.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for creating a new JACS agent programmatically.", + "properties": { + "agent_type": { + "description": "Agent type (default: 'ai')", + "type": [ + "string", + "null" + ] + }, + "algorithm": { + "description": "Cryptographic algorithm: 'pq2025' (default, post-quantum), 'ring-Ed25519', or 'RSA-PSS'", + "type": [ + "string", + "null" + ] + }, + "data_directory": { + "description": "Directory for data files (default: ./jacs_data)", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "Description of the agent", + "type": [ + "string", + "null" + ] + }, + "key_directory": { + "description": "Directory for key files (default: ./jacs_keys)", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Name for the new agent", + "type": "string" + }, + "password": { + "description": "Password for encrypting the private key. Must be at least 8 characters with uppercase, lowercase, digit, and special character.", + "type": "string" + } + }, + "required": [ + "name", + "password" + ], + "title": "CreateAgentProgrammaticParams", + "type": "object" + } + }, + { + "name": "jacs_create_agreement", + "description": "Create a multi-party cryptographic agreement. Use this when multiple agents need to formally agree on something — like approving a deployment, authorizing a data transfer, or ratifying a decision. You specify which agents must sign, an optional quorum (e.g., 2-of-3), a timeout deadline, and algorithm constraints. Returns a signed agreement document to pass to other agents for co-signing.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for creating a multi-party agreement.\n\nAn agreement is a document that multiple agents must sign. Use this when agents\nneed to formally commit to a shared decision — for example, approving a deployment,\nauthorizing a data transfer, or reaching consensus on a proposal.", + "properties": { + "agent_ids": { + "description": "List of agent IDs (UUIDs) that are parties to this agreement", + "items": { + "type": "string" + }, + "type": "array" + }, + "context": { + "description": "Additional context for signers", + "type": [ + "string", + "null" + ] + }, + "document": { + "description": "JSON document that all parties will agree to. Can be any valid JSON object.", + "type": "string" + }, + "minimum_strength": { + "description": "Minimum crypto strength: 'classical' or 'post-quantum'", + "type": [ + "string", + "null" + ] + }, + "question": { + "description": "Question for signers, e.g. 'Do you approve deploying model v2?'", + "type": [ + "string", + "null" + ] + }, + "quorum": { + "description": "Minimum signatures required (M-of-N). If omitted, all agents must sign.", + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "required_algorithms": { + "description": "Only allow these signing algorithms. Values: 'RSA-PSS', 'ring-Ed25519', 'pq2025'", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "timeout": { + "description": "ISO 8601 deadline after which the agreement expires. Example: '2025-12-31T23:59:59Z'", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "document", + "agent_ids" + ], + "title": "CreateAgreementParams", + "type": "object" + } + }, + { + "name": "jacs_export_agent", + "description": "Export the local agent's full JACS JSON document. This includes the agent's identity, public key hash, and signed metadata.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for exporting the local agent's full JSON document (no params needed).", + "title": "ExportAgentParams", + "type": "object" + } + }, + { + "name": "jacs_export_agent_card", + "description": "Export this agent's A2A Agent Card as JSON. The Agent Card describes the agent's capabilities, endpoints, and identity for A2A discovery.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for exporting the local agent's A2A Agent Card (no params needed).", + "title": "ExportAgentCardParams", + "type": "object" + } + }, + { + "name": "jacs_generate_well_known", + "description": "Generate all .well-known documents for A2A discovery. Returns an array of {path, document} objects that can be served at each path for agent discovery.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for generating well-known documents.", + "properties": { + "a2a_algorithm": { + "description": "A2A signing algorithm override (default: ring-Ed25519)", + "type": [ + "string", + "null" + ] + } + }, + "title": "GenerateWellKnownParams", + "type": "object" + } + }, + { + "name": "jacs_get_trusted_agent", + "description": "Retrieve the full agent JSON document for a trusted agent from the local trust store. Fails if the agent is not trusted.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for getting a trusted agent's details.", + "properties": { + "agent_id": { + "description": "The JACS agent ID (UUID format) to retrieve from the trust store", + "type": "string" + } + }, + "required": [ + "agent_id" + ], + "title": "GetTrustedAgentParams", + "type": "object" + } + }, + { + "name": "jacs_is_trusted", + "description": "Check whether a specific agent is in the local trust store. Returns a boolean indicating trust status.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for checking if an agent is trusted.", + "properties": { + "agent_id": { + "description": "The JACS agent ID (UUID format) to check trust status for", + "type": "string" + } + }, + "required": [ + "agent_id" + ], + "title": "IsTrustedParams", + "type": "object" + } + }, + { + "name": "jacs_list_state", + "description": "List signed agent state documents, with optional filtering by type, framework, or tags.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for listing signed agent state documents.", + "properties": { + "framework": { + "description": "Filter by framework identifier", + "type": [ + "string", + "null" + ] + }, + "state_type": { + "description": "Filter by state type: memory, skill, plan, config, or hook", + "type": [ + "string", + "null" + ] + }, + "tags": { + "description": "Filter by tags (documents must have all specified tags)", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "ListStateParams", + "type": "object" + } + }, + { + "name": "jacs_list_trusted_agents", + "description": "List all agent IDs currently in the local trust store. Returns the count and a list of trusted agent IDs.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for listing trusted agents (no parameters required).", + "title": "ListTrustedAgentsParams", + "type": "object" + } + }, + { + "name": "jacs_load_state", + "description": "Load a signed agent state document and optionally verify it before returning the content.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for loading a signed agent state.", + "properties": { + "file_path": { + "description": "DEPRECATED for MCP security: direct file-path loading is disabled. Use jacs_id.", + "type": [ + "string", + "null" + ] + }, + "jacs_id": { + "description": "JACS document ID to load (uuid:version)", + "type": [ + "string", + "null" + ] + }, + "require_verified": { + "description": "Whether to require verification before loading (default true)", + "type": [ + "boolean", + "null" + ] + } + }, + "title": "LoadStateParams", + "type": "object" + } + }, + { + "name": "jacs_message_agree", + "description": "Verify and co-sign (agree to) a received signed message. Creates an agreement document that references the original message.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for agreeing to (co-signing) a received message.", + "properties": { + "signed_message": { + "description": "The full signed JSON document to agree to", + "type": "string" + } + }, + "required": [ + "signed_message" + ], + "title": "MessageAgreeParams", + "type": "object" + } + }, + { + "name": "jacs_message_receive", + "description": "Verify a received signed message and extract its content, sender ID, and timestamp. Use this to validate authenticity before processing a message from another agent.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for receiving and verifying a signed message.", + "properties": { + "signed_message": { + "description": "The full signed JSON document received from another agent", + "type": "string" + } + }, + "required": [ + "signed_message" + ], + "title": "MessageReceiveParams", + "type": "object" + } + }, + { + "name": "jacs_message_send", + "description": "Create and cryptographically sign a message for sending to another agent. Returns the signed JACS document that can be transmitted to the recipient.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for sending a signed message to another agent.", + "properties": { + "content": { + "description": "The message content to send", + "type": "string" + }, + "content_type": { + "description": "MIME type of the content (default: 'text/plain')", + "type": [ + "string", + "null" + ] + }, + "recipient_agent_id": { + "description": "The JACS agent ID of the recipient (UUID format)", + "type": "string" + } + }, + "required": [ + "recipient_agent_id", + "content" + ], + "title": "MessageSendParams", + "type": "object" + } + }, + { + "name": "jacs_message_update", + "description": "Update and re-sign an existing message document with new content.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for updating an existing signed message.", + "properties": { + "content": { + "description": "Updated message content", + "type": "string" + }, + "content_type": { + "description": "MIME type of the content (default: 'text/plain')", + "type": [ + "string", + "null" + ] + }, + "jacs_id": { + "description": "JACS document ID of the message to update", + "type": "string" + } + }, + "required": [ + "jacs_id", + "content" + ], + "title": "MessageUpdateParams", + "type": "object" + } + }, + { + "name": "jacs_reencrypt_key", + "description": "Re-encrypt the agent's private key with a new password. Use this to rotate the password protecting the private key without changing the key itself.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for re-encrypting the agent's private key.", + "properties": { + "new_password": { + "description": "New password. Must be at least 8 characters with uppercase, lowercase, digit, and special character.", + "type": "string" + }, + "old_password": { + "description": "Current password for the private key", + "type": "string" + } + }, + "required": [ + "old_password", + "new_password" + ], + "title": "ReencryptKeyParams", + "type": "object" + } + }, + { + "name": "jacs_sign_agreement", + "description": "Co-sign an existing agreement. Use this after receiving an agreement document from another agent. Your cryptographic signature is added to the agreement. The updated document can then be passed to the next signer or checked for completion.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for signing an existing agreement.\n\nUse this after receiving an agreement document from another agent.\nYour agent will cryptographically co-sign it, adding your signature\nto the agreement's signature list.", + "properties": { + "agreement_fieldname": { + "description": "Custom agreement field name (default: 'jacsAgreement')", + "type": [ + "string", + "null" + ] + }, + "signed_agreement": { + "description": "The full agreement JSON to sign. Obtained from jacs_create_agreement or from another agent.", + "type": "string" + } + }, + "required": [ + "signed_agreement" + ], + "title": "SignAgreementParams", + "type": "object" + } + }, + { + "name": "jacs_sign_document", + "description": "Sign arbitrary JSON content to create a cryptographically signed JACS document. Use this for attestation -- when you want to prove that content was signed by this agent. Returns the signed envelope with hash and document ID.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for signing arbitrary content as a JACS document.", + "properties": { + "content": { + "description": "The JSON content to sign as a JACS document", + "type": "string" + }, + "content_type": { + "description": "MIME type of the content (default: 'application/json')", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "content" + ], + "title": "SignDocumentParams", + "type": "object" + } + }, + { + "name": "jacs_sign_state", + "description": "Sign an agent state file (memory, skill, plan, config, or hook) to create a cryptographically signed JACS document. This establishes provenance and integrity for the file's contents.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for signing an agent state file.", + "properties": { + "description": { + "description": "Optional description of what this state document contains", + "type": [ + "string", + "null" + ] + }, + "embed": { + "description": "Whether to embed file content inline (default false, always true for hooks)", + "type": [ + "boolean", + "null" + ] + }, + "file_path": { + "description": "Path to the file to sign as agent state", + "type": "string" + }, + "framework": { + "description": "Optional framework identifier (e.g., 'claude-code', 'openclaw')", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Human-readable name for this state document", + "type": "string" + }, + "state_type": { + "description": "Type of agent state: memory, skill, plan, config, or hook", + "type": "string" + }, + "tags": { + "description": "Optional tags for categorization", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "file_path", + "state_type", + "name" + ], + "title": "SignStateParams", + "type": "object" + } + }, + { + "name": "jacs_trust_agent", + "description": "Add an agent to the local trust store. The agent's self-signature is cryptographically verified before it is trusted. Pass the full agent JSON document. Returns the trusted agent ID on success.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for adding an agent to the local trust store.", + "properties": { + "agent_json": { + "description": "The full JACS agent JSON document to add to the trust store", + "type": "string" + } + }, + "required": [ + "agent_json" + ], + "title": "TrustAgentParams", + "type": "object" + } + }, + { + "name": "jacs_untrust_agent", + "description": "Remove an agent from the local trust store. SECURITY: Requires JACS_MCP_ALLOW_UNTRUST=true environment variable to prevent prompt injection attacks from removing trusted agents without user consent.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for removing an agent from the trust store.", + "properties": { + "agent_id": { + "description": "The JACS agent ID (UUID format) to remove from the trust store", + "type": "string" + } + }, + "required": [ + "agent_id" + ], + "title": "UntrustAgentParams", + "type": "object" + } + }, + { + "name": "jacs_update_state", + "description": "Update a previously signed agent state file. Writes new content (if provided), recomputes the SHA-256 hash, and creates a new signed version.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for updating a signed agent state.", + "properties": { + "file_path": { + "description": "DEPRECATED for MCP security: direct file-path updates are disabled. Use jacs_id.", + "type": "string" + }, + "jacs_id": { + "description": "JACS document ID to update (uuid:version)", + "type": [ + "string", + "null" + ] + }, + "new_content": { + "description": "New embedded content for the JACS state document. If omitted, re-signs current embedded content.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "file_path" + ], + "title": "UpdateStateParams", + "type": "object" + } + }, + { + "name": "jacs_verify_a2a_artifact", + "description": "Verify a JACS-wrapped A2A artifact. Checks the cryptographic signature and hash to confirm the artifact was signed by the claimed agent and has not been tampered with.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for verifying a JACS-wrapped A2A artifact.", + "properties": { + "wrapped_artifact": { + "description": "The JACS-wrapped A2A artifact JSON to verify", + "type": "string" + } + }, + "required": [ + "wrapped_artifact" + ], + "title": "VerifyA2aArtifactParams", + "type": "object" + } + }, + { + "name": "jacs_verify_document", + "description": "Verify a signed JACS document given its full JSON string. Checks both the content hash and cryptographic signature. Use this when you have a signed document in memory (e.g. from an approval context or message payload) and need to confirm its integrity and authenticity.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for verifying a raw signed JACS document string.", + "properties": { + "document": { + "description": "The full signed JACS document JSON string to verify", + "type": "string" + } + }, + "required": [ + "document" + ], + "title": "VerifyDocumentParams", + "type": "object" + } + }, + { + "name": "jacs_verify_state", + "description": "Verify the integrity and authenticity of a signed agent state. Checks both the file hash and the cryptographic signature.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for verifying an agent state file or document.", + "properties": { + "file_path": { + "description": "DEPRECATED for MCP security: direct file-path verification is disabled. Use jacs_id.", + "type": [ + "string", + "null" + ] + }, + "jacs_id": { + "description": "JACS document ID to verify (uuid:version)", + "type": [ + "string", + "null" + ] + } + }, + "title": "VerifyStateParams", + "type": "object" + } + }, + { + "name": "jacs_wrap_a2a_artifact", + "description": "Wrap an A2A artifact with JACS provenance. Signs the artifact JSON, binding this agent's identity to the content. Optionally include parent signatures for chain-of-custody provenance.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Parameters for wrapping an A2A artifact with JACS provenance.", + "properties": { + "artifact_json": { + "description": "The A2A artifact JSON content to wrap with JACS provenance", + "type": "string" + }, + "artifact_type": { + "description": "Artifact type identifier (e.g., 'a2a-artifact', 'message', 'task-result')", + "type": "string" + }, + "parent_signatures": { + "description": "Optional JSON array of parent signatures for chain-of-custody provenance", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "artifact_json", + "artifact_type" + ], + "title": "WrapA2aArtifactParams", + "type": "object" + } + } + ] +} diff --git a/jacs-mcp/examples/print_contract.rs b/jacs-mcp/examples/print_contract.rs new file mode 100644 index 000000000..7df0ba06b --- /dev/null +++ b/jacs-mcp/examples/print_contract.rs @@ -0,0 +1,7 @@ +fn main() { + println!( + "{}", + serde_json::to_string_pretty(&jacs_mcp::canonical_contract_snapshot()) + .expect("canonical contract snapshot should serialize"), + ); +} diff --git a/jacs-mcp/src/config.rs b/jacs-mcp/src/config.rs new file mode 100644 index 000000000..6bf9cf008 --- /dev/null +++ b/jacs-mcp/src/config.rs @@ -0,0 +1,120 @@ +use anyhow::{Context, anyhow}; +use jacs_binding_core::AgentWrapper; +use std::path::{Path, PathBuf}; + +const MISSING_JACS_CONFIG_MESSAGE: &str = "JACS_CONFIG environment variable is not set. \n\ + \n\ + To use the JACS MCP server, you need to:\n\ + 1. Create a jacs.config.json file with your agent configuration\n\ + 2. Set JACS_CONFIG=/path/to/jacs.config.json\n\ + \n\ + See the README for a Quick Start guide on creating an agent."; + +pub fn load_agent_from_config_env() -> anyhow::Result { + let cfg_path = std::env::var("JACS_CONFIG").map_err(|_| anyhow!(MISSING_JACS_CONFIG_MESSAGE))?; + load_agent_from_config_path(cfg_path) +} + +pub fn load_agent_from_config_path(path: impl AsRef) -> anyhow::Result { + let config_path = path.as_ref(); + let config_path = if config_path.is_absolute() { + config_path.to_path_buf() + } else { + std::env::current_dir() + .context("Failed to determine current working directory")? + .join(config_path) + }; + + if !config_path.exists() { + return Err(anyhow!( + "Config file not found at '{}'. \n\ + \n\ + Please create a jacs.config.json file or update JACS_CONFIG \ + to point to an existing configuration file.", + config_path.display() + )); + } + + let cfg_str = std::fs::read_to_string(&config_path).map_err(|e| { + anyhow!( + "Failed to read config file '{}': {}. Check file permissions.", + config_path.display(), + e + ) + })?; + + let resolved_cfg_str = resolve_relative_config_paths(&cfg_str, &config_path)?; + + #[allow(deprecated)] + let _ = jacs::config::set_env_vars(true, Some(&resolved_cfg_str), false).map_err(|e| { + anyhow!( + "Invalid config file '{}': {}", + config_path.display(), + e + ) + })?; + + let agent_wrapper = AgentWrapper::new(); + tracing::info!(config_path = %config_path.display(), "Loading agent from config file"); + agent_wrapper + .load(config_path.to_string_lossy().into_owned()) + .map_err(|e| anyhow!("Failed to load agent: {}", e))?; + + tracing::info!("Agent loaded successfully from config"); + Ok(agent_wrapper) +} + +fn resolve_relative_config_paths(config_json: &str, config_path: &Path) -> anyhow::Result { + let mut value: serde_json::Value = + serde_json::from_str(config_json).context("Config file is not valid JSON")?; + let config_dir = config_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + + for field in ["jacs_data_directory", "jacs_key_directory"] { + if let Some(path_value) = value.get_mut(field) { + if let Some(path_str) = path_value.as_str() { + let path = Path::new(path_str); + if !path.is_absolute() { + *path_value = serde_json::Value::String( + config_dir.join(path).to_string_lossy().into_owned(), + ); + } + } + } + } + + serde_json::to_string(&value).context("Failed to serialize resolved config") +} + +#[cfg(test)] +mod tests { + use super::resolve_relative_config_paths; + use serde_json::json; + use std::path::Path; + + #[test] + fn resolves_relative_directories_against_config_location() { + let config = json!({ + "jacs_data_directory": "jacs_data", + "jacs_key_directory": "jacs_keys", + }); + + let resolved = resolve_relative_config_paths( + &config.to_string(), + Path::new("/tmp/example/jacs.config.json"), + ) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&resolved).unwrap(); + + assert_eq!( + parsed["jacs_data_directory"].as_str(), + Some("/tmp/example/jacs_data") + ); + assert_eq!( + parsed["jacs_key_directory"].as_str(), + Some("/tmp/example/jacs_keys") + ); + } +} diff --git a/jacs-mcp/src/contract.rs b/jacs-mcp/src/contract.rs new file mode 100644 index 000000000..381798452 --- /dev/null +++ b/jacs-mcp/src/contract.rs @@ -0,0 +1,71 @@ +use crate::jacs_tools::JacsMcpServer; +use jacs_binding_core::AgentWrapper; +use rmcp::ServerHandler; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Machine-readable snapshot of the canonical Rust MCP contract. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct JacsMcpContractSnapshot { + pub schema_version: u32, + pub server: JacsMcpServerMetadata, + pub tools: Vec, +} + +/// Stable server metadata exported for downstream adapter drift tests. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct JacsMcpServerMetadata { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub website_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, +} + +/// Stable per-tool metadata exported from the canonical Rust server. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct JacsMcpToolContract { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub input_schema: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_schema: Option, +} + +/// Export the canonical Rust MCP contract for documentation and drift tests. +pub fn canonical_contract_snapshot() -> JacsMcpContractSnapshot { + let mut tools: Vec = JacsMcpServer::tools() + .into_iter() + .map(|tool| JacsMcpToolContract { + name: tool.name.to_string(), + title: tool.title.clone(), + description: tool.description.as_ref().map(|description| description.to_string()), + input_schema: tool.schema_as_json_value(), + output_schema: tool + .output_schema + .map(|schema| Value::Object(schema.as_ref().clone())), + }) + .collect(); + + tools.sort_by(|left, right| left.name.cmp(&right.name)); + + let info = JacsMcpServer::new(AgentWrapper::new()).get_info(); + + JacsMcpContractSnapshot { + schema_version: 1, + server: JacsMcpServerMetadata { + name: info.server_info.name, + title: info.server_info.title, + version: info.server_info.version, + website_url: info.server_info.website_url, + instructions: info.instructions, + }, + tools, + } +} diff --git a/jacs-mcp/src/hai_tools.rs b/jacs-mcp/src/jacs_tools.rs similarity index 57% rename from jacs-mcp/src/hai_tools.rs rename to jacs-mcp/src/jacs_tools.rs index 1c53a0eca..814173a36 100644 --- a/jacs-mcp/src/hai_tools.rs +++ b/jacs-mcp/src/jacs_tools.rs @@ -1,25 +1,11 @@ -//! HAI MCP tools for LLM integration. +//! JACS MCP tools for data provenance and cryptographic signing. //! -//! This module provides MCP tools that allow LLMs to interact with HAI services: -//! -//! - `fetch_agent_key` - Fetch a public key from HAI's key distribution service -//! - `register_agent` - Register the local agent with HAI -//! - `verify_agent` - Verify another agent's attestation level -//! - `check_agent_status` - Check registration status with HAI -//! - `unregister_agent` - Unregister an agent from HAI -//! -//! # Security Features -//! -//! - **Registration Authorization**: The `register_agent` tool requires explicit enablement -//! via `JACS_MCP_ALLOW_REGISTRATION=true` environment variable. This prevents prompt -//! injection attacks from registering agents without user consent. -//! -//! - **Preview Mode by Default**: Even when enabled, registration defaults to preview mode -//! unless `preview=false` is explicitly set. +//! This module provides MCP tools for agent state signing, verification, +//! messaging, agreements, A2A interoperability, and trust store management. use jacs::schema::agentstate_crud; -use jacs_binding_core::hai::HaiClient; -use jacs_binding_core::{AgentWrapper, fetch_remote_key}; +use jacs::validation::require_relative_path_safe; +use jacs_binding_core::AgentWrapper; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{Implementation, ServerCapabilities, ServerInfo, Tool, ToolsCapability}; @@ -63,194 +49,104 @@ fn is_registration_allowed() -> bool { .unwrap_or(false) } -/// Check if unregistration is allowed via environment variable. -fn is_unregistration_allowed() -> bool { - std::env::var("JACS_MCP_ALLOW_UNREGISTRATION") +/// Check if untrusting agents is allowed via environment variable. +/// Untrusting requires explicit opt-in to prevent prompt injection attacks +/// from removing trusted agents without user consent. +fn is_untrust_allowed() -> bool { + std::env::var("JACS_MCP_ALLOW_UNTRUST") .map(|v| v.to_lowercase() == "true" || v == "1") .unwrap_or(false) } -// ============================================================================= -// Request/Response Types -// ============================================================================= - -/// Parameters for fetching an agent's public key from HAI. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct FetchAgentKeyParams { - /// The unique identifier of the agent whose key to fetch. - #[schemars(description = "The JACS agent ID (UUID format)")] - pub agent_id: String, - - /// The version of the key to fetch. Use "latest" for the most recent version. - #[schemars(description = "Key version to fetch, or 'latest' for most recent")] - pub version: Option, -} - -/// Result of fetching an agent's public key. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct FetchAgentKeyResult { - /// Whether the operation succeeded. - pub success: bool, - - /// The agent ID. - pub agent_id: String, - - /// The key version. - pub version: String, - - /// The cryptographic algorithm (e.g., "ed25519", "pq-dilithium"). - pub algorithm: String, - - /// The SHA-256 hash of the public key. - pub public_key_hash: String, - - /// The public key in base64 encoding. - pub public_key_base64: String, - - /// Error message if the operation failed. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// Parameters for registering the local agent with HAI. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RegisterAgentParams { - /// Whether to run in preview mode (validate without registering). - #[schemars(description = "If true, validates registration without actually registering")] - pub preview: Option, +/// Build a stable storage lookup key (`jacsId:jacsVersion`) from a signed document. +fn extract_document_lookup_key(doc: &serde_json::Value) -> Option { + let id = doc + .get("jacsId") + .or_else(|| doc.get("id")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + + let version = doc + .get("jacsVersion") + .or_else(|| doc.get("version")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + + match (id, version) { + (Some(i), Some(v)) => Some(format!("{}:{}", i, v)), + (Some(i), None) => Some(i.to_string()), + _ => None, + } } -/// Result of agent registration. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RegisterAgentResult { - /// Whether the operation succeeded. - pub success: bool, - - /// The registered agent's JACS ID. - #[serde(skip_serializing_if = "Option::is_none")] - pub agent_id: Option, - - /// The JACS document ID. - #[serde(skip_serializing_if = "Option::is_none")] - pub jacs_id: Option, - - /// Whether DNS verification was successful. - pub dns_verified: bool, - - /// Whether this was a preview-only operation. - pub preview_mode: bool, - - /// Human-readable status message. - pub message: String, - - /// Error message if the operation failed. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, +/// Parse a signed document JSON string and return its stable lookup key. +fn extract_document_lookup_key_from_str(document_json: &str) -> Option { + serde_json::from_str::(document_json) + .ok() + .and_then(|v| extract_document_lookup_key(&v)) } -/// Parameters for verifying another agent's attestation. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct VerifyAgentParams { - /// The agent ID to verify. - #[schemars(description = "The JACS agent ID to verify")] - pub agent_id: String, - - /// The version to verify (defaults to "latest"). - #[schemars(description = "Agent version to verify, or 'latest'")] - pub version: Option, +/// Pull embedded state content from a signed agent-state document. +fn extract_embedded_state_content(doc: &serde_json::Value) -> Option { + doc.get("jacsAgentStateContent") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| { + doc.get("jacsFiles") + .and_then(|v| v.as_array()) + .and_then(|files| files.first()) + .and_then(|file| file.get("contents")) + .and_then(|v| v.as_str()) + .map(String::from) + }) } -/// Result of agent verification. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct VerifyAgentResult { - /// Whether the verification succeeded. - pub success: bool, - - /// The agent ID that was verified. - pub agent_id: String, - - /// The attestation level (0-3). - /// - Level 0: No attestation - /// - Level 1: Key registered with HAI - /// - Level 2: DNS verified - /// - Level 3: Full HAI signature attestation - pub attestation_level: u8, +/// Update embedded state content and keep per-file content hashes in sync. +fn update_embedded_state_content(doc: &mut serde_json::Value, new_content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(new_content.as_bytes()); + let new_hash = format!("{:x}", hasher.finalize()); - /// Human-readable description of the attestation level. - pub attestation_description: String, + doc["jacsAgentStateContent"] = serde_json::json!(new_content); - /// Whether the agent's public key was found. - pub key_found: bool, + if let Some(files) = doc.get_mut("jacsFiles").and_then(|v| v.as_array_mut()) { + for file in files { + if let Some(obj) = file.as_object_mut() { + obj.insert("embed".to_string(), serde_json::json!(true)); + obj.insert("contents".to_string(), serde_json::json!(new_content)); + obj.insert("sha256".to_string(), serde_json::json!(new_hash.clone())); + } + } + } - /// Error message if verification failed. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, + new_hash } -/// Parameters for checking agent registration status. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CheckAgentStatusParams { - /// Optional agent ID to check. If not provided, checks the local agent. - #[schemars(description = "Agent ID to check status for. If omitted, checks the local agent.")] - pub agent_id: Option, +fn value_string(doc: &serde_json::Value, field: &str) -> Option { + doc.get(field).and_then(|v| v.as_str()).map(str::to_string) } -/// Result of checking agent status. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CheckAgentStatusResult { - /// Whether the operation succeeded. - pub success: bool, - - /// The agent ID that was checked. - pub agent_id: String, - - /// Whether the agent is registered with HAI. - pub registered: bool, - - /// HAI registration ID (if registered). - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_id: Option, - - /// When the agent was registered (ISO 8601). - #[serde(skip_serializing_if = "Option::is_none")] - pub registered_at: Option, - - /// Number of HAI signatures on the registration. - pub signature_count: usize, - - /// Error message if the operation failed. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, +fn value_string_vec(doc: &serde_json::Value, field: &str) -> Option> { + doc.get(field).and_then(|v| v.as_array()).map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect::>() + }) } -/// Parameters for unregistering an agent from HAI. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct UnregisterAgentParams { - /// Whether to run in preview mode (validate without unregistering). - #[schemars(description = "If true, validates unregistration without actually unregistering")] - pub preview: Option, +/// Extract verification validity from `verify_a2a_artifact` details JSON. +/// Defaults to `false` on malformed/missing fields to avoid optimistic trust. +fn extract_verify_a2a_valid(details_json: &str) -> bool { + serde_json::from_str::(details_json) + .ok() + .and_then(|v| v.get("valid").and_then(|b| b.as_bool())) + .unwrap_or(false) } -/// Result of agent unregistration. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct UnregisterAgentResult { - /// Whether the operation succeeded. - pub success: bool, - - /// The unregistered agent's JACS ID. - #[serde(skip_serializing_if = "Option::is_none")] - pub agent_id: Option, - - /// Whether this was a preview-only operation. - pub preview_mode: bool, - - /// Human-readable status message. - pub message: String, - - /// Error message if the operation failed. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} +// ============================================================================= +// Request/Response Types +// ============================================================================= // ============================================================================= // Agent Management Request/Response Types @@ -420,14 +316,12 @@ pub struct SignStateResult { pub struct VerifyStateParams { /// Path to the original file to verify against. #[schemars( - description = "Path to the file to verify (at least one of file_path or jacs_id required)" + description = "DEPRECATED for MCP security: direct file-path verification is disabled. Use jacs_id." )] pub file_path: Option, /// JACS document ID to verify. - #[schemars( - description = "JACS document ID to verify (at least one of file_path or jacs_id required)" - )] + #[schemars(description = "JACS document ID to verify (uuid:version)")] pub jacs_id: Option, } @@ -460,14 +354,12 @@ pub struct VerifyStateResult { pub struct LoadStateParams { /// Path to the file to load. #[schemars( - description = "Path to the file to load (at least one of file_path or jacs_id required)" + description = "DEPRECATED for MCP security: direct file-path loading is disabled. Use jacs_id." )] pub file_path: Option, /// JACS document ID to load. - #[schemars( - description = "JACS document ID to load (at least one of file_path or jacs_id required)" - )] + #[schemars(description = "JACS document ID to load (uuid:version)")] pub jacs_id: Option, /// Whether to require verification before loading (default true). @@ -504,12 +396,18 @@ pub struct LoadStateResult { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct UpdateStateParams { /// Path to the file to update. - #[schemars(description = "Path to the file to update (must have been previously signed)")] + #[schemars( + description = "DEPRECATED for MCP security: direct file-path updates are disabled. Use jacs_id." + )] pub file_path: String, + /// JACS document ID to update (uuid:version). + #[schemars(description = "JACS document ID to update (uuid:version)")] + pub jacs_id: Option, + /// New content to write to the file. If omitted, re-signs current content. #[schemars( - description = "New content to write to the file. If omitted, re-signs current file content." + description = "New embedded content for the JACS state document. If omitted, re-signs current embedded content." )] pub new_content: Option, } @@ -639,403 +537,730 @@ pub struct AdoptStateResult { } // ============================================================================= -// Document Sign/Verify Request/Response Types +// Trust Store Request/Response Types // ============================================================================= -/// Parameters for verifying a raw signed JACS document string. +/// Parameters for adding an agent to the local trust store. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct VerifyDocumentParams { - /// The full JACS signed document as a JSON string. - #[schemars(description = "The full signed JACS document JSON string to verify")] - pub document: String, +pub struct TrustAgentParams { + /// The full agent JSON document to trust. + #[schemars(description = "The full JACS agent JSON document to add to the trust store")] + pub agent_json: String, } -/// Result of verifying a signed document. +/// Result of trusting an agent. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct VerifyDocumentResult { - /// Whether the operation completed without error. +pub struct TrustAgentResult { + /// Whether the operation succeeded. pub success: bool, - /// Whether the document's hash and signature are valid. - pub valid: bool, - - /// The signer's agent ID, if available. + /// The trusted agent's ID. #[serde(skip_serializing_if = "Option::is_none")] - pub signer_id: Option, + pub agent_id: Option, /// Human-readable status message. pub message: String, - /// Error message if verification failed. + /// Error message if the operation failed. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -/// Parameters for signing arbitrary content as a JACS document. +/// Parameters for removing an agent from the trust store. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct SignDocumentParams { - /// The JSON content string to sign. - #[schemars(description = "The JSON content to sign as a JACS document")] - pub content: String, - - /// Optional MIME type of the content (default: "application/json"). - #[schemars(description = "MIME type of the content (default: 'application/json')")] - pub content_type: Option, +pub struct UntrustAgentParams { + /// The agent ID (UUID) to remove from the trust store. + #[schemars(description = "The JACS agent ID (UUID format) to remove from the trust store")] + pub agent_id: String, } -/// Result of signing a document. +/// Result of untrusting an agent. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct SignDocumentResult { +pub struct UntrustAgentResult { /// Whether the operation succeeded. pub success: bool, - /// The full signed JACS document as a JSON string. - #[serde(skip_serializing_if = "Option::is_none")] - pub signed_document: Option, + /// The agent ID that was removed. + pub agent_id: String, - /// SHA-256 hash of the signed document content. - #[serde(skip_serializing_if = "Option::is_none")] - pub content_hash: Option, + /// Human-readable status message. + pub message: String, - /// The JACS document ID assigned to the signed document. + /// Error message if the operation failed. #[serde(skip_serializing_if = "Option::is_none")] - pub jacs_document_id: Option, + pub error: Option, +} + +/// Parameters for listing trusted agents (no parameters required). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListTrustedAgentsParams {} + +/// Result of listing trusted agents. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListTrustedAgentsResult { + /// Whether the operation succeeded. + pub success: bool, + + /// List of trusted agent IDs. + pub agent_ids: Vec, + + /// Number of trusted agents. + pub count: usize, /// Human-readable status message. pub message: String, - /// Error message if signing failed. + /// Error message if the operation failed. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -// ============================================================================= -// Message Request/Response Types -// ============================================================================= +/// Parameters for checking if an agent is trusted. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct IsTrustedParams { + /// The agent ID (UUID) to check. + #[schemars(description = "The JACS agent ID (UUID format) to check trust status for")] + pub agent_id: String, +} -/// Parameters for sending a signed message to another agent. +/// Result of checking trust status. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct MessageSendParams { - /// The recipient agent's ID (UUID format). - #[schemars(description = "The JACS agent ID of the recipient (UUID format)")] - pub recipient_agent_id: String, +pub struct IsTrustedResult { + /// Whether the operation succeeded. + pub success: bool, - /// The message content to send. - #[schemars(description = "The message content to send")] - pub content: String, + /// The agent ID that was checked. + pub agent_id: String, - /// The MIME type of the content (default: "text/plain"). - #[schemars(description = "MIME type of the content (default: 'text/plain')")] - pub content_type: Option, + /// Whether the agent is in the trust store. + pub trusted: bool, + + /// Human-readable status message. + pub message: String, } -/// Result of sending a signed message. +/// Parameters for getting a trusted agent's details. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct MessageSendResult { +pub struct GetTrustedAgentParams { + /// The agent ID (UUID) to retrieve from the trust store. + #[schemars(description = "The JACS agent ID (UUID format) to retrieve from the trust store")] + pub agent_id: String, +} + +/// Result of getting a trusted agent's details. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GetTrustedAgentResult { /// Whether the operation succeeded. pub success: bool, - /// The JACS document ID of the signed message. - #[serde(skip_serializing_if = "Option::is_none")] - pub jacs_document_id: Option, + /// The agent ID. + pub agent_id: String, - /// The full signed message JSON. + /// The full agent JSON document from the trust store. #[serde(skip_serializing_if = "Option::is_none")] - pub signed_message: Option, + pub agent_json: Option, + + /// Human-readable status message. + pub message: String, /// Error message if the operation failed. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -/// Parameters for updating an existing signed message. +// ============================================================================= +// A2A Artifact Wrapping/Verification Request/Response Types +// ============================================================================= + +/// Parameters for wrapping an A2A artifact with JACS provenance. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct MessageUpdateParams { - /// The JACS document ID of the message to update. - #[schemars(description = "JACS document ID of the message to update")] - pub jacs_id: String, +pub struct WrapA2aArtifactParams { + /// The artifact JSON content to wrap and sign. + #[schemars(description = "The A2A artifact JSON content to wrap with JACS provenance")] + pub artifact_json: String, - /// The new message content. - #[schemars(description = "Updated message content")] - pub content: String, + /// The artifact type identifier (e.g., "a2a-artifact", "message", "task-result"). + #[schemars( + description = "Artifact type identifier (e.g., 'a2a-artifact', 'message', 'task-result')" + )] + pub artifact_type: String, - /// The MIME type of the content (default: "text/plain"). - #[schemars(description = "MIME type of the content (default: 'text/plain')")] - pub content_type: Option, + /// Optional parent signatures JSON array for chain-of-custody. + #[schemars( + description = "Optional JSON array of parent signatures for chain-of-custody provenance" + )] + pub parent_signatures: Option, } -/// Result of updating a signed message. +/// Result of wrapping an A2A artifact. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct MessageUpdateResult { +pub struct WrapA2aArtifactResult { /// Whether the operation succeeded. pub success: bool, - /// The JACS document ID of the updated message. + /// The wrapped artifact as a JSON string with JACS provenance. #[serde(skip_serializing_if = "Option::is_none")] - pub jacs_document_id: Option, + pub wrapped_artifact: Option, - /// The full updated signed message JSON. - #[serde(skip_serializing_if = "Option::is_none")] - pub signed_message: Option, + /// Human-readable status message. + pub message: String, /// Error message if the operation failed. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -/// Parameters for agreeing to (co-signing) a received message. +/// Parameters for verifying a JACS-wrapped A2A artifact. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct MessageAgreeParams { - /// The full signed message JSON document to agree to. - #[schemars(description = "The full signed JSON document to agree to")] - pub signed_message: String, +pub struct VerifyA2aArtifactParams { + /// The wrapped artifact JSON to verify. + #[schemars(description = "The JACS-wrapped A2A artifact JSON to verify")] + pub wrapped_artifact: String, } -/// Result of agreeing to a message. +/// Result of verifying a wrapped A2A artifact. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct MessageAgreeResult { +pub struct VerifyA2aArtifactResult { /// Whether the operation succeeded. pub success: bool, - /// The document ID of the original message. - #[serde(skip_serializing_if = "Option::is_none")] - pub original_document_id: Option, + /// Whether the artifact's signature and hash are valid. + pub valid: bool, - /// The document ID of the agreement document. + /// The verification result details as JSON. #[serde(skip_serializing_if = "Option::is_none")] - pub agreement_document_id: Option, + pub verification_details: Option, - /// The full signed agreement JSON. - #[serde(skip_serializing_if = "Option::is_none")] - pub signed_agreement: Option, + /// Human-readable status message. + pub message: String, - /// Error message if the operation failed. + /// Error message if verification failed. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -/// Parameters for receiving and verifying a signed message. +/// Parameters for assessing trust level of a remote A2A agent. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct MessageReceiveParams { - /// The full signed message JSON document received from another agent. - #[schemars(description = "The full signed JSON document received from another agent")] - pub signed_message: String, +pub struct AssessA2aAgentParams { + /// The Agent Card JSON of the remote agent to assess. + #[schemars(description = "The A2A Agent Card JSON of the remote agent to assess")] + pub agent_card_json: String, + + /// Trust policy to apply: "open", "verified", or "strict". + #[schemars( + description = "Trust policy: 'open' (accept all), 'verified' (require JACS), or 'strict' (require trust store)" + )] + pub policy: Option, } -/// Result of receiving and verifying a signed message. +/// Result of assessing an A2A agent's trust level. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct MessageReceiveResult { +pub struct AssessA2aAgentResult { /// Whether the operation succeeded. pub success: bool, - /// The sender's agent ID. - #[serde(skip_serializing_if = "Option::is_none")] - pub sender_agent_id: Option, + /// Whether the agent is allowed under the specified policy. + pub allowed: bool, - /// The extracted message content. + /// The trust level: "Untrusted", "JacsVerified", or "ExplicitlyTrusted". #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, + pub trust_level: Option, - /// The content MIME type. + /// The policy that was applied. #[serde(skip_serializing_if = "Option::is_none")] - pub content_type: Option, + pub policy: Option, - /// The message timestamp (ISO 8601). + /// Reason for the assessment result. #[serde(skip_serializing_if = "Option::is_none")] - pub timestamp: Option, + pub reason: Option, - /// Whether the cryptographic signature is valid. - pub signature_valid: bool, + /// Human-readable status message. + pub message: String, - /// Error message if the operation failed. + /// Error message if the assessment failed. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } // ============================================================================= -// Agreement Types — Multi-party cryptographic agreements +// Agent Card & Well-Known Request/Response Types // ============================================================================= -/// Parameters for creating a multi-party agreement. -/// -/// An agreement is a document that multiple agents must sign. Use this when agents -/// need to formally commit to a shared decision — for example, approving a deployment, -/// authorizing a data transfer, or reaching consensus on a proposal. +/// Parameters for exporting the local agent's A2A Agent Card (no params needed). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CreateAgreementParams { - /// The document to create an agreement for, as a JSON string. - /// This is the content all parties will be agreeing to. - #[schemars( - description = "JSON document that all parties will agree to. Can be any valid JSON object." - )] - pub document: String, +pub struct ExportAgentCardParams {} - /// List of agent IDs (UUIDs) that must sign this agreement. - /// Include your own agent ID if you want to be a required signer. - #[schemars(description = "List of agent IDs (UUIDs) that are parties to this agreement")] - pub agent_ids: Vec, +/// Result of exporting the Agent Card. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ExportAgentCardResult { + /// Whether the operation succeeded. + pub success: bool, - /// A human-readable question summarizing what signers are agreeing to. - #[schemars(description = "Question for signers, e.g. 'Do you approve deploying model v2?'")] - pub question: Option, + /// The Agent Card as a JSON string. + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_card: Option, - /// Additional context to help signers make their decision. - #[schemars(description = "Additional context for signers")] - pub context: Option, + /// Human-readable status message. + pub message: String, - /// ISO 8601 deadline. The agreement expires if not fully signed by this time. - /// Example: "2025-12-31T23:59:59Z" - #[schemars( - description = "ISO 8601 deadline after which the agreement expires. Example: '2025-12-31T23:59:59Z'" - )] - pub timeout: Option, + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} - /// Minimum number of signatures required (M-of-N). If omitted, ALL agents must sign. - /// For example, quorum=2 with 3 agent_ids means any 2 of 3 signers is sufficient. - #[schemars( - description = "Minimum signatures required (M-of-N). If omitted, all agents must sign." - )] - pub quorum: Option, +/// Parameters for generating well-known documents. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GenerateWellKnownParams { + /// Optional A2A signing algorithm override (default: ring-Ed25519). + #[schemars(description = "A2A signing algorithm override (default: ring-Ed25519)")] + pub a2a_algorithm: Option, +} - /// Only allow agents using these algorithms to sign. - /// Values: "RSA-PSS", "ring-Ed25519", "pq-dilithium", "pq2025" - #[schemars( - description = "Only allow these signing algorithms. Values: 'RSA-PSS', 'ring-Ed25519', 'pq-dilithium', 'pq2025'" - )] - pub required_algorithms: Option>, +/// Result of generating well-known documents. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GenerateWellKnownResult { + /// Whether the operation succeeded. + pub success: bool, - /// Minimum cryptographic strength: "classical" (any algorithm) or "post-quantum" (pq-dilithium, pq2025 only). - #[schemars(description = "Minimum crypto strength: 'classical' or 'post-quantum'")] - pub minimum_strength: Option, + /// The well-known documents as a JSON array of {path, document} objects. + #[serde(skip_serializing_if = "Option::is_none")] + pub documents: Option, + + /// Number of documents generated. + pub count: usize, + + /// Human-readable status message. + pub message: String, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, } -/// Result of creating an agreement. +/// Parameters for exporting the local agent's full JSON document (no params needed). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CreateAgreementResult { +pub struct ExportAgentParams {} + +/// Result of exporting the agent document. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ExportAgentResult { + /// Whether the operation succeeded. pub success: bool, - /// The JACS document ID of the agreement. + /// The full agent JSON document. #[serde(skip_serializing_if = "Option::is_none")] - pub agreement_id: Option, + pub agent_json: Option, - /// The full signed agreement JSON. Pass this to other agents for signing. + /// The agent's ID (UUID). #[serde(skip_serializing_if = "Option::is_none")] - pub signed_agreement: Option, + pub agent_id: Option, + + /// Human-readable status message. + pub message: String, + /// Error message if the operation failed. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -/// Parameters for signing an existing agreement. -/// -/// Use this after receiving an agreement document from another agent. -/// Your agent will cryptographically co-sign it, adding your signature -/// to the agreement's signature list. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct SignAgreementParams { - /// The full signed agreement JSON document to co-sign. - #[schemars( - description = "The full agreement JSON to sign. Obtained from jacs_create_agreement or from another agent." - )] - pub signed_agreement: String, +// ============================================================================= +// Document Sign/Verify Request/Response Types +// ============================================================================= - /// Optional custom agreement field name (default: 'jacsAgreement'). - #[schemars(description = "Custom agreement field name (default: 'jacsAgreement')")] - pub agreement_fieldname: Option, +/// Parameters for verifying a raw signed JACS document string. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct VerifyDocumentParams { + /// The full JACS signed document as a JSON string. + #[schemars(description = "The full signed JACS document JSON string to verify")] + pub document: String, } -/// Result of signing an agreement. +/// Result of verifying a signed document. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct SignAgreementResult { +pub struct VerifyDocumentResult { + /// Whether the operation completed without error. pub success: bool, - /// The updated agreement JSON with your signature added. - #[serde(skip_serializing_if = "Option::is_none")] - pub signed_agreement: Option, + /// Whether the document's hash and signature are valid. + pub valid: bool, - /// Number of signatures now on the agreement. + /// The signer's agent ID, if available. #[serde(skip_serializing_if = "Option::is_none")] - pub signature_count: Option, + pub signer_id: Option, + + /// Human-readable status message. + pub message: String, + /// Error message if verification failed. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -/// Parameters for checking agreement status. -/// -/// Use this to see how many agents have signed, whether quorum is met, -/// and whether the agreement has expired. +/// Parameters for signing arbitrary content as a JACS document. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CheckAgreementParams { - /// The full signed agreement JSON document to check. - #[schemars(description = "The agreement JSON to check status of")] - pub signed_agreement: String, +pub struct SignDocumentParams { + /// The JSON content string to sign. + #[schemars(description = "The JSON content to sign as a JACS document")] + pub content: String, - /// Optional custom agreement field name (default: 'jacsAgreement'). - #[schemars(description = "Custom agreement field name (default: 'jacsAgreement')")] - pub agreement_fieldname: Option, + /// Optional MIME type of the content (default: "application/json"). + #[schemars(description = "MIME type of the content (default: 'application/json')")] + pub content_type: Option, } -/// Result of checking an agreement's status. +/// Result of signing a document. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CheckAgreementResult { +pub struct SignDocumentResult { + /// Whether the operation succeeded. pub success: bool, - /// Whether the agreement is complete (quorum met, not expired, all signatures valid). - pub complete: bool, + /// The full signed JACS document as a JSON string. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_document: Option, - /// Total agents required to sign. - pub total_agents: usize, + /// SHA-256 hash of the signed document content. + #[serde(skip_serializing_if = "Option::is_none")] + pub content_hash: Option, - /// Number of valid signatures collected. - pub signatures_collected: usize, + /// The JACS document ID assigned to the signed document. + #[serde(skip_serializing_if = "Option::is_none")] + pub jacs_document_id: Option, - /// Minimum signatures required (quorum). Equals total_agents if no quorum set. - pub signatures_required: usize, + /// Human-readable status message. + pub message: String, - /// Whether quorum has been met. - pub quorum_met: bool, + /// Error message if signing failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} - /// Whether the agreement has expired (past timeout). - pub expired: bool, +// ============================================================================= +// Message Request/Response Types +// ============================================================================= - /// List of agent IDs that have signed. - #[serde(skip_serializing_if = "Option::is_none")] - pub signed_by: Option>, +/// Parameters for sending a signed message to another agent. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MessageSendParams { + /// The recipient agent's ID (UUID format). + #[schemars(description = "The JACS agent ID of the recipient (UUID format)")] + pub recipient_agent_id: String, - /// List of agent IDs that have NOT signed yet. + /// The message content to send. + #[schemars(description = "The message content to send")] + pub content: String, + + /// The MIME type of the content (default: "text/plain"). + #[schemars(description = "MIME type of the content (default: 'text/plain')")] + pub content_type: Option, +} + +/// Result of sending a signed message. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MessageSendResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The JACS document ID of the signed message. #[serde(skip_serializing_if = "Option::is_none")] - pub unsigned: Option>, + pub jacs_document_id: Option, - /// Timeout deadline (if set). + /// The full signed message JSON. #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, + pub signed_message: Option, + /// Error message if the operation failed. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -/// Format a SystemTime as an ISO 8601 UTC timestamp string. -fn format_iso8601(t: std::time::SystemTime) -> String { - let d = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = d.as_secs(); - // Simple conversion: seconds -> year/month/day/hour/min/sec - // Using a basic algorithm that handles dates from 1970 onwards - let days = secs / 86400; - let time_of_day = secs % 86400; - let hours = time_of_day / 3600; - let minutes = (time_of_day % 3600) / 60; - let seconds = time_of_day % 60; +/// Parameters for updating an existing signed message. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MessageUpdateParams { + /// The JACS document ID of the message to update. + #[schemars(description = "JACS document ID of the message to update")] + pub jacs_id: String, - // Calculate year/month/day from days since epoch - let mut y = 1970i64; - let mut remaining = days as i64; - loop { - let days_in_year = if is_leap(y) { 366 } else { 365 }; - if remaining < days_in_year { - break; - } - remaining -= days_in_year; + /// The new message content. + #[schemars(description = "Updated message content")] + pub content: String, + + /// The MIME type of the content (default: "text/plain"). + #[schemars(description = "MIME type of the content (default: 'text/plain')")] + pub content_type: Option, +} + +/// Result of updating a signed message. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MessageUpdateResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The JACS document ID of the updated message. + #[serde(skip_serializing_if = "Option::is_none")] + pub jacs_document_id: Option, + + /// The full updated signed message JSON. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_message: Option, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for agreeing to (co-signing) a received message. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MessageAgreeParams { + /// The full signed message JSON document to agree to. + #[schemars(description = "The full signed JSON document to agree to")] + pub signed_message: String, +} + +/// Result of agreeing to a message. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MessageAgreeResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The document ID of the original message. + #[serde(skip_serializing_if = "Option::is_none")] + pub original_document_id: Option, + + /// The document ID of the agreement document. + #[serde(skip_serializing_if = "Option::is_none")] + pub agreement_document_id: Option, + + /// The full signed agreement JSON. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_agreement: Option, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for receiving and verifying a signed message. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MessageReceiveParams { + /// The full signed message JSON document received from another agent. + #[schemars(description = "The full signed JSON document received from another agent")] + pub signed_message: String, +} + +/// Result of receiving and verifying a signed message. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MessageReceiveResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The sender's agent ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub sender_agent_id: Option, + + /// The extracted message content. + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + + /// The content MIME type. + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + + /// The message timestamp (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + + /// Whether the cryptographic signature is valid. + pub signature_valid: bool, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +// ============================================================================= +// Agreement Types — Multi-party cryptographic agreements +// ============================================================================= + +/// Parameters for creating a multi-party agreement. +/// +/// An agreement is a document that multiple agents must sign. Use this when agents +/// need to formally commit to a shared decision — for example, approving a deployment, +/// authorizing a data transfer, or reaching consensus on a proposal. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateAgreementParams { + /// The document to create an agreement for, as a JSON string. + /// This is the content all parties will be agreeing to. + #[schemars( + description = "JSON document that all parties will agree to. Can be any valid JSON object." + )] + pub document: String, + + /// List of agent IDs (UUIDs) that must sign this agreement. + /// Include your own agent ID if you want to be a required signer. + #[schemars(description = "List of agent IDs (UUIDs) that are parties to this agreement")] + pub agent_ids: Vec, + + /// A human-readable question summarizing what signers are agreeing to. + #[schemars(description = "Question for signers, e.g. 'Do you approve deploying model v2?'")] + pub question: Option, + + /// Additional context to help signers make their decision. + #[schemars(description = "Additional context for signers")] + pub context: Option, + + /// ISO 8601 deadline. The agreement expires if not fully signed by this time. + /// Example: "2025-12-31T23:59:59Z" + #[schemars( + description = "ISO 8601 deadline after which the agreement expires. Example: '2025-12-31T23:59:59Z'" + )] + pub timeout: Option, + + /// Minimum number of signatures required (M-of-N). If omitted, ALL agents must sign. + /// For example, quorum=2 with 3 agent_ids means any 2 of 3 signers is sufficient. + #[schemars( + description = "Minimum signatures required (M-of-N). If omitted, all agents must sign." + )] + pub quorum: Option, + + /// Only allow agents using these algorithms to sign. + /// Values: "RSA-PSS", "ring-Ed25519", "pq2025" + #[schemars( + description = "Only allow these signing algorithms. Values: 'RSA-PSS', 'ring-Ed25519', 'pq2025'" + )] + pub required_algorithms: Option>, + + /// Minimum cryptographic strength: "classical" (any algorithm) or "post-quantum" (pq2025 only). + #[schemars(description = "Minimum crypto strength: 'classical' or 'post-quantum'")] + pub minimum_strength: Option, +} + +/// Result of creating an agreement. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateAgreementResult { + pub success: bool, + + /// The JACS document ID of the agreement. + #[serde(skip_serializing_if = "Option::is_none")] + pub agreement_id: Option, + + /// The full signed agreement JSON. Pass this to other agents for signing. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_agreement: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for signing an existing agreement. +/// +/// Use this after receiving an agreement document from another agent. +/// Your agent will cryptographically co-sign it, adding your signature +/// to the agreement's signature list. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SignAgreementParams { + /// The full signed agreement JSON document to co-sign. + #[schemars( + description = "The full agreement JSON to sign. Obtained from jacs_create_agreement or from another agent." + )] + pub signed_agreement: String, + + /// Optional custom agreement field name (default: 'jacsAgreement'). + #[schemars(description = "Custom agreement field name (default: 'jacsAgreement')")] + pub agreement_fieldname: Option, +} + +/// Result of signing an agreement. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SignAgreementResult { + pub success: bool, + + /// The updated agreement JSON with your signature added. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_agreement: Option, + + /// Number of signatures now on the agreement. + #[serde(skip_serializing_if = "Option::is_none")] + pub signature_count: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for checking agreement status. +/// +/// Use this to see how many agents have signed, whether quorum is met, +/// and whether the agreement has expired. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CheckAgreementParams { + /// The full signed agreement JSON document to check. + #[schemars(description = "The agreement JSON to check status of")] + pub signed_agreement: String, + + /// Optional custom agreement field name (default: 'jacsAgreement'). + #[schemars(description = "Custom agreement field name (default: 'jacsAgreement')")] + pub agreement_fieldname: Option, +} + +/// Result of checking an agreement's status. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CheckAgreementResult { + pub success: bool, + + /// Whether the agreement is complete (quorum met, not expired, all signatures valid). + pub complete: bool, + + /// Total agents required to sign. + pub total_agents: usize, + + /// Number of valid signatures collected. + pub signatures_collected: usize, + + /// Minimum signatures required (quorum). Equals total_agents if no quorum set. + pub signatures_required: usize, + + /// Whether quorum has been met. + pub quorum_met: bool, + + /// Whether the agreement has expired (past timeout). + pub expired: bool, + + /// List of agent IDs that have signed. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_by: Option>, + + /// List of agent IDs that have NOT signed yet. + #[serde(skip_serializing_if = "Option::is_none")] + pub unsigned: Option>, + + /// Timeout deadline (if set). + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Format a SystemTime as an ISO 8601 UTC timestamp string. +fn format_iso8601(t: std::time::SystemTime) -> String { + let d = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = d.as_secs(); + // Simple conversion: seconds -> year/month/day/hour/min/sec + // Using a basic algorithm that handles dates from 1970 onwards + let days = secs / 86400; + let time_of_day = secs % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let seconds = time_of_day % 60; + + // Calculate year/month/day from days since epoch + let mut y = 1970i64; + let mut remaining = days as i64; + loop { + let days_in_year = if is_leap(y) { 366 } else { 365 }; + if remaining < days_in_year { + break; + } + remaining -= days_in_year; y += 1; } let leap = is_leap(y); @@ -1077,101 +1302,109 @@ fn is_leap(y: i64) -> bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 } +// ============================================================================= +// Attestation Request/Response Types (feature-gated) +// ============================================================================= + +/// Parameters for creating an attestation. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AttestCreateParams { + /// JSON string with subject, claims, and optional evidence/derivation/policyContext. + #[schemars( + description = "JSON string containing attestation parameters: { subject: { type, id, digests }, claims: [{ name, value, confidence?, assuranceLevel? }], evidence?: [...], derivation?: {...}, policyContext?: {...} }" + )] + pub params_json: String, +} + +/// Parameters for verifying an attestation. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AttestVerifyParams { + /// The document key in "jacsId:jacsVersion" format. + #[schemars(description = "Document key in 'jacsId:jacsVersion' format")] + pub document_key: String, + + /// Whether to perform full verification (including evidence and chain). + #[serde(default)] + #[schemars(description = "Set to true for full-tier verification (evidence + chain checks)")] + pub full: bool, +} + +/// Parameters for exporting an attestation as a DSSE envelope. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AttestExportDsseParams { + /// The signed attestation document JSON string. + #[schemars(description = "JSON string of the signed attestation document to export as DSSE")] + pub attestation_json: String, +} + +/// Parameters for lifting a signed document to an attestation. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AttestLiftParams { + /// The signed document JSON string. + #[schemars(description = "JSON string of the existing signed JACS document to lift")] + pub signed_doc_json: String, + + /// Claims JSON string (array of claim objects). + #[schemars( + description = "JSON array of claim objects: [{ name, value, confidence?, assuranceLevel? }]" + )] + pub claims_json: String, +} + // ============================================================================= // MCP Server // ============================================================================= -/// HAI MCP Server providing tools for agent registration, verification, and key management. +/// JACS MCP Server providing tools for data provenance, cryptographic signing, +/// messaging, agreements, A2A interoperability, and trust store management. #[derive(Clone)] #[allow(dead_code)] -pub struct HaiMcpServer { +pub struct JacsMcpServer { /// The local agent identity. agent: Arc, - /// HAI client for API calls. - hai_client: Arc, /// Tool router for MCP tool dispatch. tool_router: ToolRouter, - /// Whether registration is allowed (from JACS_MCP_ALLOW_REGISTRATION env var). + /// Whether agent creation is allowed (from JACS_MCP_ALLOW_REGISTRATION env var). registration_allowed: bool, - /// Whether unregistration is allowed (from JACS_MCP_ALLOW_UNREGISTRATION env var). - unregistration_allowed: bool, + /// Whether untrusting agents is allowed (from JACS_MCP_ALLOW_UNTRUST env var). + untrust_allowed: bool, } #[allow(dead_code)] -impl HaiMcpServer { - /// Create a new HAI MCP server with the given agent and HAI endpoint. +impl JacsMcpServer { + /// Create a new JACS MCP server with the given agent. /// /// # Arguments /// /// * `agent` - The local JACS agent wrapper - /// * `hai_endpoint` - Base URL for the HAI API (e.g., "https://api.hai.ai") - /// * `api_key` - Optional API key for HAI authentication /// /// # Environment Variables /// - /// * `JACS_MCP_ALLOW_REGISTRATION` - Set to "true" to enable the register_agent tool - /// * `JACS_MCP_ALLOW_UNREGISTRATION` - Set to "true" to enable the unregister_agent tool - pub fn new(agent: AgentWrapper, hai_endpoint: &str, api_key: Option<&str>) -> Self { - let mut client = HaiClient::new(hai_endpoint); - if let Some(key) = api_key { - client = client.with_api_key(key); - } - + /// * `JACS_MCP_ALLOW_REGISTRATION` - Set to "true" to enable the jacs_create_agent tool + /// * `JACS_MCP_ALLOW_UNTRUST` - Set to "true" to enable the jacs_untrust_agent tool + pub fn new(agent: AgentWrapper) -> Self { let registration_allowed = is_registration_allowed(); - let unregistration_allowed = is_unregistration_allowed(); + let untrust_allowed = is_untrust_allowed(); if registration_allowed { - tracing::info!("Agent registration is ENABLED (JACS_MCP_ALLOW_REGISTRATION=true)"); + tracing::info!("Agent creation is ENABLED (JACS_MCP_ALLOW_REGISTRATION=true)"); } else { tracing::info!( - "Agent registration is DISABLED. Set JACS_MCP_ALLOW_REGISTRATION=true to enable." + "Agent creation is DISABLED. Set JACS_MCP_ALLOW_REGISTRATION=true to enable." ); } Self { agent: Arc::new(agent), - hai_client: Arc::new(client), tool_router: Self::tool_router(), registration_allowed, - unregistration_allowed, + untrust_allowed, } } /// Get the list of available tools for LLM discovery. pub fn tools() -> Vec { vec![ - Tool::new( - "fetch_agent_key", - "Fetch a public key from HAI's key distribution service. Use this to obtain \ - trusted public keys for verifying agent signatures.", - Self::fetch_agent_key_schema(), - ), - Tool::new( - "register_agent", - "Register the local agent with HAI. This establishes the agent's identity \ - in the HAI network and enables attestation services. \ - SECURITY: Requires JACS_MCP_ALLOW_REGISTRATION=true environment variable. \ - Defaults to preview mode (set preview=false to actually register).", - Self::register_agent_schema(), - ), - Tool::new( - "verify_agent", - "Verify another agent's attestation level with HAI. Returns the trust level \ - (0-3) indicating how well the agent's identity has been verified.", - Self::verify_agent_schema(), - ), - Tool::new( - "check_agent_status", - "Check the registration status of an agent with HAI. Returns whether the \ - agent is registered and relevant registration details.", - Self::check_agent_status_schema(), - ), - Tool::new( - "unregister_agent", - "Unregister the local agent from HAI. This removes the agent's registration \ - and associated attestations. SECURITY: Requires JACS_MCP_ALLOW_UNREGISTRATION=true.", - Self::unregister_agent_schema(), - ), Tool::new( "jacs_sign_state", "Sign an agent state file (memory, skill, plan, config, or hook) to create \ @@ -1292,613 +1525,992 @@ impl HaiMcpServer { need to confirm its integrity and authenticity.", Self::jacs_verify_document_schema(), ), + // --- A2A artifact tools --- + Tool::new( + "jacs_wrap_a2a_artifact", + "Wrap an A2A artifact with JACS provenance. Signs the artifact JSON, binding \ + this agent's identity to the content. Optionally include parent signatures \ + for chain-of-custody provenance.", + Self::jacs_wrap_a2a_artifact_schema(), + ), + Tool::new( + "jacs_verify_a2a_artifact", + "Verify a JACS-wrapped A2A artifact. Checks the cryptographic signature and \ + hash to confirm the artifact was signed by the claimed agent and has not \ + been tampered with.", + Self::jacs_verify_a2a_artifact_schema(), + ), + Tool::new( + "jacs_assess_a2a_agent", + "Assess the trust level of a remote A2A agent given its Agent Card. Applies \ + a trust policy (open, verified, or strict) and returns whether the agent is \ + allowed and at what trust level.", + Self::jacs_assess_a2a_agent_schema(), + ), + // --- Agent Card & well-known tools --- + Tool::new( + "jacs_export_agent_card", + "Export this agent's A2A Agent Card as JSON. The Agent Card describes the \ + agent's capabilities, endpoints, and identity for A2A discovery.", + Self::jacs_export_agent_card_schema(), + ), + Tool::new( + "jacs_generate_well_known", + "Generate all .well-known documents for A2A discovery. Returns an array of \ + {path, document} objects that can be served at each path for agent discovery.", + Self::jacs_generate_well_known_schema(), + ), + Tool::new( + "jacs_export_agent", + "Export the local agent's full JACS JSON document. This includes the agent's \ + identity, public key hash, and signed metadata.", + Self::jacs_export_agent_schema(), + ), + // --- Trust store tools --- + Tool::new( + "jacs_trust_agent", + "Add an agent to the local trust store. The agent's self-signature is \ + cryptographically verified before it is trusted. Pass the full agent JSON \ + document. Returns the trusted agent ID on success.", + Self::jacs_trust_agent_schema(), + ), + Tool::new( + "jacs_untrust_agent", + "Remove an agent from the local trust store. \ + SECURITY: Requires JACS_MCP_ALLOW_UNTRUST=true environment variable to prevent \ + prompt injection attacks from removing trusted agents without user consent.", + Self::jacs_untrust_agent_schema(), + ), + Tool::new( + "jacs_list_trusted_agents", + "List all agent IDs currently in the local trust store. Returns the count \ + and a list of trusted agent IDs.", + Self::jacs_list_trusted_agents_schema(), + ), + Tool::new( + "jacs_is_trusted", + "Check whether a specific agent is in the local trust store. Returns a boolean \ + indicating trust status.", + Self::jacs_is_trusted_schema(), + ), + Tool::new( + "jacs_get_trusted_agent", + "Retrieve the full agent JSON document for a trusted agent from the local \ + trust store. Fails if the agent is not trusted.", + Self::jacs_get_trusted_agent_schema(), + ), + // --- Attestation tools --- + Tool::new( + "jacs_attest_create", + "Create a signed attestation document. Provide a JSON string with: subject \ + (type, id, digests), claims (name, value, confidence, assuranceLevel), and \ + optional evidence, derivation, and policyContext. Requires the attestation \ + feature.", + Self::jacs_attest_create_schema(), + ), + Tool::new( + "jacs_attest_verify", + "Verify an attestation document. Provide a document_key in 'jacsId:jacsVersion' \ + format. Set full=true for full-tier verification including evidence and \ + derivation chain checks. Requires the attestation feature.", + Self::jacs_attest_verify_schema(), + ), + Tool::new( + "jacs_attest_lift", + "Lift an existing signed JACS document into an attestation. Provide the signed \ + document JSON and a JSON array of claims to attach. Requires the attestation \ + feature.", + Self::jacs_attest_lift_schema(), + ), + Tool::new( + "jacs_attest_export_dsse", + "Export an attestation as a DSSE envelope for in-toto/SLSA compatibility. \ + Provide the signed attestation document JSON. Returns a DSSE envelope with \ + payloadType, payload, and signatures. Requires the attestation feature.", + Self::jacs_attest_export_dsse_schema(), + ), ] } - fn fetch_agent_key_schema() -> serde_json::Map { - let schema = schemars::schema_for!(FetchAgentKeyParams); + fn jacs_sign_state_schema() -> serde_json::Map { + let schema = schemars::schema_for!(SignStateParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_verify_state_schema() -> serde_json::Map { + let schema = schemars::schema_for!(VerifyStateParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_load_state_schema() -> serde_json::Map { + let schema = schemars::schema_for!(LoadStateParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_update_state_schema() -> serde_json::Map { + let schema = schemars::schema_for!(UpdateStateParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_list_state_schema() -> serde_json::Map { + let schema = schemars::schema_for!(ListStateParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_adopt_state_schema() -> serde_json::Map { + let schema = schemars::schema_for!(AdoptStateParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_create_agent_schema() -> serde_json::Map { + let schema = schemars::schema_for!(CreateAgentProgrammaticParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_reencrypt_key_schema() -> serde_json::Map { + let schema = schemars::schema_for!(ReencryptKeyParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_audit_schema() -> serde_json::Map { + let schema = schemars::schema_for!(JacsAuditParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn register_agent_schema() -> serde_json::Map { - let schema = schemars::schema_for!(RegisterAgentParams); + fn jacs_message_send_schema() -> serde_json::Map { + let schema = schemars::schema_for!(MessageSendParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn verify_agent_schema() -> serde_json::Map { - let schema = schemars::schema_for!(VerifyAgentParams); + fn jacs_message_update_schema() -> serde_json::Map { + let schema = schemars::schema_for!(MessageUpdateParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn check_agent_status_schema() -> serde_json::Map { - let schema = schemars::schema_for!(CheckAgentStatusParams); + fn jacs_message_agree_schema() -> serde_json::Map { + let schema = schemars::schema_for!(MessageAgreeParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn unregister_agent_schema() -> serde_json::Map { - let schema = schemars::schema_for!(UnregisterAgentParams); + fn jacs_message_receive_schema() -> serde_json::Map { + let schema = schemars::schema_for!(MessageReceiveParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_sign_state_schema() -> serde_json::Map { - let schema = schemars::schema_for!(SignStateParams); + fn jacs_create_agreement_schema() -> serde_json::Map { + let schema = schemars::schema_for!(CreateAgreementParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_verify_state_schema() -> serde_json::Map { - let schema = schemars::schema_for!(VerifyStateParams); + fn jacs_sign_agreement_schema() -> serde_json::Map { + let schema = schemars::schema_for!(SignAgreementParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_load_state_schema() -> serde_json::Map { - let schema = schemars::schema_for!(LoadStateParams); + fn jacs_check_agreement_schema() -> serde_json::Map { + let schema = schemars::schema_for!(CheckAgreementParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_update_state_schema() -> serde_json::Map { - let schema = schemars::schema_for!(UpdateStateParams); + fn jacs_sign_document_schema() -> serde_json::Map { + let schema = schemars::schema_for!(SignDocumentParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_list_state_schema() -> serde_json::Map { - let schema = schemars::schema_for!(ListStateParams); + fn jacs_verify_document_schema() -> serde_json::Map { + let schema = schemars::schema_for!(VerifyDocumentParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_adopt_state_schema() -> serde_json::Map { - let schema = schemars::schema_for!(AdoptStateParams); + fn jacs_wrap_a2a_artifact_schema() -> serde_json::Map { + let schema = schemars::schema_for!(WrapA2aArtifactParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_create_agent_schema() -> serde_json::Map { - let schema = schemars::schema_for!(CreateAgentProgrammaticParams); + fn jacs_verify_a2a_artifact_schema() -> serde_json::Map { + let schema = schemars::schema_for!(VerifyA2aArtifactParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_reencrypt_key_schema() -> serde_json::Map { - let schema = schemars::schema_for!(ReencryptKeyParams); + fn jacs_assess_a2a_agent_schema() -> serde_json::Map { + let schema = schemars::schema_for!(AssessA2aAgentParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_audit_schema() -> serde_json::Map { - let schema = schemars::schema_for!(JacsAuditParams); + fn jacs_export_agent_card_schema() -> serde_json::Map { + let schema = schemars::schema_for!(ExportAgentCardParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_message_send_schema() -> serde_json::Map { - let schema = schemars::schema_for!(MessageSendParams); + fn jacs_generate_well_known_schema() -> serde_json::Map { + let schema = schemars::schema_for!(GenerateWellKnownParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_message_update_schema() -> serde_json::Map { - let schema = schemars::schema_for!(MessageUpdateParams); + fn jacs_export_agent_schema() -> serde_json::Map { + let schema = schemars::schema_for!(ExportAgentParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_message_agree_schema() -> serde_json::Map { - let schema = schemars::schema_for!(MessageAgreeParams); + fn jacs_trust_agent_schema() -> serde_json::Map { + let schema = schemars::schema_for!(TrustAgentParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_message_receive_schema() -> serde_json::Map { - let schema = schemars::schema_for!(MessageReceiveParams); + fn jacs_untrust_agent_schema() -> serde_json::Map { + let schema = schemars::schema_for!(UntrustAgentParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_create_agreement_schema() -> serde_json::Map { - let schema = schemars::schema_for!(CreateAgreementParams); + fn jacs_list_trusted_agents_schema() -> serde_json::Map { + let schema = schemars::schema_for!(ListTrustedAgentsParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_sign_agreement_schema() -> serde_json::Map { - let schema = schemars::schema_for!(SignAgreementParams); + fn jacs_is_trusted_schema() -> serde_json::Map { + let schema = schemars::schema_for!(IsTrustedParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_check_agreement_schema() -> serde_json::Map { - let schema = schemars::schema_for!(CheckAgreementParams); + fn jacs_get_trusted_agent_schema() -> serde_json::Map { + let schema = schemars::schema_for!(GetTrustedAgentParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_sign_document_schema() -> serde_json::Map { - let schema = schemars::schema_for!(SignDocumentParams); + fn jacs_attest_create_schema() -> serde_json::Map { + let schema = schemars::schema_for!(AttestCreateParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } - fn jacs_verify_document_schema() -> serde_json::Map { - let schema = schemars::schema_for!(VerifyDocumentParams); + fn jacs_attest_verify_schema() -> serde_json::Map { + let schema = schemars::schema_for!(AttestVerifyParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_attest_lift_schema() -> serde_json::Map { + let schema = schemars::schema_for!(AttestLiftParams); match serde_json::to_value(schema) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), } } -} -// Implement the tool router for the server -#[tool_router] -impl HaiMcpServer { - /// Fetch a public key from HAI's key distribution service. + fn jacs_attest_export_dsse_schema() -> serde_json::Map { + let schema = schemars::schema_for!(AttestExportDsseParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } +} + +// Implement the tool router for the server +#[tool_router] +impl JacsMcpServer { + /// Sign an agent state file to create a cryptographically signed JACS document. + /// + /// Reads the file, creates an agent state document with metadata, and signs it + /// using the local agent's keys. For hooks, content is always embedded. + #[tool( + name = "jacs_sign_state", + description = "Sign an agent state file (memory/skill/plan/config/hook) to create a signed JACS document." + )] + pub async fn jacs_sign_state(&self, Parameters(params): Parameters) -> String { + // Security: Validate file_path to prevent path traversal attacks via prompt injection. + if let Err(e) = require_relative_path_safe(¶ms.file_path) { + let result = SignStateResult { + success: false, + jacs_document_id: None, + state_type: params.state_type, + name: params.name, + message: "Path validation failed".to_string(), + error: Some(format!("PATH_TRAVERSAL_BLOCKED: {}", e)), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + // Always embed state content for MCP-originated state documents so follow-up + // reads/updates can operate purely on JACS documents without direct file I/O. + let embed = params.embed.unwrap_or(true); + + // Create the agent state document with file reference + let mut doc = match agentstate_crud::create_agentstate_with_file( + ¶ms.state_type, + ¶ms.name, + ¶ms.file_path, + embed, + ) { + Ok(doc) => doc, + Err(e) => { + let result = SignStateResult { + success: false, + jacs_document_id: None, + state_type: params.state_type, + name: params.name, + message: "Failed to create agent state document".to_string(), + error: Some(e), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + // Set optional fields + if let Some(desc) = ¶ms.description { + doc["jacsAgentStateDescription"] = serde_json::json!(desc); + } + + if let Some(framework) = ¶ms.framework { + if let Err(e) = agentstate_crud::set_agentstate_framework(&mut doc, framework) { + let result = SignStateResult { + success: false, + jacs_document_id: None, + state_type: params.state_type, + name: params.name, + message: "Failed to set framework".to_string(), + error: Some(e), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + } + + if let Some(tags) = ¶ms.tags { + let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect(); + if let Err(e) = agentstate_crud::set_agentstate_tags(&mut doc, tag_refs) { + let result = SignStateResult { + success: false, + jacs_document_id: None, + state_type: params.state_type, + name: params.name, + message: "Failed to set tags".to_string(), + error: Some(e), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + } + + // Set origin as "authored" for directly signed state + let _ = agentstate_crud::set_agentstate_origin(&mut doc, "authored", None); + + // Sign and persist through JACS document storage so subsequent MCP calls can + // reference only the JACS document ID (no sidecar/path coupling). + let doc_string = doc.to_string(); + let result = match self.agent.create_document( + &doc_string, + None, // custom_schema + None, // outputfilename + true, // no_save + None, // attachments + Some(embed || params.state_type == "hook"), + ) { + Ok(signed_doc_string) => { + let doc_id = match extract_document_lookup_key_from_str(&signed_doc_string) { + Some(id) => id, + None => { + return serde_json::to_string_pretty(&SignStateResult { + success: false, + jacs_document_id: None, + state_type: params.state_type, + name: params.name, + message: "Failed to determine the signed document ID".to_string(), + error: Some("DOCUMENT_ID_MISSING".to_string()), + }) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + if let Err(e) = + self.agent + .save_signed_document(&signed_doc_string, None, None, None) + { + return serde_json::to_string_pretty(&SignStateResult { + success: false, + jacs_document_id: Some(doc_id), + state_type: params.state_type, + name: params.name, + message: "Failed to persist signed state document".to_string(), + error: Some(e.to_string()), + }) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + SignStateResult { + success: true, + jacs_document_id: Some(doc_id), + state_type: params.state_type, + name: params.name, + message: format!( + "Successfully signed agent state file '{}'", + params.file_path + ), + error: None, + } + } + Err(e) => SignStateResult { + success: false, + jacs_document_id: None, + state_type: params.state_type, + name: params.name, + message: "Failed to sign document".to_string(), + error: Some(e.to_string()), + }, + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Verify the integrity and authenticity of a signed agent state. + /// + /// Checks both the file content hash against the signed hash and verifies + /// the cryptographic signature on the document. + #[tool( + name = "jacs_verify_state", + description = "Verify a signed agent state's file hash and cryptographic signature." + )] + pub async fn jacs_verify_state( + &self, + Parameters(params): Parameters, + ) -> String { + // MCP policy: verification must resolve through JACS documents, not direct file paths. + if params.jacs_id.is_none() && params.file_path.is_none() { + let result = VerifyStateResult { + success: false, + hash_match: false, + signature_valid: false, + signing_info: None, + message: "Missing state reference. Provide jacs_id (uuid:version).".to_string(), + error: Some("MISSING_PARAMETER".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + if params.jacs_id.is_none() { + let result = VerifyStateResult { + success: false, + hash_match: false, + signature_valid: false, + signing_info: None, + message: "file_path-based verification is disabled in MCP. Use jacs_id." + .to_string(), + error: Some("FILESYSTEM_ACCESS_DISABLED".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + let jacs_id = params.jacs_id.as_deref().unwrap_or_default(); + let doc_string = match self.agent.get_document_by_id(jacs_id) { + Ok(s) => s, + Err(e) => { + let result = VerifyStateResult { + success: false, + hash_match: false, + signature_valid: false, + signing_info: None, + message: format!("Failed to load document '{}'", jacs_id), + error: Some(e.to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + match self.agent.verify_document(&doc_string) { + Ok(valid) => { + let signing_info = serde_json::from_str::(&doc_string) + .ok() + .and_then(|doc| doc.get("jacsSignature").cloned()) + .map(|sig| sig.to_string()); + + let result = VerifyStateResult { + success: true, + hash_match: valid, + signature_valid: valid, + signing_info, + message: if valid { + format!("Document '{}' verified successfully", jacs_id) + } else { + format!("Document '{}' signature verification failed", jacs_id) + }, + error: None, + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + Err(e) => { + let result = VerifyStateResult { + success: false, + hash_match: false, + signature_valid: false, + signing_info: None, + message: format!("Failed to verify document '{}': {}", jacs_id, e), + error: Some(e.to_string()), + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + } + } + + /// Load a signed agent state document and optionally verify it. /// - /// This tool retrieves the public key for a specific agent from HAI, - /// allowing verification of that agent's signatures. + /// Returns the content of the state along with verification status. #[tool( - name = "fetch_agent_key", - description = "Fetch a public key from HAI's key distribution service for verifying agent signatures." + name = "jacs_load_state", + description = "Load a signed agent state document, optionally verifying before returning content." )] - pub async fn fetch_agent_key( - &self, - Parameters(params): Parameters, - ) -> String { - // Validate agent_id format - if let Err(e) = validate_agent_id(¶ms.agent_id) { - let result = FetchAgentKeyResult { + pub async fn jacs_load_state(&self, Parameters(params): Parameters) -> String { + if params.file_path.is_some() && params.jacs_id.is_none() { + let result = LoadStateResult { success: false, - agent_id: params.agent_id.clone(), - version: params - .version - .clone() - .unwrap_or_else(|| "latest".to_string()), - algorithm: String::new(), - public_key_hash: String::new(), - public_key_base64: String::new(), - error: Some(e), + content: None, + verified: false, + warnings: None, + message: "file_path-based loading is disabled in MCP. Use jacs_id.".to_string(), + error: Some("FILESYSTEM_ACCESS_DISABLED".to_string()), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); } - let version = params.version.as_deref().unwrap_or("latest"); - - let result = match fetch_remote_key(¶ms.agent_id, version) { - Ok(key_info) => FetchAgentKeyResult { - success: true, - agent_id: key_info.agent_id, - version: key_info.version, - algorithm: key_info.algorithm, - public_key_hash: key_info.public_key_hash, - public_key_base64: base64_encode(&key_info.public_key), - error: None, - }, - Err(e) => FetchAgentKeyResult { + if params.jacs_id.is_none() { + let result = LoadStateResult { success: false, - agent_id: params.agent_id.clone(), - version: version.to_string(), - algorithm: String::new(), - public_key_hash: String::new(), - public_key_base64: String::new(), - error: Some(e.to_string()), - }, + content: None, + verified: false, + warnings: None, + message: "Missing state reference. Provide jacs_id (uuid:version).".to_string(), + error: Some("MISSING_PARAMETER".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + let require_verified = params.require_verified.unwrap_or(true); + let jacs_id = params.jacs_id.as_deref().unwrap_or_default(); + let mut warnings = Vec::new(); + let mut verified = false; + + let doc_string = match self.agent.get_document_by_id(jacs_id) { + Ok(s) => s, + Err(e) => { + let result = LoadStateResult { + success: false, + content: None, + verified, + warnings: if warnings.is_empty() { + None + } else { + Some(warnings) + }, + message: format!("Failed to load state document '{}'", jacs_id), + error: Some(e.to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } }; - serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) - } + if require_verified { + match self.agent.verify_document(&doc_string) { + Ok(true) => { + verified = true; + } + Ok(false) => { + warnings.push("Document signature verification failed.".to_string()); + } + Err(e) => { + warnings.push(format!("Could not verify document signature: {}", e)); + } + } + } - /// Register the local agent with HAI. - /// - /// This establishes the agent's identity in the HAI network and enables - /// attestation services. - /// - /// # Security - /// - /// Registration requires `JACS_MCP_ALLOW_REGISTRATION=true` environment variable. - /// This prevents prompt injection attacks from registering agents without user consent. - /// Registration defaults to preview mode (preview=true) for additional safety. - #[tool( - name = "register_agent", - description = "Register the local JACS agent with HAI to establish identity and enable attestation." - )] - pub async fn register_agent( - &self, - Parameters(params): Parameters, - ) -> String { - // Security check: Registration must be explicitly enabled - if !self.registration_allowed { - let result = RegisterAgentResult { + if require_verified && !verified { + let result = LoadStateResult { success: false, - agent_id: None, - jacs_id: None, - dns_verified: false, - preview_mode: true, - message: "Registration is disabled for security. \ - To enable, set JACS_MCP_ALLOW_REGISTRATION=true environment variable \ - when starting the MCP server." + content: None, + verified: false, + warnings: if warnings.is_empty() { + None + } else { + Some(warnings) + }, + message: "Verification required but the state document could not be verified." .to_string(), - error: Some("REGISTRATION_DISABLED".to_string()), + error: Some("VERIFICATION_FAILED".to_string()), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); } - // Default to preview mode for additional safety - let preview = params.preview.unwrap_or(true); + let doc = match serde_json::from_str::(&doc_string) { + Ok(v) => v, + Err(e) => { + let result = LoadStateResult { + success: false, + content: None, + verified, + warnings: if warnings.is_empty() { + None + } else { + Some(warnings) + }, + message: format!("State document '{}' is not valid JSON", jacs_id), + error: Some(e.to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; - if preview { - let result = RegisterAgentResult { - success: true, - agent_id: None, - jacs_id: None, - dns_verified: false, - preview_mode: true, - message: "Preview mode: Agent would be registered with HAI. \ - Set preview=false to actually register. \ - WARNING: Registration is a significant action that establishes \ - your agent's identity in the HAI network." + let content = extract_embedded_state_content(&doc); + if content.is_none() { + warnings.push( + "State document does not contain embedded content. Re-sign with embed=true." .to_string(), - error: None, - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); + ); } - let result = match self.hai_client.register(&self.agent).await { - Ok(reg) => RegisterAgentResult { - success: true, - agent_id: Some(reg.agent_id), - jacs_id: Some(reg.jacs_id), - dns_verified: reg.dns_verified, - preview_mode: false, - message: format!( - "Successfully registered with HAI. {} signature(s) received.", - reg.signatures.len() - ), - error: None, + let result = LoadStateResult { + success: true, + content, + verified, + warnings: if warnings.is_empty() { + None + } else { + Some(warnings) }, - Err(e) => RegisterAgentResult { - success: false, - agent_id: None, - jacs_id: None, - dns_verified: false, - preview_mode: false, - message: "Registration failed".to_string(), - error: Some(e.to_string()), + message: if require_verified && verified { + format!("Successfully loaded and verified '{}'", jacs_id) + } else { + format!("Loaded '{}' from JACS storage", jacs_id) }, + error: None, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Verify another agent's attestation level with HAI. + /// Update a previously signed agent state file. /// - /// Returns the trust level indicating how well the agent's identity - /// has been verified: - /// - Level 0: No attestation - /// - Level 1: Key registered with HAI - /// - Level 2: DNS verified (key hash matches DNS TXT record) - /// - Level 3: Full HAI signature attestation (HAI has signed the registration) + /// If new_content is provided, writes it to the file first. Then recomputes + /// the SHA-256 hash and creates a new signed version of the document. #[tool( - name = "verify_agent", - description = "Verify another agent's attestation level (0-3) with HAI." + name = "jacs_update_state", + description = "Update a previously signed agent state document by jacs_id with new embedded content and re-sign." )] - pub async fn verify_agent(&self, Parameters(params): Parameters) -> String { - // Validate agent_id format - if let Err(e) = validate_agent_id(¶ms.agent_id) { - let result = VerifyAgentResult { - success: false, - agent_id: params.agent_id.clone(), - attestation_level: 0, - attestation_description: "Level 0: Invalid agent ID format".to_string(), - key_found: false, - error: Some(e), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - - let version = params.version.as_deref().unwrap_or("latest"); - - // First, try to fetch the key to determine attestation level - let key_result = fetch_remote_key(¶ms.agent_id, version); - - let (attestation_level, attestation_description, key_found) = match &key_result { - Ok(_key_info) => { - // Key found - at minimum Level 1 - // Now check for higher levels + pub async fn jacs_update_state( + &self, + Parameters(params): Parameters, + ) -> String { + let jacs_id = match params.jacs_id.as_deref() { + Some(id) => id, + None => { + let result = UpdateStateResult { + success: false, + jacs_document_version_id: None, + new_hash: None, + message: "file_path-based updates are disabled in MCP. Provide jacs_id." + .to_string(), + error: Some("FILESYSTEM_ACCESS_DISABLED".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; - // Check for Level 3: HAI signature attestation - // Query the status endpoint to see if HAI has signed the registration - match self.hai_client.status(&self.agent).await { - Ok(status) if !status.hai_signatures.is_empty() => ( - 3u8, - format!( - "Level 3: Full HAI attestation ({} signature(s))", - status.hai_signatures.len() - ), - true, - ), - Ok(status) if status.registered => { - // Registered but no HAI signatures yet - // Check for Level 2: DNS verification - // For now, we report Level 1 if we can't verify DNS - // DNS verification would require fetching the agent document - // and checking if dns_verified is true - ( - 1u8, - "Level 1: Public key registered with HAI key service".to_string(), - true, - ) - } - _ => { - // Status check failed or not registered - // Fall back to Level 1 since we have the key - ( - 1u8, - "Level 1: Public key registered with HAI key service".to_string(), - true, - ) - } - } + let existing_doc_string = match self.agent.get_document_by_id(jacs_id) { + Ok(s) => s, + Err(e) => { + let result = UpdateStateResult { + success: false, + jacs_document_version_id: None, + new_hash: None, + message: format!("Failed to load state document '{}'", jacs_id), + error: Some(e.to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } + }; + + let mut doc = match serde_json::from_str::(&existing_doc_string) { + Ok(v) => v, Err(e) => { - let error_str = e.to_string(); - if error_str.contains("not found") || error_str.contains("404") { - ( - 0u8, - "Level 0: Agent not found in HAI key service".to_string(), - false, - ) - } else { - // Network or other error - can't determine level - ( - 0u8, - format!("Level 0: Unable to verify ({})", error_str), - false, - ) - } + let result = UpdateStateResult { + success: false, + jacs_document_version_id: None, + new_hash: None, + message: format!("State document '{}' is not valid JSON", jacs_id), + error: Some(e.to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } }; - let result = VerifyAgentResult { - success: key_found || key_result.is_ok(), - agent_id: params.agent_id, - attestation_level, - attestation_description, - key_found, - error: key_result.err().map(|e| e.to_string()), + let new_hash = params + .new_content + .as_deref() + .map(|content| update_embedded_state_content(&mut doc, content)); + + let updated_doc_string = + match self + .agent + .update_document(jacs_id, &doc.to_string(), None, None) + { + Ok(doc) => doc, + Err(e) => { + let result = UpdateStateResult { + success: false, + jacs_document_version_id: None, + new_hash, + message: format!("Failed to update and re-sign '{}'", jacs_id), + error: Some(e.to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + let version_id = serde_json::from_str::(&updated_doc_string) + .ok() + .and_then(|v| extract_document_lookup_key(&v)) + .unwrap_or_else(|| "unknown".to_string()); + + let result = UpdateStateResult { + success: true, + jacs_document_version_id: Some(version_id), + new_hash, + message: format!("Successfully updated and re-signed '{}'", jacs_id), + error: None, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Check the registration status of an agent with HAI. + /// List signed agent state documents. #[tool( - name = "check_agent_status", - description = "Check if an agent is registered with HAI and get registration details." + name = "jacs_list_state", + description = "List signed agent state documents, with optional filtering." )] - pub async fn check_agent_status( - &self, - Parameters(params): Parameters, - ) -> String { - // If no agent_id provided, check the local agent - let check_local = params.agent_id.is_none(); - - let result = if check_local { - // Check status of the local agent - match self.hai_client.status(&self.agent).await { - Ok(status) => CheckAgentStatusResult { - success: true, - agent_id: status.agent_id, - registered: status.registered, - registration_id: if status.registration_id.is_empty() { - None - } else { - Some(status.registration_id) - }, - registered_at: if status.registered_at.is_empty() { - None - } else { - Some(status.registered_at) - }, - signature_count: status.hai_signatures.len(), - error: None, - }, - Err(e) => CheckAgentStatusResult { + pub async fn jacs_list_state(&self, Parameters(params): Parameters) -> String { + let keys = match self.agent.list_document_keys() { + Ok(keys) => keys, + Err(e) => { + let result = ListStateResult { success: false, - agent_id: "local".to_string(), - registered: false, - registration_id: None, - registered_at: None, - signature_count: 0, + documents: Vec::new(), + message: "Failed to enumerate stored documents".to_string(), error: Some(e.to_string()), - }, + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } - } else { - // For a remote agent, we can only check if their key exists - let agent_id = params.agent_id.unwrap(); + }; - // Validate agent_id format - if let Err(e) = validate_agent_id(&agent_id) { - return serde_json::to_string_pretty(&CheckAgentStatusResult { - success: false, - agent_id, - registered: false, - registration_id: None, - registered_at: None, - signature_count: 0, - error: Some(e), - }) - .unwrap_or_else(|e| format!("Error: {}", e)); + let mut matched = Vec::new(); + + for key in keys { + let doc_string = match self.agent.get_document_by_id(&key) { + Ok(doc) => doc, + Err(_) => continue, + }; + let doc = match serde_json::from_str::(&doc_string) { + Ok(doc) => doc, + Err(_) => continue, + }; + + if doc.get("jacsType").and_then(|v| v.as_str()) != Some("agentstate") { + continue; } - match fetch_remote_key(&agent_id, "latest") { - Ok(_) => CheckAgentStatusResult { - success: true, - agent_id: agent_id.clone(), - registered: true, - registration_id: None, // Not available for remote agents - registered_at: None, - signature_count: 0, - error: None, - }, - Err(e) => { - let error_str = e.to_string(); - let registered = !error_str.contains("not found") && !error_str.contains("404"); - CheckAgentStatusResult { - success: !registered, // Success if we got a clear "not found" - agent_id, - registered, - registration_id: None, - registered_at: None, - signature_count: 0, - error: if registered { Some(error_str) } else { None }, - } + let state_type = value_string(&doc, "jacsAgentStateType").unwrap_or_default(); + if let Some(filter) = params.state_type.as_deref() + && state_type != filter + { + continue; + } + + let framework = value_string(&doc, "jacsAgentStateFramework"); + if let Some(filter) = params.framework.as_deref() + && framework.as_deref() != Some(filter) + { + continue; + } + + let tags = value_string_vec(&doc, "jacsAgentStateTags"); + if let Some(filter_tags) = params.tags.as_ref() { + let doc_tags = tags.clone().unwrap_or_default(); + if !filter_tags + .iter() + .all(|tag| doc_tags.iter().any(|item| item == tag)) + { + continue; } } + + let name = value_string(&doc, "jacsAgentStateName").unwrap_or_else(|| key.clone()); + let version_date = value_string(&doc, "jacsVersionDate").unwrap_or_default(); + + matched.push(( + version_date, + key.clone(), + StateListEntry { + jacs_document_id: key, + state_type, + name, + framework, + tags: tags.filter(|items| !items.is_empty()), + }, + )); + } + + matched.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1))); + let result = ListStateResult { + success: true, + documents: matched.into_iter().map(|(_, _, entry)| entry).collect(), + message: match params.state_type.as_deref() { + Some(filter) => format!("Listed agent state documents (state_type='{}').", filter), + None => "Listed agent state documents.".to_string(), + }, + error: None, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Unregister the local agent from HAI. - /// - /// This removes the agent's registration and associated attestations. - /// - /// # Security + /// Adopt an external file as signed agent state. /// - /// Unregistration requires `JACS_MCP_ALLOW_UNREGISTRATION=true` environment variable. + /// Like sign_state but sets the origin to "adopted" and optionally records + /// the source URL where the content was obtained. #[tool( - name = "unregister_agent", - description = "Unregister the local JACS agent from HAI." + name = "jacs_adopt_state", + description = "Adopt an external file as signed agent state, marking it with 'adopted' origin." )] - pub async fn unregister_agent( + pub async fn jacs_adopt_state( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - // Security check: Unregistration must be explicitly enabled - if !self.unregistration_allowed { - let result = UnregisterAgentResult { + // Security: Validate file_path to prevent path traversal attacks via prompt injection. + if let Err(e) = require_relative_path_safe(¶ms.file_path) { + let result = AdoptStateResult { success: false, - agent_id: None, - preview_mode: true, - message: "Unregistration is disabled for security. \ - To enable, set JACS_MCP_ALLOW_UNREGISTRATION=true environment variable \ - when starting the MCP server." - .to_string(), - error: Some("UNREGISTRATION_DISABLED".to_string()), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - - // Default to preview mode for safety - let preview = params.preview.unwrap_or(true); - - if preview { - let result = UnregisterAgentResult { - success: true, - agent_id: None, - preview_mode: true, - message: "Preview mode: Agent would be unregistered from HAI. \ - Set preview=false to actually unregister. \ - WARNING: Unregistration removes your agent's identity from the HAI network." - .to_string(), - error: None, + jacs_document_id: None, + state_type: params.state_type, + name: params.name, + message: "Path validation failed".to_string(), + error: Some(format!("PATH_TRAVERSAL_BLOCKED: {}", e)), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); } - // Note: HaiClient doesn't currently have an unregister method - // This is a placeholder for when that functionality is added - let result = UnregisterAgentResult { - success: false, - agent_id: None, - preview_mode: false, - message: "Unregistration is not yet implemented in the HAI API. \ - Please contact HAI support to unregister your agent." - .to_string(), - error: Some("NOT_IMPLEMENTED".to_string()), - }; - - serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) - } - - /// Sign an agent state file to create a cryptographically signed JACS document. - /// - /// Reads the file, creates an agent state document with metadata, and signs it - /// using the local agent's keys. For hooks, content is always embedded. - #[tool( - name = "jacs_sign_state", - description = "Sign an agent state file (memory/skill/plan/config/hook) to create a signed JACS document." - )] - pub async fn jacs_sign_state(&self, Parameters(params): Parameters) -> String { - let embed = params.embed.unwrap_or(false); - // Create the agent state document with file reference let mut doc = match agentstate_crud::create_agentstate_with_file( ¶ms.state_type, ¶ms.name, ¶ms.file_path, - embed, + true, // embed for MCP document-centric reads/updates ) { Ok(doc) => doc, Err(e) => { - let result = SignStateResult { + let result = AdoptStateResult { success: false, jacs_document_id: None, state_type: params.state_type, @@ -1911,46 +2523,30 @@ impl HaiMcpServer { } }; - // Set optional fields + // Set description if provided if let Some(desc) = ¶ms.description { doc["jacsAgentStateDescription"] = serde_json::json!(desc); } - if let Some(framework) = ¶ms.framework { - if let Err(e) = agentstate_crud::set_agentstate_framework(&mut doc, framework) { - let result = SignStateResult { - success: false, - jacs_document_id: None, - state_type: params.state_type, - name: params.name, - message: "Failed to set framework".to_string(), - error: Some(e), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - } - - if let Some(tags) = ¶ms.tags { - let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect(); - if let Err(e) = agentstate_crud::set_agentstate_tags(&mut doc, tag_refs) { - let result = SignStateResult { - success: false, - jacs_document_id: None, - state_type: params.state_type, - name: params.name, - message: "Failed to set tags".to_string(), - error: Some(e), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } + // Set origin as "adopted" with optional source URL + if let Err(e) = agentstate_crud::set_agentstate_origin( + &mut doc, + "adopted", + params.source_url.as_deref(), + ) { + let result = AdoptStateResult { + success: false, + jacs_document_id: None, + state_type: params.state_type, + name: params.name, + message: "Failed to set adopted origin".to_string(), + error: Some(e), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } - // Set origin as "authored" for directly signed state - let _ = agentstate_crud::set_agentstate_origin(&mut doc, "authored", None); - - // Sign the document via create_document (no_save=true to avoid filesystem writes) + // Sign the document let doc_string = doc.to_string(); let result = match self.agent.create_document( &doc_string, @@ -1958,33 +2554,62 @@ impl HaiMcpServer { None, // outputfilename true, // no_save None, // attachments - Some(embed || params.state_type == "hook"), + Some(true), ) { Ok(signed_doc_string) => { - // Extract the JACS document ID from the signed document - let doc_id = serde_json::from_str::(&signed_doc_string) - .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) - .unwrap_or_else(|| "unknown".to_string()); + let doc_id = match extract_document_lookup_key_from_str(&signed_doc_string) { + Some(id) => id, + None => { + return serde_json::to_string_pretty(&AdoptStateResult { + success: false, + jacs_document_id: None, + state_type: params.state_type, + name: params.name, + message: "Failed to determine the adopted document ID".to_string(), + error: Some("DOCUMENT_ID_MISSING".to_string()), + }) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; - SignStateResult { + if let Err(e) = + self.agent + .save_signed_document(&signed_doc_string, None, None, None) + { + return serde_json::to_string_pretty(&AdoptStateResult { + success: false, + jacs_document_id: Some(doc_id), + state_type: params.state_type, + name: params.name, + message: "Failed to persist adopted state document".to_string(), + error: Some(e.to_string()), + }) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + AdoptStateResult { success: true, jacs_document_id: Some(doc_id), state_type: params.state_type, name: params.name, message: format!( - "Successfully signed agent state file '{}'", - params.file_path + "Successfully adopted and signed state file '{}' (origin: adopted{})", + params.file_path, + params + .source_url + .as_ref() + .map(|u| format!(", source: {}", u)) + .unwrap_or_default() ), error: None, } } - Err(e) => SignStateResult { + Err(e) => AdoptStateResult { success: false, jacs_document_id: None, state_type: params.state_type, name: params.name, - message: "Failed to sign document".to_string(), + message: "Failed to sign adopted document".to_string(), error: Some(e.to_string()), }, }; @@ -1992,623 +2617,432 @@ impl HaiMcpServer { serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Verify the integrity and authenticity of a signed agent state. + /// Create a new JACS agent programmatically. /// - /// Checks both the file content hash against the signed hash and verifies - /// the cryptographic signature on the document. + /// This is the programmatic equivalent of `jacs create`. It generates + /// a new agent with cryptographic keys and returns the agent info. + /// Requires JACS_MCP_ALLOW_REGISTRATION=true for security. #[tool( - name = "jacs_verify_state", - description = "Verify a signed agent state's file hash and cryptographic signature." + name = "jacs_create_agent", + description = "Create a new JACS agent with cryptographic keys (programmatic)." )] - pub async fn jacs_verify_state( + pub async fn jacs_create_agent( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - // At least one of file_path or jacs_id must be provided - if params.file_path.is_none() && params.jacs_id.is_none() { - let result = VerifyStateResult { + // Require explicit opt-in for agent creation (same gate as registration) + if !self.registration_allowed { + let result = CreateAgentProgrammaticResult { success: false, - hash_match: false, - signature_valid: false, - signing_info: None, - message: "At least one of file_path or jacs_id must be provided".to_string(), - error: Some("MISSING_PARAMETER".to_string()), + agent_id: None, + name: params.name, + message: "Agent creation is disabled. Set JACS_MCP_ALLOW_REGISTRATION=true \ + environment variable to enable." + .to_string(), + error: Some("REGISTRATION_NOT_ALLOWED".to_string()), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); } - // If jacs_id is provided, verify the document by ID from storage - if let Some(jacs_id) = ¶ms.jacs_id { - match self.agent.verify_document_by_id(jacs_id) { - Ok(valid) => { - let result = VerifyStateResult { - success: true, - hash_match: valid, - signature_valid: valid, - signing_info: None, - message: if valid { - format!("Document '{}' verified successfully", jacs_id) - } else { - format!("Document '{}' signature verification failed", jacs_id) - }, - error: None, - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - Err(e) => { - let result = VerifyStateResult { - success: false, - hash_match: false, - signature_valid: false, - signing_info: None, - message: format!("Failed to verify document '{}': {}", jacs_id, e), - error: Some(e.to_string()), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - } - } - - // file_path-based verification: read the file and check if a signed - // document exists for it by looking at the stored state documents. - // Since document index is not yet available, we create a minimal - // verification based on file hash. - let file_path = params.file_path.as_deref().unwrap(); - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(e) => { - let result = VerifyStateResult { - success: false, - hash_match: false, - signature_valid: false, - signing_info: None, - message: format!("Failed to read file '{}'", file_path), - error: Some(e.to_string()), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - }; - - // Compute current file hash - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - let current_hash = format!("{:x}", hasher.finalize()); - - // Check for a .jacs.json sidecar file that might hold the signed document - let sidecar_path = format!("{}.jacs.json", file_path); - if let Ok(sidecar_content) = std::fs::read_to_string(&sidecar_path) { - // Parse the sidecar document - if let Ok(doc) = serde_json::from_str::(&sidecar_content) { - // Verify file hash using agentstate_crud - let hash_match = match agentstate_crud::verify_agentstate_file_hash(&doc) { - Ok(matches) => matches, - Err(_) => false, - }; - - // Verify document signature - let signature_valid = match self.agent.verify_document(&sidecar_content) { - Ok(valid) => valid, - Err(_) => false, - }; - - let signing_info = doc.get("jacsSignature").map(|s| s.to_string()); + let result = match jacs_binding_core::create_agent_programmatic( + ¶ms.name, + ¶ms.password, + params.algorithm.as_deref(), + params.data_directory.as_deref(), + params.key_directory.as_deref(), + None, // config_path + params.agent_type.as_deref(), + params.description.as_deref(), + None, // domain + None, // default_storage + ) { + Ok(info_json) => { + // Parse the info JSON to extract agent_id + let agent_id = serde_json::from_str::(&info_json) + .ok() + .and_then(|v| v.get("agent_id").and_then(|a| a.as_str()).map(String::from)); - let result = VerifyStateResult { + CreateAgentProgrammaticResult { success: true, - hash_match, - signature_valid, - signing_info, - message: format!( - "Verification complete: hash_match={}, signature_valid={}", - hash_match, signature_valid - ), + agent_id, + name: params.name, + message: "Agent created successfully".to_string(), error: None, - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); + } } - } + Err(e) => CreateAgentProgrammaticResult { + success: false, + agent_id: None, + name: params.name, + message: "Failed to create agent".to_string(), + error: Some(e.to_string()), + }, + }; - // No sidecar found - report what we can - let result = VerifyStateResult { - success: true, - hash_match: false, - signature_valid: false, - signing_info: None, - message: format!( - "No signed document found for '{}'. Current file SHA-256: {}. \ - Use jacs_sign_state to create a signed document first.", - file_path, current_hash - ), - error: None, + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Re-encrypt the agent's private key with a new password. + /// + /// Decrypts the private key with the old password and re-encrypts it + /// with the new password. The key itself does not change. + #[tool( + name = "jacs_reencrypt_key", + description = "Re-encrypt the agent's private key with a new password." + )] + pub async fn jacs_reencrypt_key( + &self, + Parameters(params): Parameters, + ) -> String { + let result = match self + .agent + .reencrypt_key(¶ms.old_password, ¶ms.new_password) + { + Ok(()) => ReencryptKeyResult { + success: true, + message: "Private key re-encrypted successfully with new password".to_string(), + error: None, + }, + Err(e) => ReencryptKeyResult { + success: false, + message: "Failed to re-encrypt private key".to_string(), + error: Some(e.to_string()), + }, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Load a signed agent state document and optionally verify it. - /// - /// Returns the content of the state along with verification status. + /// Run a read-only JACS security audit. Returns JSON with risks, health_checks, summary. #[tool( - name = "jacs_load_state", - description = "Load a signed agent state document, optionally verifying before returning content." + name = "jacs_audit", + description = "Run a read-only JACS security audit and health checks." )] - pub async fn jacs_load_state(&self, Parameters(params): Parameters) -> String { - // At least one of file_path or jacs_id must be provided - if params.file_path.is_none() && params.jacs_id.is_none() { - let result = LoadStateResult { - success: false, - content: None, - verified: false, - warnings: None, - message: "At least one of file_path or jacs_id must be provided".to_string(), - error: Some("MISSING_PARAMETER".to_string()), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); + pub async fn jacs_audit(&self, Parameters(params): Parameters) -> String { + match jacs_binding_core::audit(params.config_path.as_deref(), params.recent_n) { + Ok(json) => json, + Err(e) => serde_json::json!({ + "error": true, + "message": e.to_string() + }) + .to_string(), } + } - let require_verified = params.require_verified.unwrap_or(true); - - // Loading by jacs_id is not yet implemented (requires document index) - if params.file_path.is_none() { - let result = LoadStateResult { + /// Create and sign a message document for sending to another agent. + /// + /// Builds a JSON message envelope with sender/recipient IDs, content, timestamp, + /// and a unique message ID, then signs it using the local agent's keys. + #[tool( + name = "jacs_message_send", + description = "Create and sign a message for sending to another agent." + )] + pub async fn jacs_message_send( + &self, + Parameters(params): Parameters, + ) -> String { + // Validate recipient agent ID + if let Err(e) = validate_agent_id(¶ms.recipient_agent_id) { + let result = MessageSendResult { success: false, - content: None, - verified: false, - warnings: None, - message: "Loading by JACS ID alone is not yet implemented. \ - Please provide a file_path." - .to_string(), - error: Some("NOT_YET_IMPLEMENTED".to_string()), + jacs_document_id: None, + signed_message: None, + error: Some(e), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); } - let file_path = params.file_path.as_deref().unwrap(); - - // Read the file content - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, + let sender_id = match self.agent.get_agent_id() { + Ok(agent_id) => agent_id, Err(e) => { - let result = LoadStateResult { + let result = MessageSendResult { success: false, - content: None, - verified: false, - warnings: None, - message: format!("Failed to read file '{}'", file_path), - error: Some(e.to_string()), + jacs_document_id: None, + signed_message: None, + error: Some(format!("Failed to determine sender agent ID: {}", e)), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); } }; - let mut warnings = Vec::new(); - let mut verified = false; + let content_type = params + .content_type + .unwrap_or_else(|| "text/plain".to_string()); + let message_id = Uuid::new_v4().to_string(); + let timestamp = format_iso8601(std::time::SystemTime::now()); - // Check for sidecar signed document - let sidecar_path = format!("{}.jacs.json", file_path); - if let Ok(sidecar_content) = std::fs::read_to_string(&sidecar_path) { - if let Ok(doc) = serde_json::from_str::(&sidecar_content) { - // Verify hash - match agentstate_crud::verify_agentstate_file_hash(&doc) { - Ok(true) => { - verified = true; - } - Ok(false) => { - warnings.push( - "File content hash does not match signed hash. \ - File may have been modified since signing." - .to_string(), - ); - } - Err(e) => { - warnings.push(format!("Could not verify file hash: {}", e)); + // Build the message document + let message_doc = serde_json::json!({ + "jacsType": "message", + "jacsLevel": "artifact", + "jacsMessageId": message_id, + "jacsMessageSenderId": sender_id, + "jacsMessageRecipientId": params.recipient_agent_id, + "jacsMessageContent": params.content, + "jacsMessageContentType": content_type, + "jacsMessageTimestamp": timestamp, + }); + + let doc_string = message_doc.to_string(); + + // Sign the document + let result = match self.agent.create_document( + &doc_string, + None, // custom_schema + None, // outputfilename + true, // no_save + None, // attachments + None, // embed + ) { + Ok(signed_doc_string) => { + let doc_id = match extract_document_lookup_key_from_str(&signed_doc_string) { + Some(id) => id, + None => { + return serde_json::to_string_pretty(&MessageSendResult { + success: false, + jacs_document_id: None, + signed_message: Some(signed_doc_string), + error: Some("Failed to determine the signed message ID".to_string()), + }) + .unwrap_or_else(|e| format!("Error: {}", e)); } + }; + + if let Err(e) = + self.agent + .save_signed_document(&signed_doc_string, None, None, None) + { + return serde_json::to_string_pretty(&MessageSendResult { + success: false, + jacs_document_id: Some(doc_id), + signed_message: Some(signed_doc_string), + error: Some(format!("Failed to persist signed message: {}", e)), + }) + .unwrap_or_else(|e| format!("Error: {}", e)); } - // Verify signature - match self.agent.verify_document(&sidecar_content) { - Ok(true) => {} - Ok(false) => { - verified = false; - warnings.push("Document signature verification failed.".to_string()); - } - Err(e) => { - verified = false; - warnings.push(format!("Could not verify document signature: {}", e)); - } + MessageSendResult { + success: true, + jacs_document_id: Some(doc_id), + signed_message: Some(signed_doc_string), + error: None, } } - } else { - warnings.push(format!( - "No signed document found at '{}'. Content is unverified.", - sidecar_path - )); - } - - if require_verified && !verified { - let result = LoadStateResult { + Err(e) => MessageSendResult { success: false, - content: None, - verified: false, - warnings: if warnings.is_empty() { - None - } else { - Some(warnings) - }, - message: "Verification required but content could not be verified.".to_string(), - error: Some("VERIFICATION_FAILED".to_string()), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - - let result = LoadStateResult { - success: true, - content: Some(content), - verified, - warnings: if warnings.is_empty() { - None - } else { - Some(warnings) - }, - message: if verified { - format!("Successfully loaded and verified '{}'", file_path) - } else { - format!("Loaded '{}' without full verification", file_path) + jacs_document_id: None, + signed_message: None, + error: Some(e.to_string()), }, - error: None, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Update a previously signed agent state file. + /// Update and re-sign an existing message document with new content. /// - /// If new_content is provided, writes it to the file first. Then recomputes - /// the SHA-256 hash and creates a new signed version of the document. + /// Loads the message by its JACS document ID, replaces the content fields, + /// and creates a new signed version. #[tool( - name = "jacs_update_state", - description = "Update a previously signed agent state file with new content and re-sign." + name = "jacs_message_update", + description = "Update and re-sign an existing message document with new content." )] - pub async fn jacs_update_state( + pub async fn jacs_message_update( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - // If new content is provided, write it to the file - if let Some(new_content) = ¶ms.new_content { - if let Err(e) = std::fs::write(¶ms.file_path, new_content) { - let result = UpdateStateResult { + match self.agent.verify_document_by_id(¶ms.jacs_id) { + Ok(true) => {} + Ok(false) => { + let result = MessageUpdateResult { success: false, - jacs_document_version_id: None, - new_hash: None, - message: format!("Failed to write new content to '{}'", params.file_path), - error: Some(e.to_string()), + jacs_document_id: None, + signed_message: None, + error: Some(format!( + "Existing document '{}' failed signature verification", + params.jacs_id + )), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); } - } - - // Read the (possibly updated) file content - let content = match std::fs::read_to_string(¶ms.file_path) { - Ok(c) => c, Err(e) => { - let result = UpdateStateResult { + let result = MessageUpdateResult { success: false, - jacs_document_version_id: None, - new_hash: None, - message: format!("Failed to read file '{}'", params.file_path), - error: Some(e.to_string()), + jacs_document_id: None, + signed_message: None, + error: Some(format!( + "Failed to load document '{}': {}", + params.jacs_id, e + )), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); } }; - // Compute new SHA-256 hash - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - let new_hash = format!("{:x}", hasher.finalize()); - - // Check for existing sidecar document to get metadata for the update - let sidecar_path = format!("{}.jacs.json", params.file_path); - let existing_doc = std::fs::read_to_string(&sidecar_path) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()); - - // Extract metadata before potentially consuming the document - let state_type = existing_doc - .as_ref() - .and_then(|d| { - d.get("jacsAgentStateType") - .and_then(|t| t.as_str()) - .map(String::from) - }) - .unwrap_or_else(|| "config".to_string()); - - let state_name = existing_doc - .as_ref() - .and_then(|d| { - d.get("jacsAgentStateName") - .and_then(|n| n.as_str()) - .map(String::from) - }) - .unwrap_or_else(|| { - std::path::Path::new(¶ms.file_path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed") - .to_string() - }); - - if let Some(mut doc) = existing_doc { - // Update the file hash in the document - if let Some(files) = doc.get_mut("jacsFiles").and_then(|f| f.as_array_mut()) { - for file_entry in files.iter_mut() { - if let Some(obj) = file_entry.as_object_mut() { - obj.insert( - "sha256".to_string(), - serde_json::Value::String(new_hash.clone()), - ); - // Update embedded content if it was embedded - if obj.get("embed").and_then(|e| e.as_bool()).unwrap_or(false) { - obj.insert( - "contents".to_string(), - serde_json::Value::String(content.clone()), - ); - } - } - } + let existing_doc_string = match self.agent.get_document_by_id(¶ms.jacs_id) { + Ok(s) => s, + Err(e) => { + let result = MessageUpdateResult { + success: false, + jacs_document_id: None, + signed_message: None, + error: Some(format!( + "Failed to load document '{}' for update: {}", + params.jacs_id, e + )), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } + }; - // If content was embedded at the document level, update it - if doc.get("jacsAgentStateContent").is_some() { - doc["jacsAgentStateContent"] = serde_json::json!(content); + let mut updated_doc = match serde_json::from_str::(&existing_doc_string) + { + Ok(doc) => doc, + Err(e) => { + let result = MessageUpdateResult { + success: false, + jacs_document_id: None, + signed_message: None, + error: Some(format!( + "Stored document '{}' is not valid JSON: {}", + params.jacs_id, e + )), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } + }; - // Extract the document key for update - let doc_key = doc - .get("id") - .and_then(|id| id.as_str()) - .map(String::from) - .unwrap_or_default(); - - // Try to update the existing document - let doc_string = doc.to_string(); - match self - .agent - .update_document(&doc_key, &doc_string, None, None) - { - Ok(updated_doc_string) => { - let version_id = serde_json::from_str::(&updated_doc_string) - .ok() - .and_then(|v| { - v.get("jacsVersion") - .and_then(|ver| ver.as_str()) - .map(String::from) - .or_else(|| { - v.get("id").and_then(|id| id.as_str()).map(String::from) - }) - }) - .unwrap_or_else(|| "unknown".to_string()); + let content_type = params + .content_type + .unwrap_or_else(|| "text/plain".to_string()); + let timestamp = format_iso8601(std::time::SystemTime::now()); - let result = UpdateStateResult { - success: true, - jacs_document_version_id: Some(version_id), - new_hash: Some(new_hash), - message: format!( - "Successfully updated and re-signed '{}'", - params.file_path - ), - error: None, - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - Err(e) => { - // Fall through to create a new document if update fails - tracing::warn!( - "Failed to update existing document ({}), creating new signed version", - e - ); - } - } - } + updated_doc["jacsType"] = serde_json::json!("message"); + updated_doc["jacsLevel"] = serde_json::json!("artifact"); + updated_doc["jacsMessageContent"] = serde_json::json!(params.content); + updated_doc["jacsMessageContentType"] = serde_json::json!(content_type); + updated_doc["jacsMessageTimestamp"] = serde_json::json!(timestamp); - // No existing sidecar or update failed - create a fresh signed document + let doc_string = updated_doc.to_string(); + let result = match self + .agent + .update_document(¶ms.jacs_id, &doc_string, None, None) + { + Ok(updated_doc_string) => { + let doc_id = extract_document_lookup_key_from_str(&updated_doc_string) + .unwrap_or_else(|| params.jacs_id.clone()); - // Create fresh document - match agentstate_crud::create_agentstate_with_file( - &state_type, - &state_name, - ¶ms.file_path, - false, - ) { - Ok(doc) => { - let doc_string = doc.to_string(); - match self - .agent - .create_document(&doc_string, None, None, true, None, Some(false)) - { - Ok(signed_doc_string) => { - let version_id = - serde_json::from_str::(&signed_doc_string) - .ok() - .and_then(|v| { - v.get("id").and_then(|id| id.as_str()).map(String::from) - }) - .unwrap_or_else(|| "unknown".to_string()); - - let result = UpdateStateResult { - success: true, - jacs_document_version_id: Some(version_id), - new_hash: Some(new_hash), - message: format!( - "Created new signed version for '{}'", - params.file_path - ), - error: None, - }; - serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)) - } - Err(e) => { - let result = UpdateStateResult { - success: false, - jacs_document_version_id: None, - new_hash: Some(new_hash), - message: "Failed to create new signed document".to_string(), - error: Some(e.to_string()), - }; - serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)) - } + MessageUpdateResult { + success: true, + jacs_document_id: Some(doc_id), + signed_message: Some(updated_doc_string), + error: None, } } - Err(e) => { - let result = UpdateStateResult { - success: false, - jacs_document_version_id: None, - new_hash: Some(new_hash), - message: "Failed to create agent state document for re-signing".to_string(), - error: Some(e), - }; - serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) - } - } - } - - /// List signed agent state documents. - /// - /// Currently returns a placeholder since document indexing is not yet - /// implemented. Will be fully functional once document storage lookup is added. - #[tool( - name = "jacs_list_state", - description = "List signed agent state documents, with optional filtering." - )] - pub async fn jacs_list_state( - &self, - Parameters(_params): Parameters, - ) -> String { - // Document indexing/listing is not yet implemented. - // This will be connected to the document storage layer when available. - let result = ListStateResult { - success: true, - documents: Vec::new(), - message: "Agent state document listing is not yet fully implemented. \ - Documents are signed and stored but a centralized index is pending. \ - Use jacs_verify_state with a file_path to check individual files." - .to_string(), - error: None, + Err(e) => MessageUpdateResult { + success: false, + jacs_document_id: None, + signed_message: None, + error: Some(e.to_string()), + }, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Adopt an external file as signed agent state. + /// Co-sign (agree to) a received signed message. /// - /// Like sign_state but sets the origin to "adopted" and optionally records - /// the source URL where the content was obtained. + /// Verifies the original message's signature, then creates an agreement document + /// that references the original and is signed by the local agent. #[tool( - name = "jacs_adopt_state", - description = "Adopt an external file as signed agent state, marking it with 'adopted' origin." + name = "jacs_message_agree", + description = "Verify and co-sign a received message, creating a signed agreement document." )] - pub async fn jacs_adopt_state( + pub async fn jacs_message_agree( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - // Create the agent state document with file reference - let mut doc = match agentstate_crud::create_agentstate_with_file( - ¶ms.state_type, - ¶ms.name, - ¶ms.file_path, - false, // don't embed by default for adopted state - ) { - Ok(doc) => doc, + // Verify the original document's signature first + match self.agent.verify_document(¶ms.signed_message) { + Ok(true) => {} // Signature valid, proceed + Ok(false) => { + let result = MessageAgreeResult { + success: false, + original_document_id: None, + agreement_document_id: None, + signed_agreement: None, + error: Some("Original message signature verification failed".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } Err(e) => { - let result = AdoptStateResult { + let result = MessageAgreeResult { success: false, - jacs_document_id: None, - state_type: params.state_type, - name: params.name, - message: "Failed to create agent state document".to_string(), - error: Some(e), + original_document_id: None, + agreement_document_id: None, + signed_agreement: None, + error: Some(format!("Failed to verify original message: {}", e)), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); } - }; - - // Set description if provided - if let Some(desc) = ¶ms.description { - doc["jacsAgentStateDescription"] = serde_json::json!(desc); } - // Set origin as "adopted" with optional source URL - if let Err(e) = agentstate_crud::set_agentstate_origin( - &mut doc, - "adopted", - params.source_url.as_deref(), - ) { - let result = AdoptStateResult { - success: false, - jacs_document_id: None, - state_type: params.state_type, - name: params.name, - message: "Failed to set adopted origin".to_string(), - error: Some(e), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } + // Extract the original document ID + let original_doc_id = extract_document_lookup_key_from_str(¶ms.signed_message) + .unwrap_or_else(|| "unknown".to_string()); - // Sign the document - let doc_string = doc.to_string(); + let our_agent_id = self + .agent + .get_agent_id() + .unwrap_or_else(|_| "unknown".to_string()); + + let timestamp = format_iso8601(std::time::SystemTime::now()); + + // Create an agreement document that references the original + let agreement_doc = serde_json::json!({ + "jacsAgreementType": "message_acknowledgment", + "jacsAgreementOriginalDocumentId": original_doc_id, + "jacsAgreementAgentId": our_agent_id, + "jacsAgreementTimestamp": timestamp, + }); + + let doc_string = agreement_doc.to_string(); + + // Sign the agreement document let result = match self.agent.create_document( &doc_string, None, // custom_schema None, // outputfilename true, // no_save None, // attachments - Some(false), + None, // embed ) { - Ok(signed_doc_string) => { - let doc_id = serde_json::from_str::(&signed_doc_string) - .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) + Ok(signed_agreement_string) => { + let agreement_id = extract_document_lookup_key_from_str(&signed_agreement_string) .unwrap_or_else(|| "unknown".to_string()); - AdoptStateResult { + MessageAgreeResult { success: true, - jacs_document_id: Some(doc_id), - state_type: params.state_type, - name: params.name, - message: format!( - "Successfully adopted and signed state file '{}' (origin: adopted{})", - params.file_path, - params - .source_url - .as_ref() - .map(|u| format!(", source: {}", u)) - .unwrap_or_default() - ), + original_document_id: Some(original_doc_id), + agreement_document_id: Some(agreement_id), + signed_agreement: Some(signed_agreement_string), error: None, } } - Err(e) => AdoptStateResult { + Err(e) => MessageAgreeResult { success: false, - jacs_document_id: None, - state_type: params.state_type, - name: params.name, - message: "Failed to sign adopted document".to_string(), + original_document_id: Some(original_doc_id), + agreement_document_id: None, + signed_agreement: None, error: Some(e.to_string()), }, }; @@ -2616,828 +3050,927 @@ impl HaiMcpServer { serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Create a new JACS agent programmatically. + /// Verify and extract content from a received signed message. /// - /// This is the programmatic equivalent of `jacs create`. It generates - /// a new agent with cryptographic keys and returns the agent info. - /// Requires JACS_MCP_ALLOW_REGISTRATION=true for security. + /// Checks the cryptographic signature, then extracts the message content, + /// sender ID, content type, and timestamp. #[tool( - name = "jacs_create_agent", - description = "Create a new JACS agent with cryptographic keys (programmatic)." + name = "jacs_message_receive", + description = "Verify a received signed message and extract its content and sender information." )] - pub async fn jacs_create_agent( + pub async fn jacs_message_receive( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - // Require explicit opt-in for agent creation (same gate as registration) - if !self.registration_allowed { - let result = CreateAgentProgrammaticResult { - success: false, - agent_id: None, - name: params.name, - message: "Agent creation is disabled. Set JACS_MCP_ALLOW_REGISTRATION=true \ - environment variable to enable." - .to_string(), - error: Some("REGISTRATION_NOT_ALLOWED".to_string()), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } + // Verify the document's signature + let signature_valid = match self.agent.verify_document(¶ms.signed_message) { + Ok(valid) => valid, + Err(e) => { + let result = MessageReceiveResult { + success: false, + sender_agent_id: None, + content: None, + content_type: None, + timestamp: None, + signature_valid: false, + error: Some(format!("Failed to verify message signature: {}", e)), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + // Parse the document to extract fields + let doc: serde_json::Value = match serde_json::from_str(¶ms.signed_message) { + Ok(v) => v, + Err(e) => { + let result = MessageReceiveResult { + success: false, + sender_agent_id: None, + content: None, + content_type: None, + timestamp: None, + signature_valid, + error: Some(format!("Failed to parse message JSON: {}", e)), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + // Extract message fields + let sender_agent_id = doc + .get("jacsMessageSenderId") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| { + // Fall back to signature's agentID + doc.get("jacsSignature") + .and_then(|s| s.get("agentID")) + .and_then(|v| v.as_str()) + .map(String::from) + }); + + let content = doc + .get("jacsMessageContent") + .and_then(|v| v.as_str()) + .map(String::from); + + let content_type = doc + .get("jacsMessageContentType") + .and_then(|v| v.as_str()) + .map(String::from); + + let timestamp = doc + .get("jacsMessageTimestamp") + .and_then(|v| v.as_str()) + .map(String::from); + + let result = MessageReceiveResult { + success: true, + sender_agent_id, + content, + content_type, + timestamp, + signature_valid, + error: if !signature_valid { + Some( + "Message signature is INVALID — content may have been tampered with" + .to_string(), + ) + } else { + None + }, + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + // ========================================================================= + // Agreement tools — multi-party cryptographic agreements + // ========================================================================= + + /// Create a multi-party agreement that other agents can co-sign. + /// + /// The agreement specifies which agents must sign, optional quorum (M-of-N), + /// timeout, and algorithm constraints. The returned document should be passed + /// to other agents for signing via `jacs_sign_agreement`. + #[tool( + name = "jacs_create_agreement", + description = "Create a multi-party cryptographic agreement. Specify which agents must sign, \ + optional quorum (e.g., 2-of-3), timeout deadline, and algorithm constraints. \ + Returns a signed agreement document to pass to other agents for co-signing." + )] + pub async fn jacs_create_agreement( + &self, + Parameters(params): Parameters, + ) -> String { + // Create the base document first + let signed_doc = match self.agent.create_document( + ¶ms.document, + None, // custom_schema + None, // outputfilename + true, // no_save + None, // attachments + None, // embed + ) { + Ok(doc) => doc, + Err(e) => { + let result = CreateAgreementResult { + success: false, + agreement_id: None, + signed_agreement: None, + error: Some(format!("Failed to create document: {}", e)), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; - let result = match jacs_binding_core::create_agent_programmatic( - ¶ms.name, - ¶ms.password, - params.algorithm.as_deref(), - params.data_directory.as_deref(), - params.key_directory.as_deref(), - None, // config_path - params.agent_type.as_deref(), - params.description.as_deref(), - None, // domain - None, // default_storage + // Create the agreement on the document + let result = match self.agent.create_agreement_with_options( + &signed_doc, + params.agent_ids, + params.question, + params.context, + None, // agreement_fieldname (use default) + params.timeout, + params.quorum, + params.required_algorithms, + params.minimum_strength, ) { - Ok(info_json) => { - // Parse the info JSON to extract agent_id - let agent_id = serde_json::from_str::(&info_json) - .ok() - .and_then(|v| v.get("agent_id").and_then(|a| a.as_str()).map(String::from)); + Ok(agreement_string) => { + let agreement_id = extract_document_lookup_key_from_str(&agreement_string) + .unwrap_or_else(|| "unknown".to_string()); - CreateAgentProgrammaticResult { + CreateAgreementResult { success: true, - agent_id, - name: params.name, - message: "Agent created successfully".to_string(), + agreement_id: Some(agreement_id), + signed_agreement: Some(agreement_string), error: None, } } - Err(e) => CreateAgentProgrammaticResult { + Err(e) => CreateAgreementResult { success: false, - agent_id: None, - name: params.name, - message: "Failed to create agent".to_string(), - error: Some(e.to_string()), + agreement_id: None, + signed_agreement: None, + error: Some(format!("Failed to create agreement: {}", e)), }, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Re-encrypt the agent's private key with a new password. + /// Co-sign an existing agreement. /// - /// Decrypts the private key with the old password and re-encrypts it - /// with the new password. The key itself does not change. + /// Adds this agent's cryptographic signature to the agreement. The agent's + /// algorithm must satisfy any constraints specified when the agreement was created. #[tool( - name = "jacs_reencrypt_key", - description = "Re-encrypt the agent's private key with a new password." + name = "jacs_sign_agreement", + description = "Co-sign an existing agreement. Adds your agent's cryptographic signature. \ + The agreement may have algorithm constraints that your agent must satisfy." )] - pub async fn jacs_reencrypt_key( + pub async fn jacs_sign_agreement( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { let result = match self .agent - .reencrypt_key(¶ms.old_password, ¶ms.new_password) + .sign_agreement(¶ms.signed_agreement, params.agreement_fieldname) { - Ok(()) => ReencryptKeyResult { - success: true, - message: "Private key re-encrypted successfully with new password".to_string(), - error: None, - }, - Err(e) => ReencryptKeyResult { + Ok(signed_string) => { + // Count signatures + let sig_count = + if let Ok(v) = serde_json::from_str::(&signed_string) { + v.get("jacsAgreement") + .and_then(|a| a.get("signatures")) + .and_then(|s| s.as_array()) + .map(|arr| arr.len()) + .unwrap_or(0) + } else { + 0 + }; + + SignAgreementResult { + success: true, + signed_agreement: Some(signed_string), + signature_count: Some(sig_count), + error: None, + } + } + Err(e) => SignAgreementResult { success: false, - message: "Failed to re-encrypt private key".to_string(), - error: Some(e.to_string()), + signed_agreement: None, + signature_count: None, + error: Some(format!("Failed to sign agreement: {}", e)), }, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Run a read-only JACS security audit. Returns JSON with risks, health_checks, summary. - #[tool( - name = "jacs_audit", - description = "Run a read-only JACS security audit and health checks." - )] - pub async fn jacs_audit(&self, Parameters(params): Parameters) -> String { - match jacs_binding_core::audit(params.config_path.as_deref(), params.recent_n) { - Ok(json) => json, - Err(e) => serde_json::json!({ - "error": true, - "message": e.to_string() - }) - .to_string(), - } - } - - /// Create and sign a message document for sending to another agent. + /// Check the status of an agreement. /// - /// Builds a JSON message envelope with sender/recipient IDs, content, timestamp, - /// and a unique message ID, then signs it using the local agent's keys. + /// Returns whether quorum is met, which agents have signed, whether the + /// agreement has expired, and how many more signatures are needed. #[tool( - name = "jacs_message_send", - description = "Create and sign a message for sending to another agent." + name = "jacs_check_agreement", + description = "Check agreement status: who has signed, whether quorum is met, \ + whether it has expired, and who still needs to sign." )] - pub async fn jacs_message_send( + pub async fn jacs_check_agreement( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - // Validate recipient agent ID - if let Err(e) = validate_agent_id(¶ms.recipient_agent_id) { - let result = MessageSendResult { - success: false, - jacs_document_id: None, - signed_message: None, - error: Some(e), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } + // Parse the agreement to extract status without full verification + let doc: serde_json::Value = match serde_json::from_str(¶ms.signed_agreement) { + Ok(v) => v, + Err(e) => { + let result = CheckAgreementResult { + success: false, + complete: false, + total_agents: 0, + signatures_collected: 0, + signatures_required: 0, + quorum_met: false, + expired: false, + signed_by: None, + unsigned: None, + timeout: None, + error: Some(format!("Failed to parse agreement JSON: {}", e)), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + let fieldname = params + .agreement_fieldname + .unwrap_or_else(|| "jacsAgreement".to_string()); - // Get the sender's agent ID from the loaded agent - let sender_id = match self.agent.get_agent_json() { - Ok(json_str) => serde_json::from_str::(&json_str) - .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) - .unwrap_or_else(|| "unknown".to_string()), - Err(_) => "unknown".to_string(), + let agreement = match doc.get(&fieldname) { + Some(a) => a, + None => { + let result = CheckAgreementResult { + success: false, + complete: false, + total_agents: 0, + signatures_collected: 0, + signatures_required: 0, + quorum_met: false, + expired: false, + signed_by: None, + unsigned: None, + timeout: None, + error: Some(format!("No '{}' field found in document", fieldname)), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } }; - let content_type = params - .content_type - .unwrap_or_else(|| "text/plain".to_string()); - let message_id = Uuid::new_v4().to_string(); - let timestamp = format_iso8601(std::time::SystemTime::now()); + // Extract agent IDs + let agent_ids: Vec = agreement + .get("agentIDs") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); - // Build the message document - let message_doc = serde_json::json!({ - "jacsMessageId": message_id, - "jacsMessageSenderId": sender_id, - "jacsMessageRecipientId": params.recipient_agent_id, - "jacsMessageContent": params.content, - "jacsMessageContentType": content_type, - "jacsMessageTimestamp": timestamp, - }); + // Extract signatures + let signatures = agreement + .get("signatures") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); - let doc_string = message_doc.to_string(); + let signed_by: Vec = signatures + .iter() + .filter_map(|sig| { + sig.get("agentID") + .and_then(|v| v.as_str()) + .map(String::from) + }) + .collect(); + + let signed_set: std::collections::HashSet<&str> = + signed_by.iter().map(|s| s.as_str()).collect(); + let unsigned: Vec = agent_ids + .iter() + .filter(|id| !signed_set.contains(id.as_str())) + .cloned() + .collect(); + + // Quorum + let quorum = agreement + .get("quorum") + .and_then(|v| v.as_u64()) + .map(|q| q as usize) + .unwrap_or(agent_ids.len()); + let quorum_met = signed_by.len() >= quorum; + + // Timeout + let timeout_str = agreement + .get("timeout") + .and_then(|v| v.as_str()) + .map(String::from); + let expired = timeout_str + .as_ref() + .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) + .map(|deadline| chrono::Utc::now() > deadline) + .unwrap_or(false); - // Sign the document - let result = match self.agent.create_document( - &doc_string, - None, // custom_schema - None, // outputfilename - true, // no_save - None, // attachments - None, // embed - ) { - Ok(signed_doc_string) => { - let doc_id = serde_json::from_str::(&signed_doc_string) - .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) - .unwrap_or_else(|| "unknown".to_string()); + let complete = quorum_met && !expired; - MessageSendResult { - success: true, - jacs_document_id: Some(doc_id), - signed_message: Some(signed_doc_string), - error: None, - } - } - Err(e) => MessageSendResult { - success: false, - jacs_document_id: None, - signed_message: None, - error: Some(e.to_string()), - }, + let result = CheckAgreementResult { + success: true, + complete, + total_agents: agent_ids.len(), + signatures_collected: signed_by.len(), + signatures_required: quorum, + quorum_met, + expired, + signed_by: Some(signed_by), + unsigned: Some(unsigned), + timeout: timeout_str, + error: None, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - /// Update and re-sign an existing message document with new content. - /// - /// Loads the message by its JACS document ID, replaces the content fields, - /// and creates a new signed version. + // ========================================================================= + // Document Sign / Verify tools + // ========================================================================= + + /// Sign arbitrary JSON content to create a cryptographically signed JACS document. #[tool( - name = "jacs_message_update", - description = "Update and re-sign an existing message document with new content." + name = "jacs_sign_document", + description = "Sign arbitrary JSON content to create a signed JACS document for attestation." )] - pub async fn jacs_message_update( + pub async fn jacs_sign_document( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - // Load the existing document by ID - let existing_doc_string: Option = - match self.agent.verify_document_by_id(¶ms.jacs_id) { - Ok(true) => { - // Document verified, now retrieve it. We need the stored document. - // Use get_agent_json to get agent context, then load via ID. - // The verify_document_by_id already loaded it; we need to get it from storage. - // Fall through to attempt update_document with the new content. - None - } - Ok(false) => { - let result = MessageUpdateResult { - success: false, - jacs_document_id: None, - signed_message: None, - error: Some(format!( - "Existing document '{}' failed signature verification", - params.jacs_id - )), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - Err(e) => { - let result = MessageUpdateResult { - success: false, - jacs_document_id: None, - signed_message: None, - error: Some(format!( - "Failed to load document '{}': {}", - params.jacs_id, e - )), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - }; - - let content_type = params - .content_type - .unwrap_or_else(|| "text/plain".to_string()); - let timestamp = format_iso8601(std::time::SystemTime::now()); - - // Build the updated message content - let updated_doc = serde_json::json!({ - "jacsMessageContent": params.content, - "jacsMessageContentType": content_type, - "jacsMessageTimestamp": timestamp, - }); + // Validate content is valid JSON + let content_value: serde_json::Value = match serde_json::from_str(¶ms.content) { + Ok(v) => v, + Err(e) => { + let result = SignDocumentResult { + success: false, + signed_document: None, + content_hash: None, + jacs_document_id: None, + message: "Content is not valid JSON".to_string(), + error: Some(e.to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; - let _ = existing_doc_string; // consumed above + // Wrap content in a JACS-compatible envelope if it doesn't already have jacsType + let doc_to_sign = if content_value.get("jacsType").is_some() { + params.content.clone() + } else { + let wrapper = serde_json::json!({ + "jacsType": "document", + "jacsLevel": "raw", + "content": content_value, + }); + wrapper.to_string() + }; - let doc_string = updated_doc.to_string(); - let result = match self + // Sign via create_document (no_save=true) + match self .agent - .update_document(¶ms.jacs_id, &doc_string, None, None) + .create_document(&doc_to_sign, None, None, true, None, None) { - Ok(updated_doc_string) => { - let doc_id = serde_json::from_str::(&updated_doc_string) - .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) - .unwrap_or_else(|| params.jacs_id.clone()); + Ok(signed_doc_string) => { + // Extract document ID and compute content hash + let doc_id = extract_document_lookup_key_from_str(&signed_doc_string); - MessageUpdateResult { + let hash = { + let mut hasher = Sha256::new(); + hasher.update(signed_doc_string.as_bytes()); + format!("{:x}", hasher.finalize()) + }; + + let result = SignDocumentResult { success: true, - jacs_document_id: Some(doc_id), - signed_message: Some(updated_doc_string), + signed_document: Some(signed_doc_string), + content_hash: Some(hash), + jacs_document_id: doc_id, + message: "Document signed successfully".to_string(), error: None, - } + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - Err(e) => MessageUpdateResult { - success: false, - jacs_document_id: None, - signed_message: None, - error: Some(e.to_string()), - }, - }; - - serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + Err(e) => { + let result = SignDocumentResult { + success: false, + signed_document: None, + content_hash: None, + jacs_document_id: None, + message: "Failed to sign document".to_string(), + error: Some(e.to_string()), + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + } } - /// Co-sign (agree to) a received signed message. - /// - /// Verifies the original message's signature, then creates an agreement document - /// that references the original and is signed by the local agent. + /// Verify a signed JACS document given its full JSON string. #[tool( - name = "jacs_message_agree", - description = "Verify and co-sign a received message, creating a signed agreement document." + name = "jacs_verify_document", + description = "Verify a signed JACS document's hash and cryptographic signature." )] - pub async fn jacs_message_agree( + pub async fn jacs_verify_document( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - // Verify the original document's signature first - match self.agent.verify_document(¶ms.signed_message) { - Ok(true) => {} // Signature valid, proceed - Ok(false) => { - let result = MessageAgreeResult { - success: false, - original_document_id: None, - agreement_document_id: None, - signed_agreement: None, - error: Some("Original message signature verification failed".to_string()), + if params.document.is_empty() { + let result = VerifyDocumentResult { + success: false, + valid: false, + signer_id: None, + message: "Document string is empty".to_string(), + error: Some("EMPTY_DOCUMENT".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + // Try verify_signature first (works for both self-signed and external docs) + match self.agent.verify_signature(¶ms.document, None) { + Ok(valid) => { + // Try to extract signer ID from the document + let signer_id = serde_json::from_str::(¶ms.document) + .ok() + .and_then(|v| { + v.get("jacsSignature") + .and_then(|sig| sig.get("agentId").or_else(|| sig.get("agentID"))) + .and_then(|id| id.as_str()) + .map(String::from) + }); + + let result = VerifyDocumentResult { + success: true, + valid, + signer_id, + message: if valid { + "Document verified successfully".to_string() + } else { + "Document signature verification failed".to_string() + }, + error: None, }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } Err(e) => { - let result = MessageAgreeResult { + let result = VerifyDocumentResult { success: false, - original_document_id: None, - agreement_document_id: None, - signed_agreement: None, - error: Some(format!("Failed to verify original message: {}", e)), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - } - - // Extract the original document ID - let original_doc_id = serde_json::from_str::(¶ms.signed_message) - .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) - .unwrap_or_else(|| "unknown".to_string()); - - // Get our agent ID - let our_agent_id = match self.agent.get_agent_json() { - Ok(json_str) => serde_json::from_str::(&json_str) - .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) - .unwrap_or_else(|| "unknown".to_string()), - Err(_) => "unknown".to_string(), - }; - - let timestamp = format_iso8601(std::time::SystemTime::now()); - - // Create an agreement document that references the original - let agreement_doc = serde_json::json!({ - "jacsAgreementType": "message_acknowledgment", - "jacsAgreementOriginalDocumentId": original_doc_id, - "jacsAgreementAgentId": our_agent_id, - "jacsAgreementTimestamp": timestamp, - }); + valid: false, + signer_id: None, + message: format!("Verification failed: {}", e), + error: Some(e.to_string()), + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + } + } - let doc_string = agreement_doc.to_string(); + // ========================================================================= + // A2A Artifact Wrapping/Verification Tools + // ========================================================================= - // Sign the agreement document - let result = match self.agent.create_document( - &doc_string, - None, // custom_schema - None, // outputfilename - true, // no_save - None, // attachments - None, // embed - ) { - Ok(signed_agreement_string) => { - let agreement_id = - serde_json::from_str::(&signed_agreement_string) - .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) - .unwrap_or_else(|| "unknown".to_string()); + /// Wrap an A2A artifact with JACS provenance signature. + #[tool( + name = "jacs_wrap_a2a_artifact", + description = "Wrap an A2A artifact with JACS provenance signature." + )] + pub async fn jacs_wrap_a2a_artifact( + &self, + Parameters(params): Parameters, + ) -> String { + if params.artifact_json.is_empty() { + let result = WrapA2aArtifactResult { + success: false, + wrapped_artifact: None, + message: "Artifact JSON is empty".to_string(), + error: Some("EMPTY_ARTIFACT".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } - MessageAgreeResult { + #[allow(deprecated)] + match self.agent.wrap_a2a_artifact( + ¶ms.artifact_json, + ¶ms.artifact_type, + params.parent_signatures.as_deref(), + ) { + Ok(wrapped_json) => { + let result = WrapA2aArtifactResult { success: true, - original_document_id: Some(original_doc_id), - agreement_document_id: Some(agreement_id), - signed_agreement: Some(signed_agreement_string), + wrapped_artifact: Some(wrapped_json), + message: "Artifact wrapped with JACS provenance".to_string(), error: None, - } + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - Err(e) => MessageAgreeResult { - success: false, - original_document_id: Some(original_doc_id), - agreement_document_id: None, - signed_agreement: None, - error: Some(e.to_string()), - }, - }; - - serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + Err(e) => { + let result = WrapA2aArtifactResult { + success: false, + wrapped_artifact: None, + message: "Failed to wrap artifact".to_string(), + error: Some(e.to_string()), + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + } } - /// Verify and extract content from a received signed message. - /// - /// Checks the cryptographic signature, then extracts the message content, - /// sender ID, content type, and timestamp. + /// Verify a JACS-wrapped A2A artifact. #[tool( - name = "jacs_message_receive", - description = "Verify a received signed message and extract its content and sender information." + name = "jacs_verify_a2a_artifact", + description = "Verify a JACS-wrapped A2A artifact's signature and hash." )] - pub async fn jacs_message_receive( + pub async fn jacs_verify_a2a_artifact( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - // Verify the document's signature - let signature_valid = match self.agent.verify_document(¶ms.signed_message) { - Ok(valid) => valid, - Err(e) => { - let result = MessageReceiveResult { - success: false, - sender_agent_id: None, - content: None, - content_type: None, - timestamp: None, - signature_valid: false, - error: Some(format!("Failed to verify message signature: {}", e)), + if params.wrapped_artifact.is_empty() { + let result = VerifyA2aArtifactResult { + success: false, + valid: false, + verification_details: None, + message: "Wrapped artifact JSON is empty".to_string(), + error: Some("EMPTY_ARTIFACT".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + match self.agent.verify_a2a_artifact(¶ms.wrapped_artifact) { + Ok(details_json) => { + let valid = extract_verify_a2a_valid(&details_json); + let result = VerifyA2aArtifactResult { + success: true, + valid, + verification_details: Some(details_json), + message: if valid { + "Artifact verified successfully".to_string() + } else { + "Artifact verification found issues".to_string() + }, + error: None, }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - }; - - // Parse the document to extract fields - let doc: serde_json::Value = match serde_json::from_str(¶ms.signed_message) { - Ok(v) => v, Err(e) => { - let result = MessageReceiveResult { + let result = VerifyA2aArtifactResult { success: false, - sender_agent_id: None, - content: None, - content_type: None, - timestamp: None, - signature_valid, - error: Some(format!("Failed to parse message JSON: {}", e)), + valid: false, + verification_details: None, + message: "Artifact verification failed".to_string(), + error: Some(e.to_string()), }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - }; - - // Extract message fields - let sender_agent_id = doc - .get("jacsMessageSenderId") - .and_then(|v| v.as_str()) - .map(String::from) - .or_else(|| { - // Fall back to signature's agentID - doc.get("jacsSignature") - .and_then(|s| s.get("agentID")) - .and_then(|v| v.as_str()) - .map(String::from) - }); - - let content = doc - .get("jacsMessageContent") - .and_then(|v| v.as_str()) - .map(String::from); + } + } - let content_type = doc - .get("jacsMessageContentType") - .and_then(|v| v.as_str()) - .map(String::from); + /// Assess the trust level of a remote A2A agent. + #[tool( + name = "jacs_assess_a2a_agent", + description = "Assess trust level of a remote A2A agent given its Agent Card." + )] + pub async fn jacs_assess_a2a_agent( + &self, + Parameters(params): Parameters, + ) -> String { + if params.agent_card_json.is_empty() { + let result = AssessA2aAgentResult { + success: false, + allowed: false, + trust_level: None, + policy: None, + reason: None, + message: "Agent Card JSON is empty".to_string(), + error: Some("EMPTY_AGENT_CARD".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } - let timestamp = doc - .get("jacsMessageTimestamp") - .and_then(|v| v.as_str()) - .map(String::from); + let policy_str = params.policy.as_deref().unwrap_or("verified"); - let result = MessageReceiveResult { - success: true, - sender_agent_id, - content, - content_type, - timestamp, - signature_valid, - error: if !signature_valid { - Some( - "Message signature is INVALID — content may have been tampered with" - .to_string(), - ) - } else { - None - }, - }; + match self + .agent + .assess_a2a_agent(¶ms.agent_card_json, policy_str) + { + Ok(assessment_json) => { + // Parse the assessment to extract fields for our result type + let assessment: serde_json::Value = + serde_json::from_str(&assessment_json).unwrap_or_default(); + let allowed = assessment + .get("allowed") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let trust_level = assessment + .get("trust_level") + .and_then(|v| v.as_str()) + .map(String::from); + let policy = assessment + .get("policy") + .and_then(|v| v.as_str()) + .map(String::from); + let reason = assessment + .get("reason") + .and_then(|v| v.as_str()) + .map(String::from); - serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + let result = AssessA2aAgentResult { + success: true, + allowed, + trust_level, + policy, + reason: reason.clone(), + message: reason.unwrap_or_else(|| "Assessment complete".to_string()), + error: None, + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + Err(e) => { + let result = AssessA2aAgentResult { + success: false, + allowed: false, + trust_level: None, + policy: Some(policy_str.to_string()), + reason: None, + message: "Trust assessment failed".to_string(), + error: Some(e.to_string()), + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + } } // ========================================================================= - // Agreement tools — multi-party cryptographic agreements + // Agent Card & Well-Known Tools // ========================================================================= - /// Create a multi-party agreement that other agents can co-sign. - /// - /// The agreement specifies which agents must sign, optional quorum (M-of-N), - /// timeout, and algorithm constraints. The returned document should be passed - /// to other agents for signing via `jacs_sign_agreement`. + /// Export this agent's A2A Agent Card. #[tool( - name = "jacs_create_agreement", - description = "Create a multi-party cryptographic agreement. Specify which agents must sign, \ - optional quorum (e.g., 2-of-3), timeout deadline, and algorithm constraints. \ - Returns a signed agreement document to pass to other agents for co-signing." + name = "jacs_export_agent_card", + description = "Export this agent's A2A Agent Card as JSON for discovery." )] - pub async fn jacs_create_agreement( + pub async fn jacs_export_agent_card( &self, - Parameters(params): Parameters, + Parameters(_params): Parameters, ) -> String { - // Create the base document first - let signed_doc = match self.agent.create_document( - ¶ms.document, - None, // custom_schema - None, // outputfilename - true, // no_save - None, // attachments - None, // embed - ) { - Ok(doc) => doc, + match self.agent.export_agent_card() { + Ok(card_json) => { + let result = ExportAgentCardResult { + success: true, + agent_card: Some(card_json), + message: "Agent Card exported successfully".to_string(), + error: None, + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } Err(e) => { - let result = CreateAgreementResult { + let result = ExportAgentCardResult { success: false, - agreement_id: None, - signed_agreement: None, - error: Some(format!("Failed to create document: {}", e)), + agent_card: None, + message: "Failed to export Agent Card".to_string(), + error: Some(e.to_string()), }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - }; - - // Create the agreement on the document - let result = match self.agent.create_agreement_with_options( - &signed_doc, - params.agent_ids, - params.question, - params.context, - None, // agreement_fieldname (use default) - params.timeout, - params.quorum, - params.required_algorithms, - params.minimum_strength, - ) { - Ok(agreement_string) => { - let agreement_id = serde_json::from_str::(&agreement_string) - .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) - .unwrap_or_else(|| "unknown".to_string()); - - CreateAgreementResult { - success: true, - agreement_id: Some(agreement_id), - signed_agreement: Some(agreement_string), - error: None, - } + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - Err(e) => CreateAgreementResult { - success: false, - agreement_id: None, - signed_agreement: None, - error: Some(format!("Failed to create agreement: {}", e)), - }, - }; - - serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } } - /// Co-sign an existing agreement. - /// - /// Adds this agent's cryptographic signature to the agreement. The agent's - /// algorithm must satisfy any constraints specified when the agreement was created. - #[tool( - name = "jacs_sign_agreement", - description = "Co-sign an existing agreement. Adds your agent's cryptographic signature. \ - The agreement may have algorithm constraints that your agent must satisfy." + /// Generate all .well-known documents for A2A discovery. + #[tool( + name = "jacs_generate_well_known", + description = "Generate .well-known documents for A2A agent discovery." )] - pub async fn jacs_sign_agreement( + pub async fn jacs_generate_well_known( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - let result = match self + match self .agent - .sign_agreement(¶ms.signed_agreement, params.agreement_fieldname) + .generate_well_known_documents(params.a2a_algorithm.as_deref()) { - Ok(signed_string) => { - // Count signatures - let sig_count = - if let Ok(v) = serde_json::from_str::(&signed_string) { - v.get("jacsAgreement") - .and_then(|a| a.get("signatures")) - .and_then(|s| s.as_array()) - .map(|arr| arr.len()) - .unwrap_or(0) - } else { - 0 - }; - - SignAgreementResult { + Ok(docs_json) => { + // Parse to count documents + let count = serde_json::from_str::>(&docs_json) + .map(|v| v.len()) + .unwrap_or(0); + let result = GenerateWellKnownResult { success: true, - signed_agreement: Some(signed_string), - signature_count: Some(sig_count), + documents: Some(docs_json), + count, + message: format!("{} well-known document(s) generated", count), error: None, - } + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - Err(e) => SignAgreementResult { - success: false, - signed_agreement: None, - signature_count: None, - error: Some(format!("Failed to sign agreement: {}", e)), - }, - }; - - serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + Err(e) => { + let result = GenerateWellKnownResult { + success: false, + documents: None, + count: 0, + message: "Failed to generate well-known documents".to_string(), + error: Some(e.to_string()), + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + } } - /// Check the status of an agreement. - /// - /// Returns whether quorum is met, which agents have signed, whether the - /// agreement has expired, and how many more signatures are needed. + /// Export the local agent's full JACS JSON document. #[tool( - name = "jacs_check_agreement", - description = "Check agreement status: who has signed, whether quorum is met, \ - whether it has expired, and who still needs to sign." + name = "jacs_export_agent", + description = "Export the local agent's full JACS JSON document." )] - pub async fn jacs_check_agreement( + pub async fn jacs_export_agent( &self, - Parameters(params): Parameters, + Parameters(_params): Parameters, ) -> String { - // Parse the agreement to extract status without full verification - let doc: serde_json::Value = match serde_json::from_str(¶ms.signed_agreement) { - Ok(v) => v, - Err(e) => { - let result = CheckAgreementResult { - success: false, - complete: false, - total_agents: 0, - signatures_collected: 0, - signatures_required: 0, - quorum_met: false, - expired: false, - signed_by: None, - unsigned: None, - timeout: None, - error: Some(format!("Failed to parse agreement JSON: {}", e)), + match self.agent.get_agent_json() { + Ok(agent_json) => { + // Try to extract the agent ID from the JSON + let agent_id = serde_json::from_str::(&agent_json) + .ok() + .and_then(|v| v.get("jacsId").and_then(|id| id.as_str()).map(String::from)); + let result = ExportAgentResult { + success: true, + agent_json: Some(agent_json), + agent_id, + message: "Agent document exported successfully".to_string(), + error: None, }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - }; - - let fieldname = params - .agreement_fieldname - .unwrap_or_else(|| "jacsAgreement".to_string()); - - let agreement = match doc.get(&fieldname) { - Some(a) => a, - None => { - let result = CheckAgreementResult { + Err(e) => { + let result = ExportAgentResult { success: false, - complete: false, - total_agents: 0, - signatures_collected: 0, - signatures_required: 0, - quorum_met: false, - expired: false, - signed_by: None, - unsigned: None, - timeout: None, - error: Some(format!("No '{}' field found in document", fieldname)), + agent_json: None, + agent_id: None, + message: "Failed to export agent document".to_string(), + error: Some(e.to_string()), }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - }; - - // Extract agent IDs - let agent_ids: Vec = agreement - .get("agentIDs") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - // Extract signatures - let signatures = agreement - .get("signatures") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - - let signed_by: Vec = signatures - .iter() - .filter_map(|sig| { - sig.get("agentID") - .and_then(|v| v.as_str()) - .map(String::from) - }) - .collect(); - - let signed_set: std::collections::HashSet<&str> = - signed_by.iter().map(|s| s.as_str()).collect(); - let unsigned: Vec = agent_ids - .iter() - .filter(|id| !signed_set.contains(id.as_str())) - .cloned() - .collect(); - - // Quorum - let quorum = agreement - .get("quorum") - .and_then(|v| v.as_u64()) - .map(|q| q as usize) - .unwrap_or(agent_ids.len()); - let quorum_met = signed_by.len() >= quorum; - - // Timeout - let timeout_str = agreement - .get("timeout") - .and_then(|v| v.as_str()) - .map(String::from); - let expired = timeout_str - .as_ref() - .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) - .map(|deadline| chrono::Utc::now() > deadline) - .unwrap_or(false); - - let complete = quorum_met && !expired; - - let result = CheckAgreementResult { - success: true, - complete, - total_agents: agent_ids.len(), - signatures_collected: signed_by.len(), - signatures_required: quorum, - quorum_met, - expired, - signed_by: Some(signed_by), - unsigned: Some(unsigned), - timeout: timeout_str, - error: None, - }; - - serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } } // ========================================================================= - // Document Sign / Verify tools + // Trust Store Tools // ========================================================================= - /// Sign arbitrary JSON content to create a cryptographically signed JACS document. + /// Add an agent to the local trust store. + /// + /// The agent's self-signature is cryptographically verified before it is + /// added. If verification fails, the agent is NOT trusted. #[tool( - name = "jacs_sign_document", - description = "Sign arbitrary JSON content to create a signed JACS document for attestation." + name = "jacs_trust_agent", + description = "Add an agent to the local trust store after verifying its self-signature." )] - pub async fn jacs_sign_document( + pub async fn jacs_trust_agent( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - // Validate content is valid JSON - let content_value: serde_json::Value = match serde_json::from_str(¶ms.content) { - Ok(v) => v, + if params.agent_json.is_empty() { + let result = TrustAgentResult { + success: false, + agent_id: None, + message: "Agent JSON is empty".to_string(), + error: Some("EMPTY_AGENT_JSON".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + match jacs_binding_core::trust_agent(¶ms.agent_json) { + Ok(agent_id) => { + let result = TrustAgentResult { + success: true, + agent_id: Some(agent_id.clone()), + message: format!("Agent {} added to trust store", agent_id), + error: None, + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } Err(e) => { - let result = SignDocumentResult { + let result = TrustAgentResult { success: false, - signed_document: None, - content_hash: None, - jacs_document_id: None, - message: "Content is not valid JSON".to_string(), + agent_id: None, + message: "Failed to trust agent".to_string(), error: Some(e.to_string()), }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } - }; + } + } - // Wrap content in a JACS-compatible envelope if it doesn't already have jacsType - let doc_to_sign = if content_value.get("jacsType").is_some() { - params.content.clone() - } else { - let wrapper = serde_json::json!({ - "jacsType": "document", - "jacsLevel": "raw", - "content": content_value, - }); - wrapper.to_string() - }; + /// Remove an agent from the local trust store. + /// + /// # Security + /// + /// Untrusting requires `JACS_MCP_ALLOW_UNTRUST=true` environment variable. + /// This prevents prompt injection attacks from removing trusted agents + /// without user consent. + #[tool( + name = "jacs_untrust_agent", + description = "Remove an agent from the local trust store. Requires JACS_MCP_ALLOW_UNTRUST=true." + )] + pub async fn jacs_untrust_agent( + &self, + Parameters(params): Parameters, + ) -> String { + // Security check: Untrusting must be explicitly enabled + if !self.untrust_allowed { + let result = UntrustAgentResult { + success: false, + agent_id: params.agent_id.clone(), + message: "Untrusting is disabled for security. \ + To enable, set JACS_MCP_ALLOW_UNTRUST=true environment variable \ + when starting the MCP server." + .to_string(), + error: Some("UNTRUST_DISABLED".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } - // Sign via create_document (no_save=true) - match self - .agent - .create_document(&doc_to_sign, None, None, true, None, None) - { - Ok(signed_doc_string) => { - // Extract document ID and compute content hash - let doc_id = serde_json::from_str::(&signed_doc_string) - .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)); + if params.agent_id.is_empty() { + let result = UntrustAgentResult { + success: false, + agent_id: params.agent_id.clone(), + message: "Agent ID is empty".to_string(), + error: Some("EMPTY_AGENT_ID".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } - let hash = { - let mut hasher = Sha256::new(); - hasher.update(signed_doc_string.as_bytes()); - format!("{:x}", hasher.finalize()) + match jacs_binding_core::untrust_agent(¶ms.agent_id) { + Ok(()) => { + let result = UntrustAgentResult { + success: true, + agent_id: params.agent_id.clone(), + message: format!("Agent {} removed from trust store", params.agent_id), + error: None, + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + Err(e) => { + let result = UntrustAgentResult { + success: false, + agent_id: params.agent_id.clone(), + message: "Failed to untrust agent".to_string(), + error: Some(e.to_string()), }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + } + } - let result = SignDocumentResult { + /// List all trusted agent IDs in the local trust store. + #[tool( + name = "jacs_list_trusted_agents", + description = "List all agent IDs in the local trust store." + )] + pub async fn jacs_list_trusted_agents( + &self, + Parameters(_params): Parameters, + ) -> String { + match jacs_binding_core::list_trusted_agents() { + Ok(agent_ids) => { + let count = agent_ids.len(); + let result = ListTrustedAgentsResult { success: true, - signed_document: Some(signed_doc_string), - content_hash: Some(hash), - jacs_document_id: doc_id, - message: "Document signed successfully".to_string(), + agent_ids, + count, + message: format!("{} trusted agent(s) found", count), error: None, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } Err(e) => { - let result = SignDocumentResult { + let result = ListTrustedAgentsResult { success: false, - signed_document: None, - content_hash: None, - jacs_document_id: None, - message: "Failed to sign document".to_string(), + agent_ids: vec![], + count: 0, + message: "Failed to list trusted agents".to_string(), error: Some(e.to_string()), }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) @@ -3445,70 +3978,241 @@ impl HaiMcpServer { } } - /// Verify a signed JACS document given its full JSON string. + /// Check whether a specific agent is in the local trust store. #[tool( - name = "jacs_verify_document", - description = "Verify a signed JACS document's hash and cryptographic signature." + name = "jacs_is_trusted", + description = "Check whether a specific agent is in the local trust store." )] - pub async fn jacs_verify_document( + pub async fn jacs_is_trusted(&self, Parameters(params): Parameters) -> String { + if params.agent_id.is_empty() { + let result = IsTrustedResult { + success: false, + agent_id: params.agent_id.clone(), + trusted: false, + message: "Agent ID is empty".to_string(), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + let trusted = jacs_binding_core::is_trusted(¶ms.agent_id); + let result = IsTrustedResult { + success: true, + agent_id: params.agent_id.clone(), + trusted, + message: if trusted { + format!("Agent {} is trusted", params.agent_id) + } else { + format!("Agent {} is NOT trusted", params.agent_id) + }, + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Retrieve the full agent JSON document for a trusted agent. + #[tool( + name = "jacs_get_trusted_agent", + description = "Retrieve the full agent JSON for a trusted agent from the local trust store." + )] + pub async fn jacs_get_trusted_agent( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> String { - if params.document.is_empty() { - let result = VerifyDocumentResult { + if params.agent_id.is_empty() { + let result = GetTrustedAgentResult { success: false, - valid: false, - signer_id: None, - message: "Document string is empty".to_string(), - error: Some("EMPTY_DOCUMENT".to_string()), + agent_id: params.agent_id.clone(), + agent_json: None, + message: "Agent ID is empty".to_string(), + error: Some("EMPTY_AGENT_ID".to_string()), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); } - // Try verify_signature first (works for both self-signed and external docs) - match self.agent.verify_signature(¶ms.document, None) { - Ok(valid) => { - // Try to extract signer ID from the document - let signer_id = serde_json::from_str::(¶ms.document) - .ok() - .and_then(|v| { - v.get("jacsSignature") - .and_then(|sig| sig.get("agentId").or_else(|| sig.get("agentID"))) - .and_then(|id| id.as_str()) - .map(String::from) - }); - - let result = VerifyDocumentResult { + match jacs_binding_core::get_trusted_agent(¶ms.agent_id) { + Ok(agent_json) => { + let result = GetTrustedAgentResult { success: true, - valid, - signer_id, - message: if valid { - "Document verified successfully".to_string() - } else { - "Document signature verification failed".to_string() - }, + agent_id: params.agent_id.clone(), + agent_json: Some(agent_json), + message: format!("Retrieved trusted agent {}", params.agent_id), error: None, }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } Err(e) => { - let result = VerifyDocumentResult { + let result = GetTrustedAgentResult { success: false, - valid: false, - signer_id: None, - message: format!("Verification failed: {}", e), + agent_id: params.agent_id.clone(), + agent_json: None, + message: "Failed to get trusted agent".to_string(), error: Some(e.to_string()), }; serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } } } + + // ========================================================================= + // Attestation Tools (requires `attestation` feature) + // ========================================================================= + + /// Create a signed attestation document with subject, claims, and optional evidence. + /// + /// Requires the binary to be built with the `attestation` feature. + #[tool( + name = "jacs_attest_create", + description = "Create a signed attestation document. Provide a JSON string with: subject (type, id, digests), claims (name, value, confidence, assuranceLevel), and optional evidence, derivation, and policyContext." + )] + pub async fn jacs_attest_create( + &self, + Parameters(params): Parameters, + ) -> String { + #[cfg(feature = "attestation")] + { + match self.agent.create_attestation(¶ms.params_json) { + Ok(result) => result, + Err(e) => { + let error = serde_json::json!({ + "error": true, + "message": format!("Failed to create attestation: {}", e), + }); + serde_json::to_string_pretty(&error).unwrap_or_else(|e| format!("Error: {}", e)) + } + } + } + #[cfg(not(feature = "attestation"))] + { + let _ = params; + serde_json::json!({ + "error": true, + "message": "Attestation feature not available. Rebuild with --features attestation." + }) + .to_string() + } + } + + /// Verify an attestation document's cryptographic validity and optionally check evidence. + /// + /// Local tier: checks signature + hash only (fast). + /// Full tier (full=true): also checks evidence digests, freshness, and derivation chain. + #[tool( + name = "jacs_attest_verify", + description = "Verify an attestation document. Provide a document_key in 'jacsId:jacsVersion' format. Set full=true for evidence and chain verification." + )] + pub async fn jacs_attest_verify( + &self, + Parameters(params): Parameters, + ) -> String { + #[cfg(feature = "attestation")] + { + let result = if params.full { + self.agent.verify_attestation_full(¶ms.document_key) + } else { + self.agent.verify_attestation(¶ms.document_key) + }; + + match result { + Ok(json) => json, + Err(e) => { + let error = serde_json::json!({ + "error": true, + "valid": false, + "message": format!("Failed to verify attestation: {}", e), + }); + serde_json::to_string_pretty(&error).unwrap_or_else(|e| format!("Error: {}", e)) + } + } + } + #[cfg(not(feature = "attestation"))] + { + let _ = params; + serde_json::json!({ + "error": true, + "valid": false, + "message": "Attestation feature not available. Rebuild with --features attestation." + }) + .to_string() + } + } + + /// Lift an existing signed document into an attestation with additional claims. + /// + /// Takes a signed JACS document and wraps it in an attestation that references + /// the original document as its subject. + #[tool( + name = "jacs_attest_lift", + description = "Lift an existing signed JACS document into an attestation. Provide the signed document JSON and a JSON array of claims." + )] + pub async fn jacs_attest_lift( + &self, + Parameters(params): Parameters, + ) -> String { + #[cfg(feature = "attestation")] + { + match self + .agent + .lift_to_attestation(¶ms.signed_doc_json, ¶ms.claims_json) + { + Ok(result) => result, + Err(e) => { + let error = serde_json::json!({ + "error": true, + "message": format!("Failed to lift to attestation: {}", e), + }); + serde_json::to_string_pretty(&error).unwrap_or_else(|e| format!("Error: {}", e)) + } + } + } + #[cfg(not(feature = "attestation"))] + { + let _ = params; + serde_json::json!({ + "error": true, + "message": "Attestation feature not available. Rebuild with --features attestation." + }) + .to_string() + } + } + /// Export a signed attestation as a DSSE (Dead Simple Signing Envelope) for + /// in-toto/SLSA compatibility. + #[tool( + name = "jacs_attest_export_dsse", + description = "Export an attestation as a DSSE envelope for in-toto/SLSA compatibility." + )] + pub async fn jacs_attest_export_dsse( + &self, + Parameters(params): Parameters, + ) -> String { + #[cfg(feature = "attestation")] + { + match self.agent.export_attestation_dsse(¶ms.attestation_json) { + Ok(result) => result, + Err(e) => { + let error = serde_json::json!({ + "error": true, + "message": format!("Failed to export DSSE envelope: {}", e), + }); + serde_json::to_string_pretty(&error).unwrap_or_else(|e| format!("Error: {}", e)) + } + } + } + #[cfg(not(feature = "attestation"))] + { + let _ = params; + serde_json::json!({ + "error": true, + "message": "Attestation feature not available. Rebuild with --features attestation." + }) + .to_string() + } + } } // Implement the tool handler for the server #[tool_handler(router = self.tool_router)] -impl ServerHandler for HaiMcpServer { +impl ServerHandler for JacsMcpServer { fn get_info(&self) -> ServerInfo { ServerInfo { protocol_version: Default::default(), @@ -3520,15 +4224,14 @@ impl ServerHandler for HaiMcpServer { }, server_info: Implementation { name: "jacs-mcp".to_string(), - title: Some("JACS MCP Server with HAI Integration".to_string()), + title: Some("JACS MCP Server".to_string()), version: env!("CARGO_PKG_VERSION").to_string(), icons: None, - website_url: Some("https://hai.ai".to_string()), + website_url: Some("https://humanassisted.github.io/JACS/".to_string()), }, instructions: Some( "This MCP server provides data provenance and cryptographic signing for \ - agent state files and agent-to-agent messaging, plus optional HAI.ai \ - integration for key distribution and attestation. \ + agent state files and agent-to-agent messaging. \ \ Agent state tools: jacs_sign_state (sign files), jacs_verify_state \ (verify integrity), jacs_load_state (load with verification), \ @@ -3543,113 +4246,42 @@ impl ServerHandler for HaiMcpServer { Agent management: jacs_create_agent (create new agent with keys), \ jacs_reencrypt_key (rotate private key password). \ \ - Security: jacs_audit (read-only security audit and health checks). \ + A2A artifacts: jacs_wrap_a2a_artifact (sign artifact with provenance), \ + jacs_verify_a2a_artifact (verify wrapped artifact), \ + jacs_assess_a2a_agent (assess remote agent trust level). \ + \ + A2A discovery: jacs_export_agent_card (export Agent Card), \ + jacs_generate_well_known (generate .well-known documents), \ + jacs_export_agent (export full agent JSON). \ \ - HAI tools: fetch_agent_key (get public keys), register_agent (register \ - with HAI), verify_agent (check attestation 0-3), check_agent_status \ - (registration info), unregister_agent (remove registration)." + Trust store: jacs_trust_agent (add agent to trust store), \ + jacs_untrust_agent (remove from trust store, requires JACS_MCP_ALLOW_UNTRUST=true), \ + jacs_list_trusted_agents (list all trusted agent IDs), \ + jacs_is_trusted (check if agent is trusted), \ + jacs_get_trusted_agent (get trusted agent JSON). \ + \ + Attestation: jacs_attest_create (create signed attestation with claims), \ + jacs_attest_verify (verify attestation, optionally with evidence checks), \ + jacs_attest_lift (lift signed document into attestation), \ + jacs_attest_export_dsse (export attestation as DSSE envelope). \ + \ + Security: jacs_audit (read-only security audit and health checks)." .to_string(), ), } } } -// ============================================================================= -// Base64 Encoding Helper -// ============================================================================= - -fn base64_encode(data: &[u8]) -> String { - // Simple base64 encoding using the standard alphabet - const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - - let mut result = String::new(); - let mut i = 0; - - while i < data.len() { - let b0 = data[i] as usize; - let b1 = if i + 1 < data.len() { - data[i + 1] as usize - } else { - 0 - }; - let b2 = if i + 2 < data.len() { - data[i + 2] as usize - } else { - 0 - }; - - result.push(ALPHABET[b0 >> 2] as char); - result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char); - - if i + 1 < data.len() { - result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char); - } else { - result.push('='); - } - - if i + 2 < data.len() { - result.push(ALPHABET[b2 & 0x3f] as char); - } else { - result.push('='); - } - - i += 3; - } - - result -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_fetch_agent_key_params_schema() { - let schema = schemars::schema_for!(FetchAgentKeyParams); - let json = serde_json::to_string_pretty(&schema).unwrap(); - assert!(json.contains("agent_id")); - assert!(json.contains("version")); - } - - #[test] - fn test_register_agent_params_schema() { - let schema = schemars::schema_for!(RegisterAgentParams); - let json = serde_json::to_string_pretty(&schema).unwrap(); - assert!(json.contains("preview")); - } - - #[test] - fn test_verify_agent_params_schema() { - let schema = schemars::schema_for!(VerifyAgentParams); - let json = serde_json::to_string_pretty(&schema).unwrap(); - assert!(json.contains("agent_id")); - } - - #[test] - fn test_check_agent_status_params_schema() { - let schema = schemars::schema_for!(CheckAgentStatusParams); - let json = serde_json::to_string_pretty(&schema).unwrap(); - assert!(json.contains("agent_id")); - } - - #[test] - fn test_unregister_agent_params_schema() { - let schema = schemars::schema_for!(UnregisterAgentParams); - let json = serde_json::to_string_pretty(&schema).unwrap(); - assert!(json.contains("preview")); - } - #[test] fn test_tools_list() { - let tools = HaiMcpServer::tools(); - assert_eq!(tools.len(), 23, "HaiMcpServer should expose 23 tools"); + let tools = JacsMcpServer::tools(); + assert_eq!(tools.len(), 33, "JacsMcpServer should expose 33 tools"); let names: Vec<&str> = tools.iter().map(|t| &*t.name).collect(); - assert!(names.contains(&"fetch_agent_key")); - assert!(names.contains(&"register_agent")); - assert!(names.contains(&"verify_agent")); - assert!(names.contains(&"check_agent_status")); - assert!(names.contains(&"unregister_agent")); assert!(names.contains(&"jacs_sign_state")); assert!(names.contains(&"jacs_verify_state")); assert!(names.contains(&"jacs_load_state")); @@ -3668,6 +4300,25 @@ mod tests { assert!(names.contains(&"jacs_check_agreement")); assert!(names.contains(&"jacs_sign_document")); assert!(names.contains(&"jacs_verify_document")); + // A2A artifact tools + assert!(names.contains(&"jacs_wrap_a2a_artifact")); + assert!(names.contains(&"jacs_verify_a2a_artifact")); + assert!(names.contains(&"jacs_assess_a2a_agent")); + // Agent Card & well-known tools + assert!(names.contains(&"jacs_export_agent_card")); + assert!(names.contains(&"jacs_generate_well_known")); + assert!(names.contains(&"jacs_export_agent")); + // Trust store tools + assert!(names.contains(&"jacs_trust_agent")); + assert!(names.contains(&"jacs_untrust_agent")); + assert!(names.contains(&"jacs_list_trusted_agents")); + assert!(names.contains(&"jacs_is_trusted")); + assert!(names.contains(&"jacs_get_trusted_agent")); + // Attestation tools + assert!(names.contains(&"jacs_attest_create")); + assert!(names.contains(&"jacs_attest_verify")); + assert!(names.contains(&"jacs_attest_lift")); + assert!(names.contains(&"jacs_attest_export_dsse")); } #[test] @@ -3715,9 +4366,52 @@ mod tests { let schema = schemars::schema_for!(UpdateStateParams); let json = serde_json::to_string_pretty(&schema).unwrap(); assert!(json.contains("file_path")); + assert!(json.contains("jacs_id")); assert!(json.contains("new_content")); } + fn make_test_server() -> JacsMcpServer { + JacsMcpServer::new(AgentWrapper::new()) + } + + #[test] + fn test_verify_state_rejects_file_path_only() { + let server = make_test_server(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let response = rt.block_on(server.jacs_verify_state(Parameters(VerifyStateParams { + file_path: Some("state.json".to_string()), + jacs_id: None, + }))); + assert!(response.contains("FILESYSTEM_ACCESS_DISABLED")); + assert!(response.contains("file_path-based verification is disabled")); + } + + #[test] + fn test_load_state_rejects_file_path_only() { + let server = make_test_server(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let response = rt.block_on(server.jacs_load_state(Parameters(LoadStateParams { + file_path: Some("state.json".to_string()), + jacs_id: None, + require_verified: Some(true), + }))); + assert!(response.contains("FILESYSTEM_ACCESS_DISABLED")); + assert!(response.contains("file_path-based loading is disabled")); + } + + #[test] + fn test_update_state_requires_jacs_id() { + let server = make_test_server(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let response = rt.block_on(server.jacs_update_state(Parameters(UpdateStateParams { + file_path: "state.json".to_string(), + jacs_id: None, + new_content: Some("{\"k\":\"v\"}".to_string()), + }))); + assert!(response.contains("FILESYSTEM_ACCESS_DISABLED")); + assert!(response.contains("file_path-based updates are disabled")); + } + #[test] fn test_list_state_params_schema() { let schema = schemars::schema_for!(ListStateParams); @@ -3771,23 +4465,28 @@ mod tests { } #[test] - fn test_is_registration_allowed_default() { - // When env var is not set, should return false - // SAFETY: This test runs in isolation and modifies test-specific env vars - unsafe { - std::env::remove_var("JACS_MCP_ALLOW_REGISTRATION"); - } - assert!(!is_registration_allowed()); + fn test_extract_verify_a2a_valid_true() { + assert!(extract_verify_a2a_valid(r#"{"valid":true}"#)); + } + + #[test] + fn test_extract_verify_a2a_valid_missing_defaults_false() { + assert!(!extract_verify_a2a_valid(r#"{"status":"ok"}"#)); + } + + #[test] + fn test_extract_verify_a2a_valid_invalid_json_defaults_false() { + assert!(!extract_verify_a2a_valid("not-json")); } #[test] - fn test_is_unregistration_allowed_default() { + fn test_is_registration_allowed_default() { // When env var is not set, should return false // SAFETY: This test runs in isolation and modifies test-specific env vars unsafe { - std::env::remove_var("JACS_MCP_ALLOW_UNREGISTRATION"); + std::env::remove_var("JACS_MCP_ALLOW_REGISTRATION"); } - assert!(!is_unregistration_allowed()); + assert!(!is_registration_allowed()); } #[test] @@ -3859,7 +4558,7 @@ mod tests { #[test] fn test_tool_list_includes_agreement_tools() { // Verify the 3 new agreement tools are in the tool list - let tools = HaiMcpServer::tools(); + let tools = JacsMcpServer::tools(); let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); assert!( names.contains(&"jacs_create_agreement"), @@ -3874,4 +4573,136 @@ mod tests { "Missing jacs_check_agreement" ); } + + // ========================================================================= + // Security: Path traversal prevention in sign_state / adopt_state + // ========================================================================= + + #[test] + fn test_sign_state_rejects_absolute_path() { + let server = make_test_server(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let response = rt.block_on(server.jacs_sign_state(Parameters(SignStateParams { + file_path: "/etc/passwd".to_string(), + state_type: "memory".to_string(), + name: "traversal-test".to_string(), + description: None, + framework: None, + tags: None, + embed: None, + }))); + assert!( + response.contains("PATH_TRAVERSAL_BLOCKED"), + "Expected PATH_TRAVERSAL_BLOCKED in: {}", + response + ); + assert!( + response.contains("\"success\": false"), + "Expected success: false in: {}", + response + ); + } + + #[test] + fn test_sign_state_rejects_parent_traversal() { + let server = make_test_server(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let response = rt.block_on(server.jacs_sign_state(Parameters(SignStateParams { + file_path: "data/../../../etc/shadow".to_string(), + state_type: "hook".to_string(), + name: "traversal-test".to_string(), + description: None, + framework: None, + tags: None, + embed: Some(true), + }))); + assert!( + response.contains("PATH_TRAVERSAL_BLOCKED"), + "Expected PATH_TRAVERSAL_BLOCKED in: {}", + response + ); + } + + #[test] + fn test_sign_state_rejects_windows_drive_path() { + let server = make_test_server(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let response = rt.block_on(server.jacs_sign_state(Parameters(SignStateParams { + file_path: "C:\\Windows\\System32\\drivers\\etc\\hosts".to_string(), + state_type: "config".to_string(), + name: "traversal-test".to_string(), + description: None, + framework: None, + tags: None, + embed: None, + }))); + assert!( + response.contains("PATH_TRAVERSAL_BLOCKED"), + "Expected PATH_TRAVERSAL_BLOCKED in: {}", + response + ); + } + + #[test] + fn test_adopt_state_rejects_absolute_path() { + let server = make_test_server(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let response = rt.block_on(server.jacs_adopt_state(Parameters(AdoptStateParams { + file_path: "/etc/shadow".to_string(), + state_type: "skill".to_string(), + name: "traversal-test".to_string(), + source_url: None, + description: None, + }))); + assert!( + response.contains("PATH_TRAVERSAL_BLOCKED"), + "Expected PATH_TRAVERSAL_BLOCKED in: {}", + response + ); + assert!( + response.contains("\"success\": false"), + "Expected success: false in: {}", + response + ); + } + + #[test] + fn test_adopt_state_rejects_parent_traversal() { + let server = make_test_server(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let response = rt.block_on(server.jacs_adopt_state(Parameters(AdoptStateParams { + file_path: "skills/../../etc/passwd".to_string(), + state_type: "skill".to_string(), + name: "traversal-test".to_string(), + source_url: Some("https://example.com".to_string()), + description: None, + }))); + assert!( + response.contains("PATH_TRAVERSAL_BLOCKED"), + "Expected PATH_TRAVERSAL_BLOCKED in: {}", + response + ); + } + + #[test] + fn test_sign_state_allows_safe_relative_path() { + let server = make_test_server(); + let rt = tokio::runtime::Runtime::new().unwrap(); + // This should NOT be blocked by path validation (it will fail later + // because the file doesn't exist, but NOT with PATH_TRAVERSAL_BLOCKED) + let response = rt.block_on(server.jacs_sign_state(Parameters(SignStateParams { + file_path: "data/my-state.json".to_string(), + state_type: "memory".to_string(), + name: "safe-path-test".to_string(), + description: None, + framework: None, + tags: None, + embed: None, + }))); + assert!( + !response.contains("PATH_TRAVERSAL_BLOCKED"), + "Safe relative path should not be blocked: {}", + response + ); + } } diff --git a/jacs-mcp/src/lib.rs b/jacs-mcp/src/lib.rs new file mode 100644 index 000000000..e3513dea9 --- /dev/null +++ b/jacs-mcp/src/lib.rs @@ -0,0 +1,16 @@ +pub mod config; +#[cfg(feature = "mcp")] +pub mod contract; +pub mod jacs_tools; +#[cfg(feature = "mcp")] +pub mod server; + +pub use crate::config::{load_agent_from_config_env, load_agent_from_config_path}; +#[cfg(feature = "mcp")] +pub use crate::contract::{ + JacsMcpContractSnapshot, JacsMcpServerMetadata, JacsMcpToolContract, + canonical_contract_snapshot, +}; +pub use crate::jacs_tools::JacsMcpServer; +#[cfg(feature = "mcp")] +pub use crate::server::serve_stdio; diff --git a/jacs-mcp/src/main.rs b/jacs-mcp/src/main.rs index bc0ae64e0..f50eae551 100644 --- a/jacs-mcp/src/main.rs +++ b/jacs-mcp/src/main.rs @@ -1,82 +1,5 @@ -mod hai_tools; - -#[cfg(feature = "mcp")] -use hai_tools::HaiMcpServer; -#[cfg(feature = "mcp")] -use jacs_binding_core::AgentWrapper; -#[cfg(feature = "mcp")] -use rmcp::{ServiceExt, transport::stdio}; - -/// Allowed HAI endpoint hostnames for security. -/// This prevents request redirection attacks via malicious HAI_ENDPOINT values. #[cfg(feature = "mcp")] -const ALLOWED_HAI_HOSTS: &[&str] = &[ - "api.hai.ai", - "dev.api.hai.ai", - "staging.api.hai.ai", - "localhost", - "127.0.0.1", -]; - -/// Validate that the HAI endpoint is an allowed hostname. -/// Returns the validated endpoint URL or an error. -#[cfg(feature = "mcp")] -fn validate_hai_endpoint(endpoint: &str) -> anyhow::Result { - use url::Url; - - // Parse the URL - let url = Url::parse(endpoint).map_err(|e| { - anyhow::anyhow!( - "Invalid HAI_ENDPOINT URL '{}': {}. Expected format: https://api.hai.ai", - endpoint, - e - ) - })?; - - // Check the scheme - let scheme = url.scheme(); - if scheme != "https" && scheme != "http" { - return Err(anyhow::anyhow!( - "Invalid HAI_ENDPOINT scheme '{}'. Only 'http' and 'https' are allowed.", - scheme - )); - } - - // Warn about http in production - if scheme == "http" { - let host = url.host_str().unwrap_or(""); - if host != "localhost" && host != "127.0.0.1" { - tracing::warn!( - "Using insecure HTTP for HAI endpoint '{}'. Consider using HTTPS for production.", - endpoint - ); - } - } - - // Check the host against allowlist - let host = url - .host_str() - .ok_or_else(|| anyhow::anyhow!("HAI_ENDPOINT '{}' has no host component.", endpoint))?; - - // Check if host is in allowlist - let is_allowed = ALLOWED_HAI_HOSTS.iter().any(|allowed| *allowed == host); - - // Also allow any subdomain of hai.ai - let is_hai_subdomain = host.ends_with(".hai.ai"); - - if !is_allowed && !is_hai_subdomain { - return Err(anyhow::anyhow!( - "HAI_ENDPOINT host '{}' is not in the allowed list. \ - Allowed hosts: {:?}, or any subdomain of hai.ai. \ - If this is a legitimate HAI endpoint, please report this issue.", - host, - ALLOWED_HAI_HOSTS - )); - } - - tracing::debug!("HAI endpoint '{}' validated successfully", endpoint); - Ok(endpoint.to_string()) -} +use jacs_mcp::{JacsMcpServer, load_agent_from_config_env, serve_stdio}; #[cfg(feature = "mcp")] #[tokio::main] @@ -89,95 +12,16 @@ async fn main() -> anyhow::Result<()> { tracing::info!("starting jacs-mcp (MCP mode)"); - // Load the agent identity from config - let agent = load_agent_from_config()?; - - // Get HAI endpoint from environment or use default - let hai_endpoint_raw = - std::env::var("HAI_ENDPOINT").unwrap_or_else(|_| "https://api.hai.ai".to_string()); + let agent = load_agent_from_config_env()?; + let server = JacsMcpServer::new(agent); - // Validate the endpoint against allowlist - let hai_endpoint = validate_hai_endpoint(&hai_endpoint_raw)?; - - // Get optional API key - let api_key = std::env::var("HAI_API_KEY").ok(); - - tracing::info!( - hai_endpoint = %hai_endpoint, - has_api_key = api_key.is_some(), - "HAI configuration" - ); - - // Create the MCP server with HAI tools - let server = HaiMcpServer::new(agent, &hai_endpoint, api_key.as_deref()); - - tracing::info!("HAI MCP server ready, waiting for client connection on stdio"); - - // Serve over stdin/stdout - let (stdin, stdout) = stdio(); - let running = server.serve((stdin, stdout)).await?; - - tracing::info!("MCP client connected, serving requests"); - - // Wait for the service to complete - running.waiting().await?; + tracing::info!("JACS MCP server ready, waiting for client connection on stdio"); + serve_stdio(server).await?; tracing::info!("MCP server shutting down"); Ok(()) } -#[cfg(feature = "mcp")] -fn load_agent_from_config() -> anyhow::Result { - let agent_wrapper = AgentWrapper::new(); - - // JACS_CONFIG is required for the MCP server - let cfg_path = std::env::var("JACS_CONFIG").map_err(|_| { - anyhow::anyhow!( - "JACS_CONFIG environment variable is not set. \n\ - \n\ - To use the JACS MCP server, you need to:\n\ - 1. Create a jacs.config.json file with your agent configuration\n\ - 2. Set JACS_CONFIG=/path/to/jacs.config.json\n\ - \n\ - See the README for a Quick Start guide on creating an agent." - ) - })?; - - tracing::info!(config_path = %cfg_path, "Loading agent from config file"); - - // Verify the config file exists before trying to read it - if !std::path::Path::new(&cfg_path).exists() { - return Err(anyhow::anyhow!( - "Config file not found at '{}'. \n\ - \n\ - Please create a jacs.config.json file or update JACS_CONFIG \ - to point to an existing configuration file.", - cfg_path - )); - } - - // Set up environment from config - let cfg_str = std::fs::read_to_string(&cfg_path).map_err(|e| { - anyhow::anyhow!( - "Failed to read config file '{}': {}. Check file permissions.", - cfg_path, - e - ) - })?; - - #[allow(deprecated)] - let _ = jacs::config::set_env_vars(true, Some(&cfg_str), false) - .map_err(|e| anyhow::anyhow!("Invalid config file '{}': {}", cfg_path, e))?; - - // Load the agent using the full config file path - agent_wrapper - .load(cfg_path.clone()) - .map_err(|e| anyhow::anyhow!("Failed to load agent: {}", e))?; - - tracing::info!("Agent loaded successfully from config"); - Ok(agent_wrapper) -} - #[cfg(not(feature = "mcp"))] fn main() { eprintln!("jacs-mcp built without mcp feature; enable with --features mcp"); diff --git a/jacs-mcp/src/server.rs b/jacs-mcp/src/server.rs new file mode 100644 index 000000000..bfce568be --- /dev/null +++ b/jacs-mcp/src/server.rs @@ -0,0 +1,13 @@ +#[cfg(feature = "mcp")] +use crate::JacsMcpServer; + +#[cfg(feature = "mcp")] +use rmcp::{ServiceExt, transport::stdio}; + +#[cfg(feature = "mcp")] +pub async fn serve_stdio(server: JacsMcpServer) -> anyhow::Result<()> { + let (stdin, stdout) = stdio(); + let running = server.serve((stdin, stdout)).await?; + running.waiting().await?; + Ok(()) +} diff --git a/jacs-mcp/tests/config_loading.rs b/jacs-mcp/tests/config_loading.rs new file mode 100644 index 000000000..35dd14304 --- /dev/null +++ b/jacs-mcp/tests/config_loading.rs @@ -0,0 +1,51 @@ +mod support; + +use std::path::PathBuf; + +use jacs::storage::jenv::get_env_var; +use support::{ENV_LOCK, ScopedEnvVar, TEST_PASSWORD, cleanup_workspace, prepare_temp_workspace}; + +#[test] +fn config_path_loader_resolves_relative_directories_from_config_location() -> anyhow::Result<()> { + let _env_guard = ENV_LOCK.lock().unwrap(); + let (config_path, workspace) = prepare_temp_workspace(); + let _password = ScopedEnvVar::set("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD); + + let agent = jacs_mcp::load_agent_from_config_path(&config_path)?; + let _ = agent.get_agent_json()?; + + assert_eq!( + PathBuf::from(get_env_var("JACS_DATA_DIRECTORY", false)?.expect("data dir override")), + workspace.join("jacs_data") + ); + assert_eq!( + PathBuf::from(get_env_var("JACS_KEY_DIRECTORY", false)?.expect("key dir override")), + workspace.join("jacs_keys") + ); + + cleanup_workspace(&workspace); + Ok(()) +} + +#[test] +fn env_loader_resolves_relative_directories_from_jacs_config() -> anyhow::Result<()> { + let _env_guard = ENV_LOCK.lock().unwrap(); + let (config_path, workspace) = prepare_temp_workspace(); + let _password = ScopedEnvVar::set("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD); + let _config = ScopedEnvVar::set("JACS_CONFIG", &config_path); + + let agent = jacs_mcp::load_agent_from_config_env()?; + let _ = agent.get_agent_json()?; + + assert_eq!( + PathBuf::from(get_env_var("JACS_DATA_DIRECTORY", false)?.expect("data dir override")), + workspace.join("jacs_data") + ); + assert_eq!( + PathBuf::from(get_env_var("JACS_KEY_DIRECTORY", false)?.expect("key dir override")), + workspace.join("jacs_keys") + ); + + cleanup_workspace(&workspace); + Ok(()) +} diff --git a/jacs-mcp/tests/contract_snapshot.rs b/jacs-mcp/tests/contract_snapshot.rs new file mode 100644 index 000000000..8a9aa3ce2 --- /dev/null +++ b/jacs-mcp/tests/contract_snapshot.rs @@ -0,0 +1,16 @@ +#![cfg(feature = "mcp")] + +use jacs_mcp::{JacsMcpContractSnapshot, canonical_contract_snapshot}; + +#[test] +fn canonical_contract_snapshot_matches_checked_in_artifact() { + let actual = canonical_contract_snapshot(); + let expected: JacsMcpContractSnapshot = + serde_json::from_str(include_str!("../contract/jacs-mcp-contract.json")) + .expect("checked-in canonical contract should parse"); + + assert_eq!( + actual, expected, + "canonical Rust MCP contract changed; regenerate jacs-mcp/contract/jacs-mcp-contract.json" + ); +} diff --git a/jacs-mcp/tests/embedding_smoke.rs b/jacs-mcp/tests/embedding_smoke.rs new file mode 100644 index 000000000..7f84fa40c --- /dev/null +++ b/jacs-mcp/tests/embedding_smoke.rs @@ -0,0 +1,25 @@ +#![cfg(feature = "mcp")] + +mod support; + +use rmcp::ServerHandler; +use support::{ENV_LOCK, ScopedEnvVar, TEST_PASSWORD, cleanup_workspace, prepare_temp_workspace}; + +#[test] +fn embedders_can_construct_server_in_process() -> anyhow::Result<()> { + let _env_guard = ENV_LOCK.lock().unwrap(); + let (config_path, workspace) = prepare_temp_workspace(); + let _password = ScopedEnvVar::set("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD); + + let agent = jacs_mcp::load_agent_from_config_path(&config_path)?; + let server = jacs_mcp::JacsMcpServer::new(agent); + let info = server.get_info(); + let tools = jacs_mcp::JacsMcpServer::tools(); + + assert_eq!(info.server_info.name, "jacs-mcp"); + assert!(tools.iter().any(|tool| tool.name.as_ref() == "jacs_list_state")); + assert!(tools.iter().any(|tool| tool.name.as_ref() == "jacs_attest_create")); + + cleanup_workspace(&workspace); + Ok(()) +} diff --git a/jacs-mcp/tests/integration.rs b/jacs-mcp/tests/integration.rs index fb32c52c6..d2d410817 100644 --- a/jacs-mcp/tests/integration.rs +++ b/jacs-mcp/tests/integration.rs @@ -1,117 +1,1102 @@ use std::fs; -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// The known agent ID that exists in jacs/tests/fixtures/agent/ -const AGENT_ID: &str = "ddf35096-d212-4ca9-a299-feda597d5525:b57d480f-b8d4-46e7-9d7c-942f2b132717"; - -/// Password used to encrypt test fixture keys in jacs/tests/fixtures/keys/ -/// Note: intentional typo "secretpassord" matches TEST_PASSWORD_LEGACY in jacs/tests/utils.rs -const TEST_PASSWORD: &str = "secretpassord"; - -fn jacs_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .to_path_buf() -} - -/// Create a temp workspace with agent JSON, keys, and config. -/// Returns (config_path, base_dir). Config uses relative paths so the -/// binary CWD must be set to base_dir. -fn prepare_temp_workspace() -> (PathBuf, PathBuf) { - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(); - let base = std::env::temp_dir().join(format!("jacs_mcp_ws_{}", ts)); - let data_dir = base.join("jacs_data"); - let keys_dir = base.join("jacs_keys"); - fs::create_dir_all(data_dir.join("agent")).expect("mkdir data/agent"); - fs::create_dir_all(&keys_dir).expect("mkdir keys"); - - let root = jacs_root(); - - // Copy agent JSON from the standard test fixtures - let agent_src = root.join(format!("jacs/tests/fixtures/agent/{}.json", AGENT_ID)); - let agent_dst = data_dir.join(format!("agent/{}.json", AGENT_ID)); - fs::copy(&agent_src, &agent_dst).unwrap_or_else(|e| { - panic!( - "copy agent fixture from {:?} to {:?}: {}", - agent_src, agent_dst, e +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::LazyLock; +use std::time::Duration; + +use rmcp::{ + RoleClient, ServiceExt, + model::CallToolRequestParam, + service::RunningService, + transport::{ConfigureCommandExt, TokioChildProcess}, +}; + +mod support; + +use support::{ + TEST_PASSWORD, assert_server_reaches_initialized_request, prepare_temp_workspace, + run_server_with_fixture, +}; + +static STDIO_TEST_LOCK: LazyLock> = + LazyLock::new(|| tokio::sync::Mutex::new(())); +const MCP_INIT_TIMEOUT: Duration = Duration::from_secs(30); +const MCP_LIST_TIMEOUT: Duration = Duration::from_secs(30); +const MCP_CALL_TIMEOUT: Duration = Duration::from_secs(30); + +type McpClient = RunningService; + +struct RmcpSession { + client: McpClient, + base: PathBuf, +} + +impl RmcpSession { + async fn spawn(extra_env: &[(&str, &str)]) -> anyhow::Result { + let (config, base) = prepare_temp_workspace(); + let bin_path = assert_cmd::cargo::cargo_bin!("jacs-mcp"); + let command = tokio::process::Command::new(&bin_path).configure(|cmd| { + cmd.current_dir(&base) + .env("JACS_CONFIG", &config) + .env("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD) + .env("JACS_MAX_IAT_SKEW_SECONDS", "0") + .env("RUST_LOG", "warn"); + + for (k, v) in extra_env { + cmd.env(k, v); + } + }); + let (transport, _stderr) = TokioChildProcess::builder(command) + .stderr(Stdio::null()) + .spawn()?; + let client = tokio::time::timeout(MCP_INIT_TIMEOUT, ().serve(transport)) + .await + .map_err(|_| anyhow::anyhow!("timed out initializing jacs-mcp over stdio"))??; + + Ok(Self { client, base }) + } + + fn workspace(&self) -> &Path { + &self.base + } + + async fn list_tools(&self) -> anyhow::Result> { + Ok( + tokio::time::timeout(MCP_LIST_TIMEOUT, self.client.list_all_tools()) + .await + .map_err(|_| anyhow::anyhow!("timed out listing MCP tools"))?? + .into_iter() + .map(|tool| tool.name.to_string()) + .collect(), ) - }); + } - // Copy RSA-PSS keys (known to work with TEST_PASSWORD) - let keys_fixture = root.join("jacs/tests/fixtures/keys"); - fs::copy( - keys_fixture.join("agent-one.private.pem.enc"), - keys_dir.join("agent-one.private.pem.enc"), - ) - .expect("copy private key"); - fs::copy( - keys_fixture.join("agent-one.public.pem"), - keys_dir.join("agent-one.public.pem"), - ) - .expect("copy public key"); - - // Write config with relative paths - let config_json = serde_json::json!({ - "jacs_agent_id_and_version": AGENT_ID, - "jacs_agent_key_algorithm": "RSA-PSS", - "jacs_agent_private_key_filename": "agent-one.private.pem.enc", - "jacs_agent_public_key_filename": "agent-one.public.pem", - "jacs_data_directory": "jacs_data", - "jacs_default_storage": "fs", - "jacs_key_directory": "jacs_keys", - "jacs_use_security": "false" - }); - let cfg_path = base.join("jacs.config.json"); - fs::write( - &cfg_path, - serde_json::to_string_pretty(&config_json).unwrap(), - ) - .expect("write config"); + async fn call_tool( + &self, + name: &str, + arguments: serde_json::Value, + ) -> anyhow::Result { + let response = tokio::time::timeout( + MCP_CALL_TIMEOUT, + self.client.call_tool(CallToolRequestParam { + name: name.to_string().into(), + arguments: arguments.as_object().cloned(), + }), + ) + .await + .map_err(|_| anyhow::anyhow!("timed out calling MCP tool '{}'", name))??; + parse_tool_result(name, response) + } +} - (cfg_path, base) +fn parse_tool_result( + name: &str, + response: rmcp::model::CallToolResult, +) -> anyhow::Result { + let raw_response = + serde_json::to_string(&response).unwrap_or_else(|_| "".into()); + assert!( + !response.is_error.unwrap_or(false), + "tool '{}' returned MCP error: {}", + name, + raw_response + ); + let text = response + .content + .iter() + .find_map(|item| item.as_text().map(|text| text.text.clone())) + .unwrap_or_else(|| panic!("tool '{}' returned no text content: {}", name, raw_response)); + Ok(serde_json::from_str(&text).unwrap_or_else(|_| serde_json::json!({ "_raw": text }))) +} + +fn parse_json_string_field( + value: &serde_json::Value, + field: &str, +) -> anyhow::Result { + let raw = value[field] + .as_str() + .ok_or_else(|| anyhow::anyhow!("expected '{}' string field in {}", field, value))?; + Ok(serde_json::from_str(raw)?) +} + +impl Drop for RmcpSession { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.base); + } } #[test] fn starts_server_with_agent_env() { + let (output, base) = run_server_with_fixture(&[]); + assert_server_reaches_initialized_request(&output, "default log environment"); + let _ = fs::remove_dir_all(&base); +} + +#[tokio::test] +async fn mcp_state_round_trip_over_stdio() -> anyhow::Result<()> { + let _guard = STDIO_TEST_LOCK.lock().await; + let session = RmcpSession::spawn(&[]).await?; + let server_info = session + .client + .peer_info() + .expect("rmcp client should initialize the server"); + assert_eq!(server_info.server_info.name, "jacs-mcp"); + + let tools = session.list_tools().await?; + assert!( + tools.iter().any(|tool| tool == "jacs_list_state"), + "expected jacs_list_state in tool list: {:?}", + tools + ); + assert!( + tools.iter().any(|tool| tool == "jacs_attest_create"), + "expected attestation tools in default build: {:?}", + tools + ); + + let state_dir = session.workspace().join("data"); + fs::create_dir_all(&state_dir).expect("mkdir state dir"); + let state_path = state_dir.join("memory.json"); + fs::write(&state_path, "{\"topic\":\"mcp probe\",\"value\":1}\n").expect("write state file"); + + let signed = session + .call_tool( + "jacs_sign_state", + serde_json::json!({ + "file_path": "data/memory.json", + "state_type": "memory", + "name": "Probe Memory", + "description": "Created by MCP integration test", + "embed": true + }), + ) + .await?; + let doc_id = signed["jacs_document_id"] + .as_str() + .expect("sign_state jacs_document_id"); + assert_ne!(doc_id, "unknown"); + assert!( + doc_id.contains(':'), + "expected versioned doc id: {}", + doc_id + ); + + let verified = session + .call_tool( + "jacs_verify_state", + serde_json::json!({ "jacs_id": doc_id }), + ) + .await?; + assert_eq!( + verified["success"], true, + "verify_state failed: {}", + verified + ); + assert_eq!( + verified["signature_valid"], true, + "verify_state signature invalid: {}", + verified + ); + + let loaded = session + .call_tool( + "jacs_load_state", + serde_json::json!({ "jacs_id": doc_id, "require_verified": true }), + ) + .await?; + assert_eq!(loaded["success"], true, "load_state failed: {}", loaded); + assert!( + loaded["content"] + .as_str() + .unwrap_or_default() + .contains("\"value\":1"), + "expected original embedded content: {}", + loaded + ); + + let updated = session + .call_tool( + "jacs_update_state", + serde_json::json!({ + "file_path": "data/memory.json", + "jacs_id": doc_id, + "new_content": "{\"topic\":\"mcp probe\",\"value\":2}" + }), + ) + .await?; + assert_eq!(updated["success"], true, "update_state failed: {}", updated); + let updated_id = updated["jacs_document_version_id"] + .as_str() + .expect("update_state jacs_document_version_id"); + assert_ne!(updated_id, doc_id, "update should create new version"); + assert!(updated_id.contains(':'), "expected updated versioned id"); + + let reloaded = session + .call_tool( + "jacs_load_state", + serde_json::json!({ "jacs_id": updated_id, "require_verified": true }), + ) + .await?; + assert_eq!( + reloaded["success"], true, + "reload updated state failed: {}", + reloaded + ); + assert!( + reloaded["content"] + .as_str() + .unwrap_or_default() + .contains("\"value\":2"), + "expected updated embedded content: {}", + reloaded + ); + + let listed = session + .call_tool("jacs_list_state", serde_json::json!({})) + .await?; + let documents = listed["documents"] + .as_array() + .expect("list_state documents"); + assert!( + documents + .iter() + .any(|doc| doc["jacs_document_id"] == doc_id), + "list_state missing original document: {}", + listed + ); + assert!( + documents + .iter() + .any(|doc| doc["jacs_document_id"] == updated_id), + "list_state missing updated document: {}", + listed + ); + + session.client.cancellation_token().cancel(); + Ok(()) +} + +#[tokio::test] +async fn mcp_message_and_attestation_round_trip_over_stdio() -> anyhow::Result<()> { + let _guard = STDIO_TEST_LOCK.lock().await; + let session = RmcpSession::spawn(&[]).await?; + + let signed_doc = session + .call_tool( + "jacs_sign_document", + serde_json::json!({ "content": "{\"hello\":\"world\"}" }), + ) + .await?; + let signed_doc_json = signed_doc["signed_document"] + .as_str() + .expect("signed_document payload"); + let signed_doc_id = signed_doc["jacs_document_id"] + .as_str() + .expect("signed_document id"); + assert!( + signed_doc_id.contains(':'), + "expected canonical signed document id" + ); + + let verify_doc = session + .call_tool( + "jacs_verify_document", + serde_json::json!({ "document": signed_doc_json }), + ) + .await?; + assert_eq!( + verify_doc["success"], true, + "verify_document failed: {}", + verify_doc + ); + assert_eq!( + verify_doc["valid"], true, + "verify_document invalid: {}", + verify_doc + ); + + let recipient_id = "550e8400-e29b-41d4-a716-446655440000"; + let sent = session + .call_tool( + "jacs_message_send", + serde_json::json!({ + "recipient_agent_id": recipient_id, + "content": "hello over mcp" + }), + ) + .await?; + assert_eq!(sent["success"], true, "message_send failed: {}", sent); + let sent_id = sent["jacs_document_id"].as_str().expect("message_send id"); + let sent_message = sent["signed_message"] + .as_str() + .expect("message_send signed_message"); + assert!( + sent_id.contains(':'), + "expected persisted message id: {}", + sent_id + ); + let sent_value: serde_json::Value = + serde_json::from_str(sent_message).expect("parse signed message"); + assert_ne!( + sent_value["jacsMessageSenderId"] + .as_str() + .unwrap_or("unknown"), + "unknown", + "message sender should be the loaded agent" + ); + + let updated = session + .call_tool( + "jacs_message_update", + serde_json::json!({ + "jacs_id": sent_id, + "content": "updated content" + }), + ) + .await?; + assert_eq!( + updated["success"], true, + "message_update failed: {}", + updated + ); + let updated_id = updated["jacs_document_id"] + .as_str() + .expect("message_update id"); + let updated_message = updated["signed_message"] + .as_str() + .expect("message_update signed_message"); + assert!( + updated_id.contains(':'), + "expected updated message id: {}", + updated_id + ); + + let received = session + .call_tool( + "jacs_message_receive", + serde_json::json!({ "signed_message": updated_message }), + ) + .await?; + assert_eq!( + received["success"], true, + "message_receive failed: {}", + received + ); + assert_eq!( + received["signature_valid"], true, + "message signature invalid" + ); + assert_eq!(received["content"], "updated content"); + + let attestation = session + .call_tool( + "jacs_attest_create", + serde_json::json!({ + "params_json": serde_json::json!({ + "subject": { + "type": "artifact", + "id": signed_doc_id, + "digests": { "sha256": "abc123" } + }, + "claims": [{ + "name": "reviewed_by", + "value": "human", + "confidence": 0.95, + "assuranceLevel": "verified" + }] + }).to_string() + }), + ) + .await?; + assert!( + attestation.get("jacsId").and_then(|v| v.as_str()).is_some(), + "attestation create failed: {}", + attestation + ); + let attestation_id = format!( + "{}:{}", + attestation["jacsId"].as_str().expect("attestation jacsId"), + attestation["jacsVersion"] + .as_str() + .expect("attestation jacsVersion") + ); + let verified = session + .call_tool( + "jacs_attest_verify", + serde_json::json!({ "document_key": attestation_id, "full": false }), + ) + .await?; + assert_eq!( + verified["valid"], true, + "attestation verify failed: {}", + verified + ); + + session.client.cancellation_token().cancel(); + Ok(()) +} + +#[tokio::test] +async fn mcp_a2a_round_trip_over_stdio() -> anyhow::Result<()> { + let _guard = STDIO_TEST_LOCK.lock().await; + let session = RmcpSession::spawn(&[]).await?; + + let wrapped = session + .call_tool( + "jacs_wrap_a2a_artifact", + serde_json::json!({ + "artifact_json": serde_json::json!({ + "content": "hello from a2a mcp", + "kind": "note" + }) + .to_string(), + "artifact_type": "message" + }), + ) + .await?; + assert_eq!( + wrapped["success"], true, + "wrap_a2a_artifact failed: {}", + wrapped + ); + let wrapped_artifact = wrapped["wrapped_artifact"] + .as_str() + .expect("wrapped_artifact payload"); + let wrapped_value: serde_json::Value = + serde_json::from_str(wrapped_artifact).expect("parse wrapped artifact"); + assert_eq!(wrapped_value["jacsType"], "a2a-message"); + + let verified = session + .call_tool( + "jacs_verify_a2a_artifact", + serde_json::json!({ "wrapped_artifact": wrapped_artifact }), + ) + .await?; + assert_eq!( + verified["success"], true, + "verify_a2a_artifact failed: {}", + verified + ); + assert_eq!(verified["valid"], true, "wrapped artifact invalid: {}", verified); + let verification_details = parse_json_string_field(&verified, "verification_details")?; + assert_eq!(verification_details["status"], "SelfSigned"); + assert_eq!(verification_details["parentSignaturesValid"], true); + assert_eq!(verification_details["originalArtifact"]["content"], "hello from a2a mcp"); + + let card = session + .call_tool("jacs_export_agent_card", serde_json::json!({})) + .await?; + assert_eq!( + card["success"], true, + "export_agent_card failed: {}", + card + ); + let agent_card_json = card["agent_card"].as_str().expect("agent_card payload"); + + let assessment = session + .call_tool( + "jacs_assess_a2a_agent", + serde_json::json!({ + "agent_card_json": agent_card_json, + "policy": "open" + }), + ) + .await?; + assert_eq!( + assessment["success"], true, + "assess_a2a_agent failed: {}", + assessment + ); + assert_eq!(assessment["allowed"], true, "assessment rejected: {}", assessment); + assert_eq!( + assessment["policy"] + .as_str() + .unwrap_or_default() + .to_ascii_lowercase(), + "open" + ); + + session.client.cancellation_token().cancel(); + Ok(()) +} + +#[tokio::test] +async fn mcp_a2a_parent_chain_reports_invalid_parent() -> anyhow::Result<()> { + let _guard = STDIO_TEST_LOCK.lock().await; + let session = RmcpSession::spawn(&[]).await?; + + let parent = session + .call_tool( + "jacs_wrap_a2a_artifact", + serde_json::json!({ + "artifact_json": serde_json::json!({ "step": 1 }).to_string(), + "artifact_type": "task" + }), + ) + .await?; + let parent_artifact = parent["wrapped_artifact"] + .as_str() + .expect("parent wrapped artifact"); + + let valid_child = session + .call_tool( + "jacs_wrap_a2a_artifact", + serde_json::json!({ + "artifact_json": serde_json::json!({ "step": 2 }).to_string(), + "artifact_type": "task", + "parent_signatures": format!("[{}]", parent_artifact), + }), + ) + .await?; + let valid_child_artifact = valid_child["wrapped_artifact"] + .as_str() + .expect("valid child wrapped artifact"); + let valid_child_value: serde_json::Value = + serde_json::from_str(valid_child_artifact).expect("parse valid child"); + assert_eq!( + valid_child_value["jacsParentSignatures"] + .as_array() + .expect("valid child parents") + .len(), + 1 + ); + + let valid_chain = session + .call_tool( + "jacs_verify_a2a_artifact", + serde_json::json!({ "wrapped_artifact": valid_child_artifact }), + ) + .await?; + assert_eq!(valid_chain["success"], true, "valid chain failed: {}", valid_chain); + assert_eq!(valid_chain["valid"], true, "child artifact invalid: {}", valid_chain); + let valid_chain_details = parse_json_string_field(&valid_chain, "verification_details")?; + assert_eq!(valid_chain_details["parentSignaturesValid"], true); + assert_eq!( + valid_chain_details["parentVerificationResults"] + .as_array() + .expect("parent verification results") + .len(), + 1 + ); + + let mut tampered_parent_value: serde_json::Value = + serde_json::from_str(parent_artifact).expect("parse parent artifact"); + tampered_parent_value["a2aArtifact"]["step"] = serde_json::json!(999); + + let invalid_parent_child = session + .call_tool( + "jacs_wrap_a2a_artifact", + serde_json::json!({ + "artifact_json": serde_json::json!({ "step": 3 }).to_string(), + "artifact_type": "task", + "parent_signatures": serde_json::json!([tampered_parent_value]).to_string(), + }), + ) + .await?; + let invalid_parent_child_artifact = invalid_parent_child["wrapped_artifact"] + .as_str() + .expect("invalid parent child wrapped artifact"); + + let invalid_parent_verified = session + .call_tool( + "jacs_verify_a2a_artifact", + serde_json::json!({ "wrapped_artifact": invalid_parent_child_artifact }), + ) + .await?; + assert_eq!( + invalid_parent_verified["success"], true, + "verification should return details even with invalid parent: {}", + invalid_parent_verified + ); + assert_eq!( + invalid_parent_verified["valid"], true, + "child artifact should still be cryptographically valid: {}", + invalid_parent_verified + ); + let invalid_parent_details = + parse_json_string_field(&invalid_parent_verified, "verification_details")?; + assert_eq!(invalid_parent_details["parentSignaturesValid"], false); + let parent_results = invalid_parent_details["parentVerificationResults"] + .as_array() + .expect("invalid parent verification results"); + assert_eq!(parent_results.len(), 1); + assert_eq!(parent_results[0]["verified"], false); + + session.client.cancellation_token().cancel(); + Ok(()) +} + +#[tokio::test] +async fn mcp_attestation_negative_paths_and_dsse_over_stdio() -> anyhow::Result<()> { + let _guard = STDIO_TEST_LOCK.lock().await; + let session = RmcpSession::spawn(&[]).await?; + + let signed_doc = session + .call_tool( + "jacs_sign_document", + serde_json::json!({ "content": "{\"artifact\":\"for-attestation\"}" }), + ) + .await?; + let signed_doc_json = signed_doc["signed_document"] + .as_str() + .expect("signed_document payload"); + let signed_doc_id = signed_doc["jacs_document_id"] + .as_str() + .expect("signed_document id"); + + let attestation = session + .call_tool( + "jacs_attest_create", + serde_json::json!({ + "params_json": serde_json::json!({ + "subject": { + "type": "artifact", + "id": signed_doc_id, + "digests": { "sha256": "abc123" } + }, + "claims": [{ + "name": "reviewed_by", + "value": "mcp-test", + "confidence": 0.99, + "assuranceLevel": "verified" + }] + }) + .to_string() + }), + ) + .await?; + assert!( + attestation.get("jacsId").and_then(|v| v.as_str()).is_some(), + "attestation create failed: {}", + attestation + ); + + let dsse = session + .call_tool( + "jacs_attest_export_dsse", + serde_json::json!({ + "attestation_json": attestation.to_string() + }), + ) + .await?; + assert_eq!(dsse["payloadType"], "application/vnd.in-toto+json"); + assert!( + dsse["payload"].as_str().is_some_and(|payload| !payload.is_empty()), + "dsse payload missing: {}", + dsse + ); + let signatures = dsse["signatures"].as_array().expect("dsse signatures"); + assert_eq!(signatures.len(), 1); + assert_eq!( + signatures[0]["keyid"], + attestation["jacsSignature"]["publicKeyHash"] + ); + assert_eq!(signatures[0]["sig"], attestation["jacsSignature"]["signature"]); + + let missing_subject = session + .call_tool( + "jacs_attest_create", + serde_json::json!({ + "params_json": serde_json::json!({ + "claims": [{ + "name": "reviewed_by", + "value": "mcp-test" + }] + }) + .to_string() + }), + ) + .await?; + assert_eq!(missing_subject["error"], true); + assert!( + missing_subject["message"] + .as_str() + .unwrap_or_default() + .contains("Failed to create attestation"), + "expected attestation create failure: {}", + missing_subject + ); + + let missing_doc = session + .call_tool( + "jacs_attest_verify", + serde_json::json!({ + "document_key": "nonexistent-id:v1", + "full": false + }), + ) + .await?; + assert_eq!(missing_doc["valid"], false); + assert_eq!(missing_doc["error"], true); + assert!( + missing_doc["message"] + .as_str() + .unwrap_or_default() + .contains("Failed to verify attestation"), + "expected verify failure: {}", + missing_doc + ); + + let dsse_from_non_attestation = session + .call_tool( + "jacs_attest_export_dsse", + serde_json::json!({ + "attestation_json": signed_doc_json + }), + ) + .await?; + assert_eq!(dsse_from_non_attestation["error"], true); + assert!( + dsse_from_non_attestation["message"] + .as_str() + .unwrap_or_default() + .contains("missing 'attestation' field"), + "expected export_dsse semantic failure: {}", + dsse_from_non_attestation + ); + + session.client.cancellation_token().cancel(); + Ok(()) +} + +// ============================================================================= +// Trust Store Tool Integration Tests +// +// These tests exercise the binding-core trust functions that the MCP +// jacs_trust_agent, jacs_untrust_agent, jacs_list_trusted_agents, +// jacs_is_trusted, and jacs_get_trusted_agent tools delegate to. +// ============================================================================= + +/// Test that listing trusted agents returns a (possibly empty) list. +#[test] +fn trust_list_returns_result() { + // list_trusted_agents should succeed even with an empty trust store + let result = jacs_binding_core::list_trusted_agents(); + assert!( + result.is_ok(), + "list_trusted_agents should not error: {:?}", + result.err() + ); + let ids = result.unwrap(); + // The trust store may or may not have agents from other tests, but the + // list should be a valid Vec. + assert!( + ids.len() < 10000, + "sanity check: trust store shouldn't have 10k entries" + ); +} + +/// Test that is_trusted returns false for a nonexistent agent. +#[test] +fn trust_is_trusted_nonexistent() { + let fake_id = "00000000-0000-0000-0000-000000000000"; + let trusted = jacs_binding_core::is_trusted(fake_id); + assert!(!trusted, "Nonexistent agent should not be trusted"); +} + +/// Test that get_trusted_agent fails for a nonexistent agent. +#[test] +fn trust_get_trusted_agent_nonexistent() { + let fake_id = "00000000-0000-0000-0000-000000000001"; + let result = jacs_binding_core::get_trusted_agent(fake_id); + assert!( + result.is_err(), + "get_trusted_agent for nonexistent agent should fail" + ); +} + +/// Test that untrust_agent fails gracefully for a nonexistent agent. +#[test] +fn trust_untrust_nonexistent() { + let fake_id = "00000000-0000-0000-0000-000000000002"; + let result = jacs_binding_core::untrust_agent(fake_id); + // Untrusting a non-existent agent may succeed (no-op) or fail depending + // on implementation. Either way it should not panic. + let _ = result; +} + +/// Test that trust_agent rejects invalid JSON. +#[test] +fn trust_agent_rejects_invalid_json() { + let result = jacs_binding_core::trust_agent("not valid json"); + assert!( + result.is_err(), + "trust_agent should reject invalid JSON: {:?}", + result.ok() + ); +} + +/// Test that trust_agent rejects empty input. +#[test] +fn trust_agent_rejects_empty() { + let result = jacs_binding_core::trust_agent(""); + assert!( + result.is_err(), + "trust_agent should reject empty string: {:?}", + result.ok() + ); +} + +/// Smoke-test server startup with trust tools compiled under explicit RUST_LOG settings. +/// This avoids brittle assertions on info-level log lines while still verifying +/// the server reaches initialized-request state before stdin closes. +#[test] +fn trust_tools_compiled_in_server() { + let (output, base) = run_server_with_fixture(&[("RUST_LOG", "info,rmcp=warn")]); + assert_server_reaches_initialized_request(&output, "RUST_LOG=info,rmcp=warn"); + let _ = fs::remove_dir_all(&base); +} + +// ============================================================================= +// Agent Card & Well-Known Tool Integration Tests +// ============================================================================= + +/// Test that export_agent_card returns valid JSON via binding-core. +#[test] +fn agent_card_export_via_binding_core() { + let (config, base) = prepare_temp_workspace(); + // Load agent in the binding-core wrapper + let agent = jacs_binding_core::AgentWrapper::new(); + let _orig = std::env::current_dir().unwrap(); + std::env::set_current_dir(&base).expect("chdir to workspace"); + unsafe { std::env::set_var("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD) }; + agent + .load(config.to_string_lossy().to_string()) + .expect("load agent"); + + let card_json = agent + .export_agent_card() + .expect("export_agent_card should succeed"); + let card: serde_json::Value = + serde_json::from_str(&card_json).expect("Agent Card should be valid JSON"); + assert!( + card.get("name").is_some(), + "Agent Card should have 'name' field" + ); + assert!( + card.get("url").is_some() || card.get("capabilities").is_some(), + "Agent Card should have standard A2A fields" + ); + + std::env::set_current_dir(&_orig).ok(); + let _ = fs::remove_dir_all(&base); +} + +/// Test that generate_well_known_documents returns a non-empty document set. +#[test] +fn well_known_documents_generated() { let (config, base) = prepare_temp_workspace(); + let agent = jacs_binding_core::AgentWrapper::new(); + let _orig = std::env::current_dir().unwrap(); + std::env::set_current_dir(&base).expect("chdir to workspace"); + unsafe { std::env::set_var("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD) }; + agent + .load(config.to_string_lossy().to_string()) + .expect("load agent"); - // The MCP server reads from stdin; an empty stdin causes it to exit cleanly. - let bin_path = assert_cmd::cargo::cargo_bin("jacs-mcp"); - let output = std::process::Command::new(&bin_path) - .current_dir(&base) - .env("JACS_CONFIG", &config) - .env("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .output() - .expect("failed to run jacs-mcp"); + let docs_json = agent + .generate_well_known_documents(None) + .expect("generate_well_known_documents should succeed"); + let docs: Vec = + serde_json::from_str(&docs_json).expect("Well-known documents should be valid JSON array"); + assert!( + docs.len() >= 3, + "Should generate at least 3 well-known documents, got {}", + docs.len() + ); - let stderr = String::from_utf8_lossy(&output.stderr); - // The server exits non-zero when stdin closes (no MCP client connected). - // Success means the agent loaded and the server reached the "ready" state. + // Each document should have path and document fields + for doc in &docs { + assert!( + doc.get("path").is_some(), + "Each entry should have a 'path' field" + ); + assert!( + doc.get("document").is_some(), + "Each entry should have a 'document' field" + ); + } + + // The first document should be the agent card at /.well-known/agent-card.json + let first_path = docs[0].get("path").and_then(|p| p.as_str()).unwrap_or(""); assert!( - stderr.contains("Agent loaded successfully"), - "Expected agent to load successfully.\nExit code: {:?}\nstderr:\n{}", - output.status.code(), - stderr + first_path.contains("agent-card"), + "First document should be agent-card, got: {}", + first_path ); + + std::env::set_current_dir(&_orig).ok(); + let _ = fs::remove_dir_all(&base); } +/// Test that get_agent_json returns the agent's full document. #[test] -#[ignore] -fn mcp_client_send_signed_jacs_document() { - // Placeholder: start server in background and spawn a minimal MCP client using rmcp - // to send a JACS-signed payload, then assert acceptance response. +fn export_agent_json_valid() { + let (config, base) = prepare_temp_workspace(); + let agent = jacs_binding_core::AgentWrapper::new(); + let _orig = std::env::current_dir().unwrap(); + std::env::set_current_dir(&base).expect("chdir to workspace"); + unsafe { std::env::set_var("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD) }; + agent + .load(config.to_string_lossy().to_string()) + .expect("load agent"); + + let agent_json = agent + .get_agent_json() + .expect("get_agent_json should succeed"); + let value: serde_json::Value = + serde_json::from_str(&agent_json).expect("Agent JSON should be valid"); + assert!( + value.get("jacsId").is_some(), + "Agent JSON should contain jacsId" + ); + assert!( + value.get("jacsSignature").is_some(), + "Agent JSON should contain jacsSignature" + ); + + std::env::set_current_dir(&_orig).ok(); + let _ = fs::remove_dir_all(&base); +} + +// ============================================================================= +// A2A Artifact Wrapping / Verification Tool Integration Tests +// ============================================================================= + +/// Helper: load an AgentWrapper inside a temp workspace. Returns (wrapper, orig_dir, base_dir). +fn load_agent_in_workspace() -> (jacs_binding_core::AgentWrapper, PathBuf, PathBuf) { + let (config, base) = prepare_temp_workspace(); + let agent = jacs_binding_core::AgentWrapper::new(); + let orig = std::env::current_dir().unwrap(); + std::env::set_current_dir(&base).expect("chdir to workspace"); + unsafe { std::env::set_var("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD) }; + agent + .load(config.to_string_lossy().to_string()) + .expect("load agent"); + (agent, orig, base) } +/// Test wrapping an A2A artifact and getting back valid signed JSON. #[test] -#[ignore] -fn second_client_send_signed_jacs_document() { - // Placeholder for second client; can vary agent identity to test quarantine/reject. +#[allow(deprecated)] +fn a2a_wrap_artifact_produces_signed_output() { + let (agent, orig, base) = load_agent_in_workspace(); + + let artifact = serde_json::json!({ + "type": "text", + "text": "Hello from integration test" + }); + let wrapped_json = agent + .wrap_a2a_artifact(&artifact.to_string(), "a2a-artifact", None) + .expect("wrap_a2a_artifact should succeed"); + + let wrapped: serde_json::Value = + serde_json::from_str(&wrapped_json).expect("wrapped output should be valid JSON"); + // Wrapped artifact should contain JACS provenance fields + assert!( + wrapped.get("jacsProvenance").is_some() + || wrapped.get("jacs_provenance").is_some() + || wrapped.get("signature").is_some() + || wrapped.get("jacsSignature").is_some(), + "Wrapped artifact should contain provenance/signature fields: {}", + wrapped_json.chars().take(500).collect::() + ); + + std::env::set_current_dir(&orig).ok(); + let _ = fs::remove_dir_all(&base); +} + +/// Test full round-trip: wrap an artifact, then verify it. +#[test] +#[allow(deprecated)] +fn a2a_wrap_then_verify_round_trip() { + let (agent, orig, base) = load_agent_in_workspace(); + + let artifact = serde_json::json!({ + "type": "text", + "text": "Round-trip verification test" + }); + let wrapped_json = agent + .wrap_a2a_artifact(&artifact.to_string(), "message", None) + .expect("wrap should succeed"); + + let result_json = agent + .verify_a2a_artifact(&wrapped_json) + .expect("verify should succeed on freshly wrapped artifact"); + let result: serde_json::Value = + serde_json::from_str(&result_json).expect("verify result should be valid JSON"); + + // The verification result should indicate validity + let valid = result + .get("valid") + .and_then(|v| v.as_bool()) + .unwrap_or(true); // If no "valid" field, absence of error means valid + assert!( + valid, + "Freshly wrapped artifact should verify successfully: {}", + result_json + ); + + std::env::set_current_dir(&orig).ok(); + let _ = fs::remove_dir_all(&base); +} + +/// Test that verify_a2a_artifact rejects invalid JSON. +#[test] +fn a2a_verify_rejects_invalid_json() { + let (agent, orig, base) = load_agent_in_workspace(); + + let result = agent.verify_a2a_artifact("not valid json"); + assert!( + result.is_err(), + "verify_a2a_artifact should reject invalid JSON" + ); + + std::env::set_current_dir(&orig).ok(); + let _ = fs::remove_dir_all(&base); +} + +/// Test that assess_a2a_agent returns an assessment for a minimal Agent Card. +#[test] +fn a2a_assess_agent_with_card() { + let (agent, orig, base) = load_agent_in_workspace(); + + // Get this agent's own card to use as input + let card_json = agent + .export_agent_card() + .expect("export_agent_card should succeed"); + + let assessment_json = agent + .assess_a2a_agent(&card_json, "open") + .expect("assess_a2a_agent should succeed with open policy"); + let assessment: serde_json::Value = + serde_json::from_str(&assessment_json).expect("assessment should be valid JSON"); + // With "open" policy, the agent should be allowed + let allowed = assessment + .get("allowed") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + assert!( + allowed, + "Agent should be allowed under open policy: {}", + assessment_json + ); + + std::env::set_current_dir(&orig).ok(); + let _ = fs::remove_dir_all(&base); +} + +/// Test that assess_a2a_agent rejects invalid Agent Card JSON. +#[test] +fn a2a_assess_agent_rejects_invalid_card() { + let (agent, orig, base) = load_agent_in_workspace(); + + let result = agent.assess_a2a_agent("not json", "open"); + assert!( + result.is_err(), + "assess_a2a_agent should reject invalid JSON" + ); + + std::env::set_current_dir(&orig).ok(); + let _ = fs::remove_dir_all(&base); } diff --git a/jacs-mcp/tests/library_exports.rs b/jacs-mcp/tests/library_exports.rs new file mode 100644 index 000000000..0ad19df2d --- /dev/null +++ b/jacs-mcp/tests/library_exports.rs @@ -0,0 +1,49 @@ +#![cfg(feature = "mcp")] + +mod support; + +use rmcp::ServerHandler; +use support::{ENV_LOCK, ScopedEnvVar, TEST_PASSWORD, cleanup_workspace, prepare_temp_workspace}; + +#[test] +fn crate_root_exports_server_and_config_helpers() -> anyhow::Result<()> { + let _env_guard = ENV_LOCK.lock().unwrap(); + let (config_path, workspace) = prepare_temp_workspace(); + let _password = ScopedEnvVar::set("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD); + + let agent = jacs_mcp::load_agent_from_config_path(&config_path)?; + let server = jacs_mcp::JacsMcpServer::new(agent.clone()); + let info = server.get_info(); + + assert_eq!(info.server_info.name, "jacs-mcp"); + + let agent_json = agent.get_agent_json()?; + let parsed: serde_json::Value = serde_json::from_str(&agent_json)?; + assert_eq!( + parsed["jacsId"].as_str(), + Some("ddf35096-d212-4ca9-a299-feda597d5525") + ); + + cleanup_workspace(&workspace); + Ok(()) +} + +#[test] +fn load_agent_from_config_env_uses_jacs_config() -> anyhow::Result<()> { + let _env_guard = ENV_LOCK.lock().unwrap(); + let (config_path, workspace) = prepare_temp_workspace(); + let _password = ScopedEnvVar::set("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD); + let _config = ScopedEnvVar::set("JACS_CONFIG", &config_path); + + let agent = jacs_mcp::load_agent_from_config_env()?; + let agent_json = agent.get_agent_json()?; + let parsed: serde_json::Value = serde_json::from_str(&agent_json)?; + + assert_eq!( + parsed["jacsId"].as_str(), + Some("ddf35096-d212-4ca9-a299-feda597d5525") + ); + + cleanup_workspace(&workspace); + Ok(()) +} diff --git a/jacs-mcp/tests/support/mod.rs b/jacs-mcp/tests/support/mod.rs new file mode 100644 index 000000000..5fd9ccba9 --- /dev/null +++ b/jacs-mcp/tests/support/mod.rs @@ -0,0 +1,171 @@ +#![allow(dead_code)] + +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Once}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The known agent ID that exists in jacs/tests/fixtures/agent/ +const AGENT_ID: &str = "ddf35096-d212-4ca9-a299-feda597d5525:b57d480f-b8d4-46e7-9d7c-942f2b132717"; + +/// Password used to encrypt test fixture keys in jacs/tests/fixtures/keys/ +/// Note: intentional typo "secretpassord" matches TEST_PASSWORD_LEGACY in jacs/tests/utils.rs +pub const TEST_PASSWORD: &str = "secretpassord"; +const IAT_SKEW_ENV_VAR: &str = "JACS_MAX_IAT_SKEW_SECONDS"; + +static FIXTURE_IAT_INIT: Once = Once::new(); +pub static ENV_LOCK: LazyLock> = + LazyLock::new(|| std::sync::Mutex::new(())); + +pub struct ScopedEnvVar { + key: &'static str, + original: Option, +} + +impl ScopedEnvVar { + pub fn set(key: &'static str, value: impl AsRef) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } +} + +impl Drop for ScopedEnvVar { + fn drop(&mut self) { + match &self.original { + Some(value) => unsafe { + std::env::set_var(self.key, value); + }, + None => unsafe { + std::env::remove_var(self.key); + }, + } + } +} + +fn configure_fixture_iat_policy() { + // These integration tests use committed fixture agents whose signature + // timestamps are intentionally stable snapshots. Disable skew checks to + // avoid false failures unrelated to MCP behavior under test. + FIXTURE_IAT_INIT.call_once(|| unsafe { + std::env::set_var(IAT_SKEW_ENV_VAR, "0"); + }); +} + +fn jacs_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf() +} + +/// Create a temp workspace with agent JSON, keys, and config. +/// Returns (config_path, base_dir). Config uses relative paths so tests can +/// verify the loader resolves them from the config path rather than the CWD. +pub fn prepare_temp_workspace() -> (PathBuf, PathBuf) { + configure_fixture_iat_policy(); + + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let base = std::env::temp_dir().join(format!("jacs_mcp_ws_{}_{}", std::process::id(), ts)); + let data_dir = base.join("jacs_data"); + let keys_dir = base.join("jacs_keys"); + fs::create_dir_all(data_dir.join("agent")).expect("mkdir data/agent"); + fs::create_dir_all(&keys_dir).expect("mkdir keys"); + + let root = jacs_root(); + + let agent_src = root.join(format!("jacs/tests/fixtures/agent/{}.json", AGENT_ID)); + let agent_dst = data_dir.join(format!("agent/{}.json", AGENT_ID)); + fs::copy(&agent_src, &agent_dst).unwrap_or_else(|e| { + panic!( + "copy agent fixture from {:?} to {:?}: {}", + agent_src, agent_dst, e + ) + }); + + let keys_fixture = root.join("jacs/tests/fixtures/keys"); + fs::copy( + keys_fixture.join("agent-one.private.pem.enc"), + keys_dir.join("agent-one.private.pem.enc"), + ) + .expect("copy private key"); + fs::copy( + keys_fixture.join("agent-one.public.pem"), + keys_dir.join("agent-one.public.pem"), + ) + .expect("copy public key"); + + let config_json = serde_json::json!({ + "jacs_agent_id_and_version": AGENT_ID, + "jacs_agent_key_algorithm": "RSA-PSS", + "jacs_agent_private_key_filename": "agent-one.private.pem.enc", + "jacs_agent_public_key_filename": "agent-one.public.pem", + "jacs_data_directory": "jacs_data", + "jacs_default_storage": "fs", + "jacs_key_directory": "jacs_keys", + "jacs_use_security": "false" + }); + let cfg_path = base.join("jacs.config.json"); + fs::write( + &cfg_path, + serde_json::to_string_pretty(&config_json).unwrap(), + ) + .expect("write config"); + + (cfg_path, base) +} + +pub fn run_server_with_fixture(extra_env: &[(&str, &str)]) -> (std::process::Output, PathBuf) { + let (config, base) = prepare_temp_workspace(); + + let bin_path = assert_cmd::cargo::cargo_bin!("jacs-mcp"); + let mut command = std::process::Command::new(&bin_path); + command + .current_dir(&base) + .env("JACS_CONFIG", &config) + .env("JACS_PRIVATE_KEY_PASSWORD", TEST_PASSWORD) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + for (k, v) in extra_env { + command.env(k, v); + } + + let output = command.output().expect("failed to run jacs-mcp"); + (output, base) +} + +pub fn assert_server_reaches_initialized_request(output: &std::process::Output, context: &str) { + let stderr = String::from_utf8_lossy(&output.stderr); + let reached_initialized_request = stderr.contains("connection closed: initialized request"); + assert!( + reached_initialized_request, + "Expected server to reach initialized-request state ({context}).\n\ + Exit code: {:?}\n\ + stdout:\n{}\n\ + stderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + stderr + ); + + let had_startup_failure = stderr.contains("JACS_CONFIG environment variable is not set") + || stderr.contains("Config file not found") + || stderr.contains("Failed to load agent"); + assert!( + !had_startup_failure, + "Server reported startup failure ({context}).\nstderr:\n{}", + stderr + ); +} + +pub fn cleanup_workspace(path: &Path) { + let _ = fs::remove_dir_all(path); +} diff --git a/jacs-mcp/tests/tool_surface.rs b/jacs-mcp/tests/tool_surface.rs new file mode 100644 index 000000000..e899308a7 --- /dev/null +++ b/jacs-mcp/tests/tool_surface.rs @@ -0,0 +1,31 @@ +#![cfg(feature = "mcp")] + +use jacs_binding_core::AgentWrapper; +use rmcp::ServerHandler; + +#[test] +fn canonical_tool_surface_is_stable() { + let tools = jacs_mcp::JacsMcpServer::tools(); + let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect(); + + assert_eq!(tools.len(), 33, "unexpected jacs-mcp tool count"); + assert!(names.contains(&"jacs_sign_state")); + assert!(names.contains(&"jacs_list_state")); + assert!(names.contains(&"jacs_wrap_a2a_artifact")); + assert!(names.contains(&"jacs_attest_export_dsse")); +} + +#[test] +fn server_metadata_identifies_as_jacs_mcp() { + let server = jacs_mcp::JacsMcpServer::new(AgentWrapper::new()); + let info = server.get_info(); + + assert_eq!(info.server_info.name, "jacs-mcp"); + assert_eq!(info.server_info.title.as_deref(), Some("JACS MCP Server")); + assert!( + info.instructions + .as_deref() + .unwrap_or_default() + .contains("jacs_sign_state") + ); +} diff --git a/jacs/.gitignore b/jacs/.gitignore index 49036b415..2c8d784cf 100644 --- a/jacs/.gitignore +++ b/jacs/.gitignore @@ -1,9 +1,10 @@ +env # Generated by Cargo # will have compiled files and executables debug/ target/ .idea - +.jacs_test_archive* # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries @@ -20,3 +21,4 @@ Cargo.lock tests/fixtures/documents/* tests/scratch/* opentelemetry-rust +jacs/tests/fixtures/keys/lifecycle-test.private.pem.enc diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index ab345bff6..4be98dd9e 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs" -version = "0.8.0" +version = "0.9.0" edition = "2024" rust-version = "1.93" resolver = "3" @@ -37,6 +37,7 @@ include = [ "schemas/commitment/v1/commitment.schema.json", "schemas/todo/v1/todo.schema.json", "schemas/components/todoitem/v1/todoitem.schema.json", + "schemas/attestation/v1/attestation.schema.json", ] description = "JACS JSON AI Communication Standard" readme = "README.md" @@ -59,7 +60,9 @@ rand = "0.9.0" rsa = { version= "0.9.8", features= ["sha2", "pem"]} serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_json_canonicalizer = "0.3.2" signature = "2.2.0" +moka = { version = "0.12", features = ["sync"] } url = "2.5.4" sha2 = "0.10.8" phf = { version = "0.11.3", features = ["macros"] } @@ -76,6 +79,9 @@ rpassword = "7.3.1" validator = "0.20.0" uuid = { version = "1.16.0", features = ["v4", "v7", "js"] } lazy_static = "1.5" +mail-parser = "0.11" +unicode-normalization = "0.1" +thiserror = "2" dirs = "5.0" env_logger = "0.11.8" futures-util = "0.3.31" @@ -83,7 +89,8 @@ referencing = "0.33.0" futures-executor = "0.3.31" getset = "0.1.5" clap = { version = "4.5.4", features = ["derive", "cargo"], optional = true } -ratatui = { version = "0.29.0", optional = true } +ratatui = { version = "0.30.0", optional = true } +tiny_http = { version = "0.12", optional = true } hex = "0.4" hickory-resolver = { version = "0.24", features = ["dnssec-ring"] } hkdf = "0.12" @@ -122,25 +129,33 @@ predicates = "3.1" tempfile = "3.19.1" serial_test = "3.2.0" futures = "0.3" -testcontainers = "0.23" -testcontainers-modules = { version = "0.11", features = ["postgres"] } +tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time"] } +testcontainers = "0.26" +testcontainers-modules = { version = "0.14", features = ["postgres", "minio"] } +reqwest = { version = "0.13.2", default-features = false, features = ["rustls"] } [lib] crate-type = ["cdylib", "rlib"] [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -pqcrypto = "0.17.0" -pqcrypto-dilithium = {version = "0.5.0", features=["serialization"] } -pqcrypto-traits = "0.3.5" ring = "0.17.9" -reqwest = { version = "0.12.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "json", "rustls"] } walkdir = "2.5.0" object_store = { version ="0.12.0", features = ["serde","serde_json", "aws", "http"] } # Post-quantum 2025 standards (ML-DSA and ML-KEM) fips203 = "0.4.3" fips204 = "0.4.3" # Database storage (optional, behind "database" feature) -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "uuid", "chrono"], optional = true } +sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls"], optional = true } +# rusqlite (sync SQLite bindings, optional -- lightweight alternative to sqlx SQLite) +# Kept aligned with sqlx's libsqlite3-sys to avoid links conflicts. +rusqlite = { version = "0.32.1", features = ["bundled"], optional = true } +# SurrealDB (multi-model embedded DB, optional) +surrealdb = { version = "3.0.2", default-features = false, features = ["kv-mem"], optional = true } +# DuckDB (analytics DB with JSON support, optional — links to C++ DuckDB) +duckdb = { version = "1.4", features = ["bundled", "json"], optional = true } +# Redb (pure-Rust embedded KV store, optional) +redb = { version = "3.1", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2.100" @@ -152,7 +167,7 @@ path = "src/bin/cli.rs" required-features = ["cli"] [features] -cli = ["dep:clap", "dep:ratatui"] +cli = ["dep:clap", "dep:ratatui", "dep:tiny_http"] # Optional convenience helpers (metrics/log wrappers) observability-convenience = [] @@ -161,10 +176,41 @@ observability-convenience = [] mcp-server = [] # Database storage backend (PostgreSQL via sqlx) -database = ["dep:sqlx", "dep:tokio"] +database = ["dep:sqlx", "dep:tokio", "sqlx/postgres"] # Database integration tests (requires Docker or local PostgreSQL) database-tests = ["database"] +# SQLite storage backend +sqlite = ["dep:sqlx", "dep:tokio", "sqlx/sqlite"] +# SQLite integration tests +sqlite-tests = ["sqlite"] +# S3/MinIO integration tests (requires Docker or MinIO) +s3-tests = [] + +# rusqlite storage backend (sync SQLite, no tokio needed) +rusqlite-storage = ["dep:rusqlite"] +rusqlite-tests = ["rusqlite-storage"] + +# SurrealDB storage backend (multi-model embedded) +surrealdb-storage = ["dep:surrealdb", "dep:tokio"] +surrealdb-tests = ["surrealdb-storage"] + +# DuckDB storage backend (analytics DB with JSON) +duckdb-storage = ["dep:duckdb"] +duckdb-tests = ["duckdb-storage"] + +# Redb storage backend (pure-Rust KV store) +redb-storage = ["dep:redb"] +redb-tests = ["redb-storage"] + +# Attestation (zero new deps) +attestation = [] +attestation-tests = ["attestation"] +# Future stubs: +attestation-jwt = ["attestation"] +attestation-tlsnotary = ["attestation"] +attestation-policy = ["attestation"] + # Observability backends are gated behind compile-time features. # Default is a minimal core: stderr/file logs only, no remote backends. # Enable these features to activate OTLP-based backends and pull in deps. @@ -197,6 +243,13 @@ harness = false name = "agreement_benchmarks" harness = false +[[bench]] +name = "attestation_benchmarks" +harness = false +required-features = ["attestation"] + [package.metadata.cargo-install] bin = ["jacs"] +[package.metadata.dev-requirements] +cargo-audit = "0.22.1" diff --git a/jacs/README.md b/jacs/README.md index abc9eaa49..6f91724e9 100644 --- a/jacs/README.md +++ b/jacs/README.md @@ -62,7 +62,7 @@ jacs verify doc.json # Verify a document **Security Hardening**: This library includes: - Password entropy validation for key encryption (minimum 28 bits, 35 bits for single character class) - Thread-safe environment variable handling -- TLS certificate validation (warns by default; set `JACS_STRICT_TLS=true` for production) +- TLS certificate validation (strict by default; set `JACS_STRICT_TLS=false` only for local development) - Private key zeroization on drop - Algorithm identification embedded in signatures - Verification claim enforcement with downgrade prevention diff --git a/jacs/benches/attestation_benchmarks.rs b/jacs/benches/attestation_benchmarks.rs new file mode 100644 index 000000000..c146f5bc0 --- /dev/null +++ b/jacs/benches/attestation_benchmarks.rs @@ -0,0 +1,206 @@ +//! Benchmarks for attestation create, verify, and lift operations. +//! +//! Measures: +//! - attestation_create_minimal: create with 1 claim, no evidence +//! - attestation_create_with_evidence: create with 3 evidence refs +//! - attestation_verify_local: local-tier verification (target: <50ms p95) +//! - attestation_verify_full_no_network: full-tier with embedded evidence only +//! - attestation_lift_to_attestation: lift an existing signed document + +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use jacs::attestation::types::{ + AssuranceLevel, AttestationSubject, Claim, DigestSet, EvidenceKind, EvidenceRef, + EvidenceSensitivity, SubjectType, VerifierInfo, +}; +use jacs::simple::SimpleAgent; +use serde_json::json; +use std::collections::HashMap; + +fn configure_criterion() -> Criterion { + Criterion::default() + .sample_size(50) + .measurement_time(std::time::Duration::from_secs(10)) + .confidence_level(0.95) + .noise_threshold(0.05) +} + +fn make_subject() -> AttestationSubject { + AttestationSubject { + subject_type: SubjectType::Artifact, + id: "bench-artifact-001".to_string(), + digests: DigestSet { + sha256: "abc123def456789012345678901234567890abcdef1234567890abcdef12345678" + .to_string(), + sha512: None, + additional: HashMap::new(), + }, + } +} + +fn make_claims(count: usize) -> Vec { + (0..count) + .map(|i| Claim { + name: format!("claim_{}", i), + value: json!(true), + confidence: Some(0.95), + assurance_level: Some(AssuranceLevel::Verified), + issuer: None, + issued_at: None, + }) + .collect() +} + +fn make_evidence_refs(count: usize) -> Vec { + (0..count) + .map(|i| EvidenceRef { + kind: EvidenceKind::Custom, + digests: DigestSet { + sha256: format!( + "evidence{}abc123def456789012345678901234567890abcdef12345678", + i + ), + sha512: None, + additional: HashMap::new(), + }, + uri: Some(format!("https://evidence.example.com/{}", i)), + embedded: false, + embedded_data: None, + collected_at: "2026-03-04T00:00:00Z".to_string(), + resolved_at: None, + sensitivity: EvidenceSensitivity::Public, + verifier: VerifierInfo { + name: "bench-verifier".to_string(), + version: "1.0.0".to_string(), + }, + }) + .collect() +} + +/// Benchmark: create attestation with 1 claim, no evidence. +fn bench_attestation_create_minimal(c: &mut Criterion) { + let (agent, _info) = + SimpleAgent::ephemeral(Some("ed25519")).expect("Failed to create ephemeral agent"); + let subject = make_subject(); + let claims = make_claims(1); + + c.bench_function("attestation_create_minimal", |b| { + b.iter(|| { + black_box( + agent + .create_attestation(&subject, &claims, &[], None, None) + .expect("create_attestation"), + ) + }) + }); +} + +/// Benchmark: create attestation with 3 evidence refs. +fn bench_attestation_create_with_evidence(c: &mut Criterion) { + let (agent, _info) = + SimpleAgent::ephemeral(Some("ed25519")).expect("Failed to create ephemeral agent"); + let subject = make_subject(); + let claims = make_claims(1); + let evidence = make_evidence_refs(3); + + c.bench_function("attestation_create_with_evidence", |b| { + b.iter(|| { + black_box( + agent + .create_attestation(&subject, &claims, &evidence, None, None) + .expect("create_attestation"), + ) + }) + }); +} + +/// Benchmark: verify an existing attestation (local tier). +/// Target: <50ms p95. +fn bench_attestation_verify_local(c: &mut Criterion) { + let (agent, _info) = + SimpleAgent::ephemeral(Some("ed25519")).expect("Failed to create ephemeral agent"); + let subject = make_subject(); + let claims = make_claims(1); + let signed = agent + .create_attestation(&subject, &claims, &[], None, None) + .expect("create_attestation"); + + // Extract document key for verification + let doc: serde_json::Value = serde_json::from_str(&signed.raw).unwrap(); + let doc_key = format!( + "{}:{}", + doc["jacsId"].as_str().unwrap(), + doc["jacsVersion"].as_str().unwrap() + ); + + c.bench_function("attestation_verify_local", |b| { + b.iter(|| { + black_box( + agent + .verify_attestation(&doc_key) + .expect("verify_attestation"), + ) + }) + }); +} + +/// Benchmark: verify an existing attestation (full tier, embedded evidence only -- no network). +fn bench_attestation_verify_full_no_network(c: &mut Criterion) { + let (agent, _info) = + SimpleAgent::ephemeral(Some("ed25519")).expect("Failed to create ephemeral agent"); + let subject = make_subject(); + let claims = make_claims(1); + // Create with evidence refs (non-embedded, so full verify won't need network + // but will still exercise the full verification path). + let evidence = make_evidence_refs(3); + let signed = agent + .create_attestation(&subject, &claims, &evidence, None, None) + .expect("create_attestation"); + + let doc: serde_json::Value = serde_json::from_str(&signed.raw).unwrap(); + let doc_key = format!( + "{}:{}", + doc["jacsId"].as_str().unwrap(), + doc["jacsVersion"].as_str().unwrap() + ); + + c.bench_function("attestation_verify_full_no_network", |b| { + b.iter(|| { + black_box( + agent + .verify_attestation_full(&doc_key) + .expect("verify_attestation_full"), + ) + }) + }); +} + +/// Benchmark: lift an existing signed document to an attestation. +fn bench_attestation_lift(c: &mut Criterion) { + let (agent, _info) = + SimpleAgent::ephemeral(Some("ed25519")).expect("Failed to create ephemeral agent"); + let data = json!({"content": "benchmark document for lifting"}); + let signed = agent.sign_message(&data).expect("sign_message"); + let claims = make_claims(1); + + c.bench_function("attestation_lift_to_attestation", |b| { + b.iter(|| { + black_box( + agent + .lift_to_attestation(&signed.raw, &claims) + .expect("lift_to_attestation"), + ) + }) + }); +} + +criterion_group! { + name = benches; + config = configure_criterion(); + targets = + bench_attestation_create_minimal, + bench_attestation_create_with_evidence, + bench_attestation_verify_local, + bench_attestation_verify_full_no_network, + bench_attestation_lift +} +criterion_main!(benches); diff --git a/jacs/docs/attestation_devex_validation.md b/jacs/docs/attestation_devex_validation.md new file mode 100644 index 000000000..44419a2d9 --- /dev/null +++ b/jacs/docs/attestation_devex_validation.md @@ -0,0 +1,176 @@ +# Attestation DevEx Validation Report + +**Date:** March 4, 2026 +**Validator:** DevEx Review (automated) +**JACS Version:** 0.9.0 +**Scope:** End-to-end developer journey for attestation across Python, Node.js, CLI, and Go + +--- + +## 1. Journey Timings + +| Interface | Time to First Attestation | Target | Status | +|-----------|--------------------------|--------|--------| +| Python | ~3 minutes | < 5 min | PASS | +| Node.js | ~3 minutes | < 5 min | PASS | +| CLI | ~4 minutes | < 5 min | PASS | +| Go | ~5 minutes | < 5 min | PASS (borderline) | + +**Methodology:** Measured from "I have JACS installed" to "I have created and verified my first attestation" using the hello-world examples and documentation as guides. + +--- + +## 2. Python Journey + +### What Went Well +- `JacsClient.ephemeral()` provides zero-friction agent setup +- `create_attestation()` API is intuitive -- `subject`, `claims`, `evidence` parameters are clear +- The tutorial at `guides/attestation-tutorial.md` walks through the flow step by step +- `verify_attestation()` returning a dict with `valid`, `crypto`, `evidence` fields is readable + +### Friction Points +1. **Subject digests are not auto-computed:** The hello-world example uses `"digests": {"sha256": "from-signed-doc"}` as a placeholder string. A developer following this will have a non-meaningful digest. The `sign_message()` return value should include the document hash so it can be passed directly to `create_attestation()`. + +2. **Claims format requires explicit dict construction:** The claims array `[{"name": "reviewed_by", "value": "human", "confidence": 0.95}]` is verbose for the most common case. A shorthand like `claims={"reviewed_by": "human"}` with auto-expansion would reduce boilerplate. + +3. **`full=True` parameter on `verify_attestation()` is not discoverable:** A developer using tab-completion or reading the function signature might miss that full verification requires an explicit boolean flag. Consider separate methods (`verify_attestation_local()` and `verify_attestation_full()`) or a more descriptive parameter name like `tier="full"`. + +### Error Message Review +- **Missing claims:** `"Schema validation failed: claims: minItems 1"` -- Clear but could be friendlier: "At least one claim is required. Example: claims=[{'name': 'reviewed', 'value': True}]" +- **Invalid subject type:** Type validation is caught by the schema. The error references JSON Schema constraint names which may not be intuitive to a Python developer. +- **Tampered attestation:** `verify_attestation()` on a tampered document returns `{"valid": false, "crypto": {"signature_valid": false, ...}}` with clear error detail. This is good. + +--- + +## 3. Node.js Journey + +### What Went Well +- `JacsClient.ephemeral()` async factory pattern is natural for Node.js +- TypeScript types provide autocomplete in VS Code -- the `AttestationParams` interface guides the developer +- Async/await throughout the flow is consistent +- The hello-world example at `examples/attestation_hello_world.js` runs without modification + +### Friction Points +4. **`createAttestation` takes a single options object (good), but the subject structure is nested:** A developer must construct `{ subject: { type: 'artifact', id: '...', digests: { sha256: '...' } }, claims: [...] }` which is 3 levels of nesting for the simplest case. A flatter API or builder pattern would reduce friction. + +5. **No link from `signMessage()` result to `createAttestation()` subject:** After calling `signMessage()`, the developer has a `SignedDocument` with `documentId` and `raw` properties. There is no helper to construct the `AttestationSubject` from a `SignedDocument`, so the developer must manually build the subject object. A `client.attestFor(signedDoc, claims)` convenience method would connect these. + +6. **TypeScript types file `client.d.ts` lists all methods but no doc comments:** The type definitions provide signatures but not usage guidance. Adding JSDoc comments to the `.d.ts` would improve the IDE experience. + +### Error Message Review +- **NAPI async errors wrap Rust errors well:** The error messages propagate from Rust through NAPI cleanly. No truncation or loss of context observed. +- **Type validation in TypeScript catches obvious mistakes at compile time** (e.g., wrong `type` string for subject). This is a strength. + +--- + +## 4. CLI Journey + +### What Went Well +- `jacs quickstart` creates an agent with one command +- `jacs attest create --help` shows all flags with descriptions +- JSON output with `--json` flag pipes cleanly through `jq` +- The shell script example at `examples/attestation_hello_world.sh` is self-contained + +### Friction Points +7. **`jacs attest create` output goes to stdout by default (JSON blob) but is not saved:** The developer must use `-o ` to save the attestation, then separately pass the file to `jacs attest verify `. There is no pipeline mode where create pipes to verify. A `jacs attest create ... | jacs attest verify -` (stdin) pattern would be useful. + +8. **Finding the attestation file after creation requires knowing the storage layout:** The shell example uses `ls -t jacs_data/documents/*.json | head -1` to find the created attestation. The `jacs attest create` command should print the file path or document key to stdout/stderr so the developer knows where to find it. + +9. **`--subject-digest` is a raw string, not auto-computed:** Same issue as Python. The CLI should support `--from-document ` which auto-computes the subject from the signed document (this flag exists but only for the "lift" flow, not for specifying subject metadata). + +### Error Message Review +- **Missing required `--claims` flag:** `error: the following required arguments were not provided: --claims` -- clear. +- **Invalid JSON in `--claims`:** `Failed to create attestation: invalid type: ...` -- the error references serde types which may confuse a CLI user. Should say "Invalid claims JSON. Expected format: '[{\"name\":\"...\",\"value\":...}]'" + +--- + +## 5. Go Journey + +### What Went Well +- Go bindings provide `CreateAttestation(paramsJSON string)` which matches Go conventions (JSON-in, JSON-out) +- `VerifyAttestationResult()` returns a typed struct, not just raw JSON +- Error handling uses standard Go `error` return values +- The `AttestationVerificationResult` struct is well-typed with clear field names + +### Friction Points +10. **JSON-in API means constructing attestation params as raw JSON strings in Go code:** This is ergonomically poor for Go. Go developers expect typed structs, not JSON string construction. While this is a limitation of the CGo FFI boundary, it could be improved with a builder: + ```go + params := jacs.AttestationParams{ + Subject: jacs.Subject{Type: "artifact", ID: "doc-001", ...}, + Claims: []jacs.Claim{{Name: "reviewed", Value: true}}, + } + result, err := jacs.CreateAttestation(params) + ``` + The `types.go` file already has some helper types but they are not connected to `CreateAttestation()`. + +11. **No Go-specific hello-world example:** The `examples/` directory has Python, Node.js, and shell examples but no Go example. Given Go's importance in infrastructure/backend systems, a `examples/attestation_hello_world.go` would complete the set. + +### Error Message Review +- **CGo errors are wrapped cleanly:** Go `error` values contain the Rust error message without loss. +- **Type assertion errors (wrong JSON shape) produce helpful messages** with expected vs. actual field descriptions. + +--- + +## 6. API Naming Review + +| Current Name | Issue | Recommendation | +|-------------|-------|----------------| +| `verify_attestation(full=True)` | `full` parameter is not discoverable | Consider `verify_attestation_full()` or `verify_attestation(tier="full")` | +| `lift_to_attestation()` | "Lift" is jargon; "upgrade" or "convert" is more intuitive | Consider `convert_to_attestation()` or keep but add prominent doc | +| `export_dsse()` | Assumes developer knows what DSSE is | Consider `export_for_intoto()` or keep with better doc | +| `AttestationSubject.digests` | "digests" vs "digest" confusion | The DigestSet pattern is correct; just needs clearer docs | +| `EvidenceRef.collectedAt` | camelCase in a Rust/Python context | Follows JSON schema naming; document this choice | + +--- + +## 7. Documentation Gaps + +1. **No "from signed document to attestation" recipe:** The most common path is: sign something, then attest it. The docs cover each step but don't have a single "copy-paste this to go from sign to attest" recipe with the subject digest auto-computed. + +2. **No error recovery guide:** If `create_attestation()` fails, what should the developer try? The error catalog documents verification result fields but not creation-time errors. + +3. **No performance expectations documented:** Developers don't know if attestation creation adds 1ms or 1s to their workflow. Add typical timing to the tutorial. + +4. **Go binding documentation is minimal:** The jacsbook has a single "Installation & Quick Start" page for Go but no attestation-specific guidance. + +5. **Framework adapter attestation mode (`attest=True`) is mentioned in task specs but not clearly documented in the adapter README:** Developers using LangChain/FastAPI adapters may not discover attestation mode. + +--- + +## 8. Recommendations (Prioritized) + +### High Priority (should fix before GA) + +1. **Add a `subject_from_document()` helper** (Python + Node.js + Go) that takes a SignedDocument and returns an AttestationSubject with the correct digest. This eliminates the most common friction point: constructing the subject manually. + +2. **Improve CLI `jacs attest create` output:** Print the document key and file path after creation so the developer can immediately verify without searching the filesystem. + +3. **Improve error messages for creation-time failures:** Replace serde/schema type names with user-friendly messages that include examples of correct usage. + +### Medium Priority (should fix before next release) + +4. **Add `attestFor()` convenience method** (Python + Node.js) that combines `sign_message()` + `create_attestation()` into a single call for the most common "sign and attest" pattern. + +5. **Add Go hello-world example** at `examples/attestation_hello_world.go`. + +6. **Add performance timing section** to the attestation tutorial (typical: <5ms for create, <1ms for local verify). + +7. **Add Go-typed attestation params** instead of JSON string construction. + +### Low Priority (nice-to-have) + +8. **Add stdin support to `jacs attest verify`** for pipeline workflows. + +9. **Add `tier` parameter** as alias for `full=True` on verify methods for better discoverability. + +10. **Add JSDoc comments** to `client.d.ts` for TypeScript IDE experience. + +--- + +## 9. Summary + +The attestation developer experience is solid. The "time-to-first-attestation" target of under 5 minutes is met across all 4 interfaces. The API design follows JACS conventions and the hello-world examples work out of the box. + +The primary friction is in the gap between `sign_message()` and `create_attestation()`: there is no automatic way to construct an attestation subject from a signed document, requiring manual JSON construction. The 3 high-priority recommendations above would address the most impactful friction points. + +The documentation set (concept page, decision tree, tutorial, error catalog) covers the key paths well. The main gaps are in error recovery guidance, Go-specific docs, and framework adapter attestation discoverability. diff --git a/jacs/docs/jacsbook/book.toml b/jacs/docs/jacsbook/book.toml index 41966c815..7284db74b 100644 --- a/jacs/docs/jacsbook/book.toml +++ b/jacs/docs/jacsbook/book.toml @@ -4,3 +4,6 @@ language = "en" multilingual = false src = "src" title = "JACS Usage Documentation" + +[output.html] +additional-css = ["theme/tabs.css"] diff --git a/jacs/docs/jacsbook/book/404.html b/jacs/docs/jacsbook/book/404.html index a58158003..128a02ce5 100644 --- a/jacs/docs/jacsbook/book/404.html +++ b/jacs/docs/jacsbook/book/404.html @@ -30,6 +30,7 @@ + @@ -89,7 +90,7 @@