diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index b37b609bb..b502263f9 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -42,7 +42,11 @@ jobs: - name: Install dependencies working-directory: jacsnpm - run: npm ci || npm install + run: npm ci + + - name: Run npm audit + working-directory: jacsnpm + run: npm audit --audit-level=high || true - name: Build native module working-directory: jacsnpm @@ -51,3 +55,14 @@ jobs: - name: Run tests working-directory: jacsnpm run: npm test + + - name: Smoke test packed npm install + working-directory: jacsnpm + run: | + set -euo pipefail + PACKAGE_TGZ=$(npm pack | tail -n 1) + mkdir -p /tmp/jacs-npm-smoke + 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')" diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 9a2fa5126..c9168e863 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -1,27 +1,19 @@ -name: Python (jacspy crate) +name: Python (jacs) on: push: branches: [ "main" ] - paths: # Optional: Trigger only on changes within jacspy/ or relevant files - - 'jacspy/**' - - 'jacs/**' # jacspy depends on jacs - - '.github/workflows/python.yml' pull_request: branches: [ "main" ] - paths: # Optional: Trigger only on changes within jacspy/ or relevant files - - 'jacspy/**' - - 'jacs/**' # jacspy depends on jacs - - '.github/workflows/python.yml' workflow_dispatch: # Allows manual triggering env: CARGO_TERM_COLOR: always jobs: - # Job to run tests on every push/PR - test-jacspy: - name: Test jacspy crate (x86_64) + # Job to run tests on every push/PR (jacspy = local dir name; package on PyPI is jacs) + test-jacs: + name: Test jacs (x86_64) runs-on: ubuntu-latest # Docker is available on ubuntu-latest runners (x86_64) steps: @@ -35,7 +27,7 @@ jobs: working-directory: jacspy # Directory containing DockerfileBuilder run: docker buildx build --tag "jacs-build-x86_64" -f DockerfileBuilder . --load # --load makes image available - - name: Run jacspy tests in Docker (x86_64) + - name: Run jacs tests in Docker (x86_64) working-directory: jacspy # To match PWD context if needed by scripts env: RUST_BACKTRACE: "1" @@ -51,12 +43,62 @@ jobs: pip install fastmcp mcp starlette && \ make test-python" - # Job to build wheels, runs ONLY on push to main - build-jacspy-wheels: - name: Build jacspy wheels on ${{ matrix.os }} - # Condition: Only run on push events to the main branch - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: test-jacspy # Optional: Ensure tests pass before building wheels + - name: Verify sdist build on PR/push + working-directory: jacspy + env: + RUST_BACKTRACE: "1" + run: | + docker run --rm \ + -v "$(pwd)/..:/workspace" \ + jacs-build-x86_64 \ + bash -c "\ + cd /workspace/jacspy && \ + /opt/python/cp311-cp311/bin/python3.11 -m pip install maturin && \ + /opt/python/cp311-cp311/bin/python3.11 -m maturin sdist --out dist-sdist-check" + + wheel-smoke-uv: + name: Wheel smoke install (uv) + needs: test-jacs + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: '1.93' + + - name: Install uv and maturin + run: python -m pip install uv maturin + + - name: Build wheel + working-directory: jacspy + run: UV_NO_SYNC=1 uv run maturin build --release --out dist + + - name: Smoke test wheel import in clean venv + working-directory: jacspy + run: | + set -euo pipefail + uv venv /tmp/jacs-wheel-smoke + uv pip install --python /tmp/jacs-wheel-smoke/bin/python dist/jacs-*.whl + uv run --python /tmp/jacs-wheel-smoke/bin/python python - <<'PY' + import jacs + import jacs.simple + import jacs.hai + print("wheel smoke imports ok") + PY + + # Job to build wheels for CI coverage (PyPI package name is jacs) + build-jacs-wheels: + name: Build jacs wheels on ${{ matrix.os }} + needs: [test-jacs, wheel-smoke-uv] runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -80,28 +122,31 @@ jobs: with: python-version: '3.11' + - name: Set up Rust (macOS) + if: runner.os == 'macOS' + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: '1.93' + target: x86_64-apple-darwin,aarch64-apple-darwin + - name: Install cibuildwheel run: python -m pip install cibuildwheel - name: Build wheels - # Run cibuildwheel from the root, but it should detect ./JACS/jacspy/pyproject.toml - # Or use working-directory: JACS/jacspy run: cibuildwheel --output-dir wheelhouse jacspy env: - # === cibuildwheel configuration === - # Build architectures CIBW_ARCHS_LINUX: "x86_64 aarch64" - CIBW_ARCHS_MACOS: "x86_64 aarch64" # Build both Intel and ARM on macOS runners - # Skip PyPy builds - CIBW_SKIP: "pp*" - # Python versions to build for (align with pyproject.toml requires-python) - CIBW_BUILD: "cp311-* cp312-* cp313-*" # Example: Build for 3.11, 3.12, 3.13 - # Linux specific dependencies (if needed inside manylinux container) - # CIBW_BUILD_DEPENDS_LINUX: "openssl-devel" + CIBW_ARCHS_MACOS: "x86_64 arm64" + CIBW_BUILD: "cp311-* cp312-* cp313-*" + # macOS: set minimum deployment target so delocate doesn't reject the wheel + CIBW_ENVIRONMENT_MACOS: 'MACOSX_DEPLOYMENT_TARGET=10.13' + # Linux: install Rust toolchain and OpenSSL inside manylinux containers + CIBW_BEFORE_ALL_LINUX: "yum install -y openssl-devel perl-IPC-Cmd && curl https://sh.rustup.rs -sSf | sh -s -- -y" + CIBW_ENVIRONMENT_LINUX: 'PATH=$HOME/.cargo/bin:$PATH' - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: wheels-jacspy-${{ matrix.os }} + name: wheels-jacs-${{ matrix.os }} # Path is relative to GITHUB_WORKSPACE (repo root) - path: ./wheelhouse/*.whl \ No newline at end of file + path: ./wheelhouse/*.whl diff --git a/.github/workflows/release-crate.yml b/.github/workflows/release-crate.yml index 18baeaa25..005c53159 100644 --- a/.github/workflows/release-crate.yml +++ b/.github/workflows/release-crate.yml @@ -22,18 +22,18 @@ jobs: TAG="${GITHUB_REF#refs/tags/crate/v}" echo "version=$TAG" >> $GITHUB_OUTPUT - - name: Check Cargo.toml version matches tag + - name: Check Cargo.toml version matches tag (jacs + jacs-mcp) run: | - CARGO_VERSION=$(grep '^version = ' jacs/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') TAG_VERSION="${{ steps.extract.outputs.version }}" - echo "Cargo version: $CARGO_VERSION" - echo "Tag version: $TAG_VERSION" - if [ "$CARGO_VERSION" = "$TAG_VERSION" ]; then - echo "Version match confirmed" - else - echo "::error::Version mismatch! Cargo.toml has $CARGO_VERSION but tag is $TAG_VERSION" - exit 1 - fi + for dir in jacs jacs-mcp; do + CARGO_VERSION=$(grep '^version = ' $dir/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "$dir Cargo version: $CARGO_VERSION, tag: $TAG_VERSION" + if [ "$CARGO_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Version mismatch! $dir/Cargo.toml has $CARGO_VERSION but tag is $TAG_VERSION" + exit 1 + fi + done + echo "Version match confirmed for jacs and jacs-mcp" publish: needs: verify-version @@ -43,6 +43,11 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 - - name: Publish to crates.io + - name: Publish jacs to crates.io working-directory: jacs run: cargo publish --token ${{ secrets.CRATES_IO_TOKEN }} + + - name: Publish jacs-mcp to crates.io + working-directory: jacs-mcp + continue-on-error: true + run: cargo publish --token ${{ secrets.CRATES_IO_TOKEN }} diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml index 73ebe3da8..690d36df0 100644 --- a/.github/workflows/release-pypi.yml +++ b/.github/workflows/release-pypi.yml @@ -43,21 +43,35 @@ jobs: build-wheels: needs: verify-version + continue-on-error: ${{ matrix.allow_failure }} strategy: + fail-fast: false matrix: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu + use_zig: false + allow_failure: false - os: ubuntu-latest target: x86_64-unknown-linux-musl + use_zig: true + allow_failure: false - os: ubuntu-latest target: aarch64-unknown-linux-gnu + use_zig: true + allow_failure: false + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + use_zig: true + allow_failure: false - os: macos-latest target: aarch64-apple-darwin + use_zig: false + allow_failure: false - os: macos-13 target: x86_64-apple-darwin - - os: windows-latest - target: x86_64-pc-windows-msvc + use_zig: false + allow_failure: false runs-on: ${{ matrix.os }} steps: @@ -69,15 +83,27 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: + toolchain: '1.93' target: ${{ matrix.target }} - name: Install maturin + if: ${{ !matrix.use_zig }} run: pip install maturin + - name: Install maturin with zig support + if: ${{ matrix.use_zig }} + run: pip install "maturin[zig]" + - name: Build wheel + if: ${{ !matrix.use_zig }} working-directory: jacspy run: maturin build --release --target ${{ matrix.target }} --out dist + - name: Build wheel (zig) + if: ${{ matrix.use_zig }} + working-directory: jacspy + run: maturin build --release --target ${{ matrix.target }} --out dist --zig + - uses: actions/upload-artifact@v4 with: name: wheels-${{ matrix.target }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d42b70a7..32dbb27d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,21 +41,59 @@ jobs: build: needs: verify-version + continue-on-error: ${{ matrix.allow_failure }} strategy: + fail-fast: false matrix: include: - os: macos-latest target: aarch64-apple-darwin + use_zig: false + allow_failure: false - os: macos-13 target: x86_64-apple-darwin + use_zig: false + allow_failure: false - os: ubuntu-latest target: x86_64-unknown-linux-gnu + use_zig: false + allow_failure: false - os: ubuntu-latest target: x86_64-unknown-linux-musl + use_zig: true + allow_failure: false - os: ubuntu-latest target: aarch64-unknown-linux-gnu - - os: windows-latest - target: x86_64-pc-windows-msvc + use_zig: true + allow_failure: false + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + use_zig: true + allow_failure: false + - os: ubuntu-latest + target: armv7-unknown-linux-gnueabihf + use_zig: true + allow_failure: true + - os: ubuntu-latest + target: armv7-unknown-linux-musleabihf + use_zig: true + allow_failure: true + - os: ubuntu-latest + target: riscv64gc-unknown-linux-gnu + use_zig: true + allow_failure: true + - os: ubuntu-latest + target: riscv64gc-unknown-linux-musl + use_zig: true + allow_failure: true + - os: ubuntu-latest + target: s390x-unknown-linux-gnu + use_zig: true + allow_failure: true + - os: ubuntu-latest + target: x86_64-unknown-freebsd + use_zig: true + allow_failure: true runs-on: ${{ matrix.os }} steps: @@ -70,13 +108,25 @@ jobs: with: node-version: 20 + - name: Install Zig + if: ${{ matrix.use_zig }} + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.13.0 + - name: Install dependencies run: npm install working-directory: jacsnpm - name: Build native module - run: npx napi build --platform --release --target ${{ matrix.target }} working-directory: jacsnpm + shell: bash + run: | + if [ "${{ matrix.use_zig }}" = "true" ]; then + npx napi build --platform --release --target ${{ matrix.target }} --zig + else + npx napi build --platform --release --target ${{ matrix.target }} + fi - uses: actions/upload-artifact@v4 with: @@ -94,7 +144,85 @@ jobs: path: jacsnpm/artifacts - name: Copy binaries - run: cp artifacts/**/*.node . + run: | + find artifacts -name "*.node" -type f -print -exec cp {} . \; + ls -1 *.node + working-directory: jacsnpm + + - name: Verify required package files exist + run: | + set -euo pipefail + REQUIRED_FILES=( + "index.js" + "index.d.ts" + "simple.js" + "simple.d.ts" + "mcp.js" + "mcp.d.ts" + "http.js" + "http.d.ts" + "src/a2a.js" + "src/a2a.d.ts" + "package.json" + ) + REQUIRED_BINARIES=( + "jacs.darwin-arm64.node" + "jacs.darwin-x64.node" + "jacs.linux-x64-gnu.node" + "jacs.linux-x64-musl.node" + "jacs.linux-arm64-gnu.node" + "jacs.linux-arm64-musl.node" + ) + + OPTIONAL_BINARIES=( + "jacs.win32-x64-msvc.node" + "jacs.win32-ia32-msvc.node" + "jacs.win32-arm64-msvc.node" + "jacs.linux-arm-gnueabihf.node" + "jacs.linux-arm-musleabihf.node" + "jacs.linux-riscv64-gnu.node" + "jacs.linux-riscv64-musl.node" + "jacs.linux-s390x-gnu.node" + "jacs.freebsd-x64.node" + ) + + for file in "${REQUIRED_FILES[@]}"; do + test -f "$file" || { echo "::error::Missing required file: $file"; exit 1; } + done + + for file in "${REQUIRED_BINARIES[@]}"; do + test -f "$file" || { echo "::error::Missing required native binary: $file"; exit 1; } + done + + for file in "${OPTIONAL_BINARIES[@]}"; do + if [ ! -f "$file" ]; then + echo "::warning::Optional native binary not present: $file" + fi + done + working-directory: jacsnpm + + - name: Validate npm pack output + run: | + set -euo pipefail + npm pack --dry-run | tee pack-dry-run.txt + REQUIRED_PACKED_FILES=( + "index.js" + "index.d.ts" + "simple.js" + "simple.d.ts" + "mcp.js" + "mcp.d.ts" + "http.js" + "http.d.ts" + "src/a2a.js" + "src/a2a.d.ts" + ) + for file in "${REQUIRED_PACKED_FILES[@]}"; do + grep -F " ${file}" pack-dry-run.txt >/dev/null || { + echo "::error::npm pack output missing expected file: $file" + exit 1 + } + done working-directory: jacsnpm - uses: actions/setup-node@v4 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8e6e4369e..5eb062a50 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -18,19 +18,29 @@ env: jobs: test-jacs: # Renamed job for clarity - name: Test jacs crate - runs-on: ubuntu-latest + name: Test jacs crate (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] steps: - name: Checkout code - uses: actions/checkout@v4 # Use v4 + 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 - # Specify the working directory for the test command working-directory: jacs run: cargo test --verbose --features cli diff --git a/.gitignore b/.gitignore index f6b5504ef..88319ec7b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb .cursor +.vscode grant.md scratch.md jacs.config.json diff --git a/A2A_QUICKSTART.md b/A2A_QUICKSTART.md index fa805b375..d31e1464a 100644 --- a/A2A_QUICKSTART.md +++ b/A2A_QUICKSTART.md @@ -19,7 +19,7 @@ cargo add jacs pip install jacs # Node.js -npm install jacsnpm +npm install @hai-ai/jacs ``` ## Basic Usage @@ -49,7 +49,7 @@ agent_card = a2a.export_agent_card({ ### 2. Wrap A2A Artifacts with Provenance ```javascript -const { JACSA2AIntegration } = require('jacsnpm'); +const { JACSA2AIntegration } = require('@hai-ai/jacs/a2a'); const a2a = new JACSA2AIntegration(); // Wrap any A2A artifact @@ -86,7 +86,8 @@ chain = a2a.create_chain_of_custody([step1, step2, step3]) Serve these endpoints for A2A discovery: -- `/.well-known/agent.json` - A2A Agent Card (JWS signed) +- `/.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 @@ -98,10 +99,7 @@ Serve these endpoints for A2A discovery: "extensions": [{ "uri": "urn:hai.ai:jacs-provenance-v1", "description": "JACS cryptographic document signing", - "params": { - "supportedAlgorithms": ["dilithium", "rsa", "ecdsa"], - "verificationEndpoint": "/jacs/verify" - } + "required": false }] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cf87cbb..8c9c1553f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,70 @@ +## 0.6.0 + +### Security audit (MVP) + +- **`audit()`**: New read-only security audit and health checks. Returns structured report (risks, health_checks, summary); checks config/directories, secrets/keys, trust store, storage paths, quarantine/failed files, and optionally re-verifies N recent documents. Exposed in Rust (`jacs::audit`), binding-core, jacspy (`jacs.audit()`), jacsnpm (`jacs.audit(options?)`), and MCP tool `jacs_audit`. Documented in jacsbook (Security Model) and READMEs. + +### DX Improvements + +- **Programmatic `create()` API**: New `CreateAgentParams` struct and `create_with_params()` method for non-interactive agent creation across all bindings (Rust, Python, Node.js, Go) +- **Programmatic create hardening**: `create_with_params()` now generates schema-valid agent payloads (including required service metadata), writes complete config key filename fields, and restores caller environment overrides after creation instead of mutating process env state. +- **Python create() password UX**: `jacspy.simple.create()` now guarantees immediate post-create load using the provided password even when `JACS_PRIVATE_KEY_PASSWORD` was initially unset. +- **`verify_by_id()` method**: Load and verify documents by ID from storage, with helpful error when `verify()` is called with non-JSON input +- **Key re-encryption**: New `reencrypt_key(old_password, new_password)` API and `jacs key reencrypt` CLI command +- **Password requirements documentation**: `password_requirements()` function, requirements shown before password prompts, clearer error messages +- **`pq-dilithium` deprecated**: Use `pq2025` (ML-DSA-87, FIPS-204) instead. `pq-dilithium` still works but emits deprecation warnings +- **Go default algorithm fix**: Changed default from `ed25519` to `pq2025` +- **Improved error messages**: `user_message()` method on errors, categorized `From>` conversion +- **Version alignment**: All packages (jacs-mcp, binding-core, jacspy, jacsnpm) aligned to 0.6.0 + +### Packaging and Release Hardening + +- **jacsnpm install behavior**: Removed install-time native build (`npm install` no longer runs `napi build`), so consumers do not need a Rust toolchain at install time. +- **jacsnpm publish contents**: Added `mcp.d.ts` to published package files so `@hai-ai/jacs/mcp` TypeScript types resolve correctly from npm tarballs. +- **npm release checks**: Added release-time validation that required `.node` binaries exist and `npm pack --dry-run` contains all exported API files before `npm publish`. +- **Expanded npm binary coverage**: npm release workflow builds and validates hosted Linux/macOS targets (including Linux `arm64` musl) with best-effort builds for additional Linux/FreeBSD architectures; Windows artifacts are currently optional while checkout path compatibility is being remediated. +- **jacspy sdist portability**: Excluded `jacspy/examples/**` from crate packaging so `maturin sdist` no longer fails on colon-containing fixture filenames. +- **jacspy packaging source of truth**: Removed stale `jacspy/setup.py`; `pyproject.toml` + `maturin` now define Python package metadata and build behavior. +- **jacspy PyO3 compatibility fix**: Replaced deprecated PyO3 conversion APIs (`*_bound`, `into_py`) with current APIs in Rust bindings so `uv run maturin build --release` succeeds under strict warning-as-error CI settings. +- **CI early failure checks**: Added PR/push-time sdist build verification in Python CI, plus a uv-based wheel smoke test and npm tarball smoke install/import test. +- **Expanded wheel coverage**: PyPI release and CI wheel workflows now cover additional hosted targets (including Linux musl variants) with platform-specific build paths. +- **Python test correctness**: Updated unreachable-key-service test to use a valid UUID so it exercises the intended network error path. +- **Rust toolchain pinning for Python builds**: Python wheel CI and PyPI wheel release jobs now pin Rust `1.93` (matching workspace `rust-version`) to reduce toolchain drift. +- **Python CI trigger reliability**: Removed path filters from `Python (jacs)` workflow so Python tests always run on `push`/`pull_request` to `main` and are not silently skipped by unrelated file changes. +- **Python wheel CI on PRs**: `build-jacs-wheels` now runs for pull requests as well as pushes, so wheel build coverage is no longer shown as a skipped job in PR checks. +- **Temporary Windows CI bypass**: Windows runner jobs were removed from active CI/release matrices because GitHub Windows checkout cannot handle existing colon-named tracked fixtures. Linux/macOS coverage remains fully enabled to unblock releases; Windows automation will return after fixture/path normalization. + +### A2A Interoperability Hardening + +- **Foreign A2A signature verification (Rust core)**: `verify_wrapped_artifact()` now resolves signer keys using configured key resolution order (`local`, `dns`, `hai`) and performs cryptographic verification when key material is available. Unresolvable keys now return explicit `Unverified` status instead of optimistic success. +- **Parent signature verification depth (Node.js/Python)**: A2A wrappers now recursively verify `jacsParentSignatures` and report `parent_signatures_valid` based on actual verification outcomes. +- **Well-known document parity**: Node.js and Python A2A helpers now include `/.well-known/jwks.json` in generated well-known document sets, matching Rust integration expectations. +- **JWKS correctness improvements**: Removed placeholder EC JWK data in core A2A key helpers and added explicit Ed25519 JWK/JWS support (`EdDSA`) for truthful key metadata. +- **Node.js create() 12-factor UX**: `@hai-ai/jacs/simple.create()` now accepts password from `JACS_PRIVATE_KEY_PASSWORD` when `options.password` is omitted, with explicit error if neither is provided. + +### Security + +- **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. +- **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`. + +### Documentation + +- **SECURITY.md**: Added short "Security model" subsection (password via env only, keys encrypted at rest, path validation, no secrets in config). +- **README**: First-run minimal setup, verification and key resolution (`JACS_KEY_RESOLUTION`), supported algorithms, troubleshooting, dependency audit instructions, runtime password note. +- **jacsnpm**: Documented that `overrides` for `body-parser` and `qs` are for security (CVE-2024-45590). Added `npm audit` step in CI. +- **jacspy**: Aligned key resolution docstring with Rust (comma-separated `local,dns,hai`); added note to run `pip audit` when using optional deps. +- **A2A documentation refresh**: Added detailed jacsbook guide at `integrations/a2a.md`, corrected stale A2A quickstart endpoints/imports (`agent-card.json`, `jwks.json`, `@hai-ai/jacs/a2a`), and aligned Node.js package references to `@hai-ai/jacs` across docs. +- **Agreement testing guidance**: Expanded jacsbook advanced testing docs with strict agreement-completion semantics and two-agent harness patterns for Python and Node.js. +- **README clarity**: Added explicit note that `check_agreement` is strict and fails until all required signers have signed. +- **Rust agreement test strictness**: Core `agreement_test` now explicitly asserts that `check_agreement` fails after the first signature and only succeeds after both required agents sign. + + ## 0.5.2 ### Security @@ -420,4 +487,4 @@ proof of concept - encrypt private key in memory, wipe - check and verify signatures - refactors - - allow custom json schema verification \ No newline at end of file + - allow custom json schema verification diff --git a/LINES_OF_CODE.md b/LINES_OF_CODE.md index 0806b9d2f..2999ac39b 100644 --- a/LINES_OF_CODE.md +++ b/LINES_OF_CODE.md @@ -1,13 +1,13 @@ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Language Files Lines Code Comments Blanks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Go 13 4292 3144 538 610 - Python 154 33765 26371 1613 5781 - TypeScript 9 2467 1365 889 213 + Go 13 4810 3571 587 652 + Python 155 34534 27061 1621 5852 + TypeScript 9 3134 1660 1236 238 ───────────────────────────────────────────────────────────────────────────────── - Rust 338 88907 73300 5155 10452 - |- Markdown 241 24447 635 17946 5866 - (Total) 113354 73935 23101 16318 + Rust 354 101187 83405 5735 12047 + |- Markdown 260 25449 667 18762 6020 + (Total) 126636 84072 24497 18067 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Total 514 153878 104815 26141 22922 + Total 531 169114 116364 27941 24809 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/README.md b/README.md index 639431ce2..f0e579bcd 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,30 @@ # JACS -**JSON Agent Communication Standard** - Cryptographic signing and verification for AI agents. +**JSON Agent Communication Standard** - Data provenance and cryptographic signing for AI agents. **[Documentation](https://humanassisted.github.io/JACS/)** | **[Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html)** | **[API Reference](https://humanassisted.github.io/JACS/nodejs/api.html)** ## What is JACS? -JACS provides cryptographic signatures for AI agent communications. Every message, file, or artifact can be signed and verified, ensuring: +JACS is an open data provenance toolkit that lets any AI agent or application sign, verify, and track the origin of data. It works standalone -- no server, no account required. Optionally register with [HAI.ai](https://hai.ai) for cross-organization key discovery and attestation. -- **Authenticity**: Prove who created the data -- **Integrity**: Detect tampering +Available as a library for **Python**, **Node.js**, **Go**, and **Rust**, plus a CLI and MCP servers. + +**Why use JACS?** + +- **Data provenance**: Know who created data, when, and whether it's been modified +- **Decentralized by default**: Runs entirely local -- keys and signatures stay on your machine +- **Tamper detection**: Cryptographic hashes catch any change, accidental or malicious - **Non-repudiation**: Signed actions can't be denied +- **Post-quantum ready**: NIST-standardized ML-DSA (FIPS-204) signatures out of the box + +## First run (minimal setup) + +1. Copy `jacs.config.example.json` to `jacs.config.json` (or use `jacs config create`). +2. Set `JACS_PRIVATE_KEY_PASSWORD` in your environment (never put the password in the config file). +3. Run `jacs agent create` or `jacs init` as documented, then sign/verify as in Quick Start below. + +For runtime signing, set `JACS_PRIVATE_KEY_PASSWORD` (or use a keychain). The CLI can prompt during init; scripts and servers must set the env var. ## Quick Start @@ -78,12 +92,45 @@ jacs document create -f mydata.json | Function | Description | |----------|-------------| +| `create(name, options)` | Create a new agent programmatically (non-interactive) | | `load(config)` | Load agent from config file | | `sign_message(data)` | Sign any JSON data | | `sign_file(path, embed)` | Sign a file | -| `verify(document)` | Verify a signed document | +| `verify(document)` | Verify a signed document (JSON string) | +| `verify_standalone(document, options)` | Verify without loading an agent (one-off) | +| `verify_by_id(id)` | Verify a document by storage ID (`uuid:version`) | +| `register_with_hai(options)` | Register the loaded agent with HAI.ai | +| `get_dns_record(domain, ttl?)` | Get DNS TXT record line for the agent | +| `get_well_known_json()` | Get well-known JSON (e.g. for `/.well-known/jacs-pubkey.json`) | +| `reencrypt_key(old, new)` | Re-encrypt the private key with a new password | | `verify_self()` | Verify agent integrity | | `get_public_key()` | Get public key for sharing | +| `audit(options)` | Run a read-only security audit (risks, health checks, summary) | +| `generate_verify_link(document, base_url)` | Generate a shareable hai.ai verification URL for a signed document | + +## Use Cases + +These scenarios show how teams use JACS today. Each links to a [detailed walkthrough](USECASES.md). + +**Prove that pipeline outputs are authentic.** A build service signs every JSON artifact it emits -- deployment configs, test reports, compliance summaries. 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 it sends 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) + +**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, and Go. Auditors get cryptographic proof instead of trust-only logs. [Full scenario](USECASES.md#4-a-go-node-or-python-agent-with-strong-data-provenance) + +### Other use cases + +- **Sign AI outputs** -- Wrap any model response or generated artifact with a signature before it leaves your service. Downstream consumers call `verify()` to confirm which agent produced it and that nothing was altered in transit. +- **Sign files and documents** -- Contracts, reports, configs, or any file on disk: `sign_file(path)` attaches a cryptographic signature. Recipients verify the file's integrity and origin without trusting the transport layer. +- **Build MCP servers with signed tool calls** -- Every tool invocation through your MCP server can carry the agent's signature automatically, giving clients proof of which agent executed the call and what it returned. +- **Establish agent-to-agent trust** -- Two or more agents can sign agreements and verify each other's identities using the trust store. Multi-party signatures let you build workflows where each step is attributable. +- **Agreement verification is strict** -- `check_agreement` fails until all required signers have signed, so partial approvals cannot be mistaken for completion. +- **Track data provenance through pipelines** -- As data moves between services, each stage signs its output. The final consumer can walk the signature chain to verify every transformation back to the original source. +- **Verify without loading an agent** -- Use `verify_standalone()` when you just need to check a signature in a lightweight service or script. No config file, no trust store, no agent setup required. +- **Register with HAI.ai for key discovery** -- Publish your agent's public key to [HAI.ai](https://hai.ai) with `register_with_hai()` so other organizations can discover and verify your agent without exchanging keys out-of-band. +- **Audit your JACS setup** -- Call `audit()` to check config, keys, trust store health, and re-verify recent documents. Returns structured risks and health checks so you can catch misconfigurations before they matter. +- **Share verification links** -- Generate a `https://hai.ai/jacs/verify?s=...` URL with `generate_verify_link()` and embed it in emails, Slack messages, or web pages. Recipients click to verify the document without installing anything. +- **Air-gapped and offline environments** -- Set `JACS_KEY_RESOLUTION=local` and distribute public keys manually. JACS works fully offline with no network calls once keys are in the local trust store. ## MCP Integration @@ -113,6 +160,23 @@ agent_card = a2a.export_agent_card(agent_data) wrapped = a2a.wrap_artifact_with_provenance(artifact, "task") ``` +JACS A2A interoperability now includes foreign-agent signature verification using configured key resolution (`local`, `dns`, `hai`) and publishes `/.well-known/agent-card.json` plus `/.well-known/jwks.json` for verifier compatibility. See the [A2A interoperability guide](./jacs/docs/jacsbook/src/integrations/a2a.md) for deployment details. + +## Verification and key resolution + +When verifying signatures, JACS looks up signers' public keys in an order controlled by `JACS_KEY_RESOLUTION` (comma-separated: `local`, `dns`, `hai`). Default is `local,hai` (local trust store first, then HAI key service). For air-gapped use, set `JACS_KEY_RESOLUTION=local`. + +## Supported algorithms + +Signing and verification support: **ring-Ed25519**, **RSA-PSS**, **pq2025** (ML-DSA-87, FIPS-204, recommended). `pq-dilithium` is deprecated -- use `pq2025` instead. Set `jacs_agent_key_algorithm` in config or `JACS_AGENT_KEY_ALGORITHM` in the environment. + +## Troubleshooting + +- **Config not found**: Copy `jacs.config.example.json` to `jacs.config.json` and set required env vars (see First run). +- **Private key decryption failed**: Wrong password or wrong key file. Ensure `JACS_PRIVATE_KEY_PASSWORD` matches the password used when generating keys. +- **Required environment variable X not set**: Set the variable per the [config docs](https://humanassisted.github.io/JACS/); common ones are `JACS_KEY_DIRECTORY`, `JACS_DATA_DIRECTORY`, `JACS_AGENT_PRIVATE_KEY_FILENAME`, `JACS_AGENT_PUBLIC_KEY_FILENAME`, `JACS_AGENT_KEY_ALGORITHM`, `JACS_AGENT_ID_AND_VERSION`. +- **Algorithm detection failed**: Set the `signingAlgorithm` field in the document, or use `JACS_REQUIRE_EXPLICIT_ALGORITHM=true` to require it. + ## Post-Quantum Cryptography JACS supports NIST-standardized post-quantum algorithms: @@ -134,10 +198,11 @@ JACS supports NIST-standardized post-quantum algorithms: | [jacspy/](./jacspy/) | Python bindings | | [jacsnpm/](./jacsnpm/) | Node.js bindings | | [jacsgo/](./jacsgo/) | Go bindings | +| [jacs-mcp/](./jacs-mcp/) | MCP server for agent state and HAI integration | ## Version -Current version: **0.5.1** +Current version: **0.6.0** ## License diff --git a/SECURITY.md b/SECURITY.md index 03209c12b..5e25683af 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,9 +1,15 @@ # Security Policy +## Current hardening highlights + +- Untrusted filesystem path inputs are validated centrally (`require_relative_path_safe`) and include checks for traversal segments, null bytes, and Windows drive-prefixed absolute paths. +- Filesystem schema loading is opt-in and restricted to configured allowed roots via normalized/canonical path containment checks. +- HAI registration verification endpoints require HTTPS (`HAI_API_URL`), with HTTP allowed only for localhost testing. +- A2A wrapped-artifact verification fails closed for unresolved foreign signer keys and reports explicit `Unverified` status until keys are resolved via configured key sources. + If you think you have identified a security issue with a JACS, do not open a public issue. To responsibly report a security issue, please navigate to the "Security" tab for the repo, and click "Report a vulnerability". Be sure to include as much detail as necessary in your report. As with reporting normal issues, a minimal reproducible example will help the maintainers address the issue faster. Thank you for supporting JACS. - diff --git a/USECASES.md b/USECASES.md new file mode 100644 index 000000000..ecccdad2d --- /dev/null +++ b/USECASES.md @@ -0,0 +1,105 @@ +# JACS use cases + +This document describes fictional but detailed scenarios for using JACS. Each section includes the situation, technical flow, and outcome. Use these as templates for your own workflows. + +**Language-specific API names:** Function names differ by language. See the API references: [Node (jacsnpm)](https://humanassisted.github.io/JACS/nodejs/api.html), [Python (jacspy)](https://humanassisted.github.io/JACS/python/api.html), [Rust (core)](https://humanassisted.github.io/JACS/rust/library.html). + +--- + +## 1. Verifying that JSON files came from a specific program + +**Scenario.** Meridian Build Co. runs an internal pipeline that emits JSON artifacts: deployment configs, test reports, and compliance summaries. These files are consumed by other teams and by external auditors. The problem: anyone could drop a JSON file into a shared drive and claim it came from "the build service." Meridian needs a way for consumers to cryptographically verify that a given JSON file was produced by their official build program and has not been altered. + +**Why JACS.** JACS gives the build program a single agent identity. Every artifact is signed at emission with `sign_message` or `sign_file`. Downstream systems and auditors verify with `verify()` (or `verify_by_id` when they have a storage ID; `verify_by_id` uses local storage only). No central server is required; keys stay with the build environment. Consumers must either load a config (so JACS can resolve the signer’s key from the trust store, DNS, or HAI) or use `verify_standalone()` for one-off verification with explicit key resolution options. Key discovery order is configured via `JACS_KEY_RESOLUTION` and the [configuration reference](https://humanassisted.github.io/JACS/reference/configuration.html). + +**Technical flow.** + +1. **One agent per program.** The build service runs with its own JACS config: `jacs.config.json` (or equivalent) and a dedicated key pair. Create the agent once (e.g. `jacs init` or `create()` in your language); thereafter the pipeline loads it with `load(config)`. +2. **Sign at emission.** When the pipeline produces a JSON artifact (e.g. `report.json` or an in-memory payload), it signs before writing or sending: + - **Payload as JSON:** `sign_message(payload)` → signed document (e.g. `signed.raw`). + - **File on disk:** `sign_file(path)` or `sign_file(path, embed: true)` → signed document that either references the file by hash or embeds its content. +3. **Verify at consumption.** Consumers receive the signed document (file or string). They call `verify(signed.raw)` (or `verify_by_id(id)` if they have the stored document ID). JACS resolves the signer’s public key (e.g. via `JACS_KEY_RESOLUTION=local,hai`), checks the signature and integrity, and returns validity and signer identity. +4. **Trust.** Consumers trust the build service’s public key (stored locally or discovered via HAI). A valid verification means the JSON came from that program and was not modified. + +**Outcome.** Meridian’s consumers and auditors can prove that each JSON file they use was produced by the designated build program and is unaltered. Tampering or forgery is detected by failed verification. + +--- + +## 2. Protecting your agent's identity on the internet + +**Scenario.** A research lab runs a public-facing AI agent that answers questions and participates in open forums. They want the agent’s messages to be **verifiable** (recipients can cryptographically confirm that the message came from that agent and wasn’t changed) but they do **not** want to expose who operates the agent or where it runs. In other words: the agent has a stable, verifiable identity; the operator’s identity stays off the internet. + +**Why JACS.** JACS provides a pseudonymous agent identity: a key pair and agent ID that are not tied to the operator’s name or infrastructure. The agent signs messages internally; only the **public** key is published (via [DNS](https://humanassisted.github.io/JACS/rust/dns.html) and optionally HAI). There is **no public sign endpoint**—signing happens only inside the agent’s environment. Recipients verify with the published public key using `verify()` (core JACS) or `jacs_verify_auto` (OpenClaw/moltyjacs), so they get proof of origin and integrity without learning who runs the agent. The [well-known endpoint](https://humanassisted.github.io/JACS/integrations/openclaw.html) (e.g. `/.well-known/jacs-pubkey.json`) and DNS TXT format support key discovery. + +**Technical flow.** + +1. **Create an agent identity.** Run `jacs init` (or equivalent) to generate a key pair and agent ID. Do not embed operator or organization details in the agent document; use a neutral name/description if needed. +2. **Sign only internally.** All signing is done inside your infrastructure. The agent calls `sign_message(...)` (or the moltyjacs `jacs_sign` tool) before sending any message. Never expose an API that allows external parties to request signatures. +3. **Publish only the public key.** Publish the agent’s public key so others can verify: + - **DNS:** Publish a TXT record so that key discovery works for your domain (e.g. `agent.example.com`). Recipients can then resolve and verify without contacting your backend. + - **Well-known endpoint:** Expose `GET /.well-known/jacs-pubkey.json` (and optionally `/jacs/status`, `/jacs/verify`, `/jacs/attestation`) so that anyone can fetch the public key and attestation status. Do **not** expose a sign endpoint. + - **Optional HAI.ai:** Register the agent with HAI so others can discover the key via HAI’s key service; this still does not reveal who operates the agent. +4. **Recipients verify.** Recipients receive the signed message (over any channel: HTTP, MCP, etc.) and call `verify(signed_document)` (core JACS) or `jacs_verify_auto(signed_document)` (OpenClaw/moltyjacs). JACS fetches the public key (from DNS, HAI, or a provided URL), verifies the signature and integrity, and returns the signer’s identity (agent ID / key hash). The recipient gets assurance that the message came from that agent and was not modified; they do not learn who runs it. + +**Outcome.** The lab’s agent can participate in public conversations with verifiable, signed messages. Third parties can trust that messages are from that agent and unaltered, while the operator’s identity remains protected because signing is internal-only and only the public key is published. + +--- + +## 3. Registering and testing your agent on HAI.ai + +**Scenario.** The team at Grove Software is building an AI agent they plan to use with partners and eventually list on HAI.ai. They want to register the agent with HAI for attestation and discoverability, and to test that registration and verification work before going live. + +**Why JACS.** JACS agents can be registered with HAI.ai. Registration publishes the agent’s public key and optional metadata to HAI’s key service, so other HAI users (and systems using `JACS_KEY_RESOLUTION=...,hai`) can discover and verify the agent. Attestation status (e.g. verified, verified_at) and verification claim (e.g. `verified-hai.ai`) give partners a clear trust level. + +**Technical flow.** + +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: + - **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). + - **CLI / other languages:** If available, use the equivalent (e.g. `openclaw jacs register` when using moltyjacs). Pass the API key via `--api-key` or `HAI_API_KEY`. + Registration sends the agent JSON to HAI’s API (`POST /api/v1/agents/register`). HAI stores the key for discovery and may return attestation status. +4. **Check attestation.** After registration, verify that HAI shows the agent as attested (e.g. `openclaw jacs attestation` or the HAI client’s attestation/status call). Optionally set the verification claim to `verified-hai.ai` so that verifiers recognize the agent as HAI-registered. +5. **Test verification.** From another environment or as a partner, resolve the agent’s key with `JACS_KEY_RESOLUTION=local,hai` and verify a signed document from that agent. Confirm that `verify()` or `jacs_verify_auto()` succeeds and reports the expected signer. + +**Outcome.** Grove’s agent is registered with HAI and discoverable. Partners can verify signed documents from the agent using HAI’s key service, and the team has validated attestation and verification before production. + +--- + +## 4. A Go, Node, or Python agent with strong data provenance + +**Scenario.** A compliance-sensitive application (e.g. in finance or healthcare) is implemented as an agent in Go, JavaScript/Node, or Python. Every output—recommendations, reports, or audit events—must be provable: the organization must be able to show exactly which agent produced the data and that it has not been altered. They need a simple, robust integration with JACS that works the same way across languages. + +**Why JACS.** JACS’s simple API (`load`, `sign_message`, `verify`) is available in jacspy, jacsnpm, and jacsgo. Keys stay local; no central server is required. Optional HAI or DNS resolution allows external parties to verify without pre-sharing keys. The same patterns apply in all three languages. + +**Technical flow.** + +1. **Load the agent.** At startup, load the agent from config (e.g. `jacs.config.json`): + - **Python:** `simple.load('./jacs.config.json')` (or `load()` with default path). + - **Node:** `jacs.load('./jacs.config.json')`. + - **Go:** `jacs.Load(nil)` or load from a config path. + Ensure `JACS_PRIVATE_KEY_PASSWORD` is set in the environment for signing; never put the password in the config file. +2. **Sign every critical output.** Before returning or persisting any result that must be attributable and tamper-evident, sign it: `sign_message(payload)` (or the language equivalent). Attach or store the signed document (e.g. `signed.raw`) with the workflow or response. +3. **Verify when consuming.** Any consumer (internal or external) that receives a signed document calls `verify(signed.raw)` (requires a loaded agent) or `verify_standalone(signed.raw, options)` for one-off verification without agent setup. For external signers, use key resolution (e.g. `JACS_KEY_RESOLUTION=local,hai` or `local,dns,hai`) so JACS can fetch the signer’s public key. The result includes `valid` and signer identity (e.g. `signer_id`). +4. **Air-gapped or locked-down.** For fully offline or high-security environments, set `JACS_KEY_RESOLUTION=local` and distribute public keys out-of-band. No network is required for verification once keys are in the local trust store. + +**Outcome.** The organization has a single, language-agnostic pattern for data provenance: every important output is signed by a known agent and can be verified for origin and integrity. Compliance and audits can rely on cryptographic proof instead of trust-only logs. + +--- + +## 5. OpenClaw (moltyjacs): proving your agent actually sent a message + +**Scenario.** You run an OpenClaw agent with the moltyjacs plugin. You (or another agent) need to be sure that a specific message really came from your OpenClaw agent—for example, to enforce commitments, settle disputes, or satisfy an auditor. Without proof, anyone could claim "the agent said X." + +**Why JACS and moltyjacs.** The moltyjacs plugin gives your OpenClaw agent a JACS identity and tools to sign and verify. When the agent sends a message, it signs it with `jacs_sign` before sending. The signed payload travels with the message; the recipient (human or agent) verifies with `jacs_verify_auto` (or with the agent’s public key). That provides cryptographic proof of origin and integrity. + +**Technical flow.** + +1. **Install and initialize.** Install the moltyjacs plugin (e.g. `openclaw plugins install moltyjacs` or from npm/ClawHub). Run `openclaw jacs init` to create the agent’s key pair and config. Optionally register with HAI (`openclaw jacs register`) so others can discover your key. +2. **Sign outbound messages.** When the agent sends a message that should be attributable, it uses the `jacs_sign` tool (or equivalent) to sign the message payload. The signed document (e.g. JACS JSON with signature and payload) is what gets sent—over HTTP, MCP, or any channel. +3. **Recipient verifies.** The recipient receives the signed document. They call `jacs_verify_auto(signed_document)`. Moltyjacs (or the JACS library) fetches the signer’s public key if needed (from DNS, HAI, or local store), verifies the signature and hash, and returns whether the document is valid and who signed it (agent ID / key hash). +4. **Proof of sending.** A valid verification means the message was signed by the private key corresponding to the published public key for that agent. So long as the private key is only used by your OpenClaw agent, you have proof that the agent sent that message and that it was not altered in transit. + +**Outcome.** You and your partners can prove that a given message was sent by your OpenClaw agent. The signature travels with the message; no separate PKI or custom infra is required. For more on moltyjacs and OpenClaw, see the [moltyjacs repository](https://github.com/HumanAssisted/moltyjacs) and the [OpenClaw integration](https://humanassisted.github.io/JACS/integrations/openclaw.html) in the JACS docs. diff --git a/binding-core/Cargo.toml b/binding-core/Cargo.toml index 1fa5bd4e7..8cca7c8c5 100644 --- a/binding-core/Cargo.toml +++ b/binding-core/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "jacs-binding-core" -version = "0.5.0" +version = "0.6.0" edition = "2024" -rust-version = "1.85" +rust-version = "1.93" resolver = "3" description = "Shared core logic for JACS language bindings (Python, Node.js, etc.)" readme = "../README.md" diff --git a/binding-core/src/conversion.rs b/binding-core/src/conversion.rs index 81a424c81..4ca0d9586 100644 --- a/binding-core/src/conversion.rs +++ b/binding-core/src/conversion.rs @@ -37,7 +37,10 @@ pub fn try_decode_bytes_object(obj: &serde_json::Map) -> Option Value { let base64_str = general_purpose::STANDARD.encode(bytes); let mut map = JsonMap::new(); - map.insert(TYPE_MARKER_KEY.to_string(), Value::String(type_marker.to_string())); + map.insert( + TYPE_MARKER_KEY.to_string(), + Value::String(type_marker.to_string()), + ); map.insert(DATA_KEY.to_string(), Value::String(base64_str)); Value::Object(map) } diff --git a/binding-core/src/hai.rs b/binding-core/src/hai.rs index c86956645..1dfc261d5 100644 --- a/binding-core/src/hai.rs +++ b/binding-core/src/hai.rs @@ -59,7 +59,7 @@ use serde::{Deserialize, Serialize}; use std::fmt; use std::sync::Arc; use std::time::Duration; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::{RwLock, mpsc}; // ============================================================================= // Error Types @@ -82,6 +82,8 @@ pub enum HaiError { 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 { @@ -94,10 +96,29 @@ impl fmt::Display for HaiError { 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 {} // ============================================================================= @@ -389,10 +410,7 @@ impl HaiClient { /// - `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)?; + let api_key = self.api_key.as_ref().ok_or(HaiError::AuthRequired)?; // Get the agent JSON from the wrapper let agent_json = agent @@ -451,10 +469,7 @@ impl HaiClient { /// - `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)?; + let api_key = self.api_key.as_ref().ok_or(HaiError::AuthRequired)?; // Get the agent JSON and extract the ID let agent_json = agent @@ -467,7 +482,9 @@ impl HaiClient { 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()))? + .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); @@ -553,7 +570,9 @@ impl HaiClient { 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()))? + .ok_or_else(|| { + HaiError::InvalidResponse("Agent JSON missing jacsId field".to_string()) + })? .to_string(); let url = format!("{}/api/v1/benchmarks/run", self.endpoint); @@ -835,7 +854,10 @@ impl HaiClient { /// 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) + matches!( + state, + ConnectionState::Connected | ConnectionState::Reconnecting + ) } } @@ -846,26 +868,26 @@ impl HaiClient { /// 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(), - }, - } - } + "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() }, + event: if event_type.is_empty() { + "message".to_string() + } else { + event_type.to_string() + }, data: data.to_string(), }, } @@ -893,8 +915,7 @@ mod tests { #[test] fn test_client_builder() { - let client = HaiClient::new("https://api.hai.ai") - .with_api_key("test-key"); + 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())); @@ -1191,8 +1212,7 @@ mod tests { #[tokio::test] async fn test_connection_state_starts_disconnected() { - let client = HaiClient::new("https://api.hai.ai") - .with_api_key("test-key"); + let client = HaiClient::new("https://api.hai.ai").with_api_key("test-key"); let state = client.connection_state().await; assert_eq!(state, ConnectionState::Disconnected); @@ -1200,19 +1220,20 @@ mod tests { #[tokio::test] async fn test_is_connected_when_disconnected() { - let client = HaiClient::new("https://api.hai.ai") - .with_api_key("test-key"); + 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"); + 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); + assert_eq!( + client.connection_state().await, + ConnectionState::Disconnected + ); } } diff --git a/binding-core/src/lib.rs b/binding-core/src/lib.rs index 602f8875f..1846fd365 100644 --- a/binding-core/src/lib.rs +++ b/binding-core/src/lib.rs @@ -6,13 +6,18 @@ //! by any language binding. Each binding implements the `BindingError` trait //! to convert errors to their native format. -use jacs::agent::document::DocumentTraits; +use jacs::agent::agreement::Agreement; +use jacs::agent::document::{DocumentTraits, JACSDocument}; use jacs::agent::payloads::PayloadTraits; -use jacs::agent::{Agent, AGENT_REGISTRATION_SIGNATURE_FIELDNAME, AGENT_SIGNATURE_FIELDNAME}; +use jacs::agent::{ + AGENT_AGREEMENT_FIELDNAME, AGENT_REGISTRATION_SIGNATURE_FIELDNAME, AGENT_SIGNATURE_FIELDNAME, + Agent, +}; use jacs::config::Config; -use jacs::crypt::hash::hash_string as jacs_hash_string; use jacs::crypt::KeyManager; -use serde_json::Value; +use jacs::crypt::hash::hash_string as jacs_hash_string; +use serde_json::{Value, json}; +use std::collections::HashMap; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; pub mod conversion; @@ -139,6 +144,81 @@ impl From> for BindingCoreError { /// Result type for binding core operations. pub type BindingResult = Result; +fn is_editable_level(level: &str) -> bool { + matches!(level, "artifact" | "config") +} + +fn normalize_agent_id_for_compare(agent_id: &str) -> &str { + agent_id.split(':').next().unwrap_or(agent_id) +} + +fn extract_agreement_payload(value: &Value) -> Value { + if let Some(payload) = value.get("jacsDocument") { + return payload.clone(); + } + if let Some(payload) = value.get("content") { + return payload.clone(); + } + if let Some(obj) = value.as_object() { + let mut filtered = serde_json::Map::new(); + for (k, v) in obj { + if !k.starts_with("jacs") && k != "$schema" { + filtered.insert(k.clone(), v.clone()); + } + } + if !filtered.is_empty() { + return Value::Object(filtered); + } + } + Value::Null +} + +fn create_editable_agreement_document(agent: &mut Agent, payload: Value) -> BindingResult { + let wrapped = json!({ + "jacsType": "artifact", + "jacsLevel": "artifact", + "content": payload + }); + agent + .create_document_and_load(&wrapped.to_string(), None, None) + .map_err(|e| { + BindingCoreError::document_failed(format!( + "Failed to create editable agreement document: {}", + e + )) + }) +} + +fn ensure_editable_agreement_document( + agent: &mut Agent, + document_string: &str, +) -> BindingResult { + match agent.load_document(document_string) { + Ok(doc) => { + let level = doc.value.get("jacsLevel").and_then(|v| v.as_str()).unwrap_or(""); + if is_editable_level(level) { + Ok(doc) + } else { + let payload = extract_agreement_payload(doc.getvalue()); + create_editable_agreement_document(agent, payload) + } + } + Err(load_err) => { + if let Ok(parsed) = serde_json::from_str::(document_string) + && (parsed.get("jacsId").is_some() || parsed.get("jacsVersion").is_some()) + { + return Err(BindingCoreError::document_failed(format!( + "Failed to load document: {}", + load_err + ))); + } + let payload = serde_json::from_str::(document_string) + .unwrap_or_else(|_| Value::String(document_string.to_string())); + create_editable_agreement_document(agent, payload) + } + } +} + // ============================================================================= // Wrapper Type for Agent with Arc> // ============================================================================= @@ -278,13 +358,17 @@ impl AgentWrapper { let mut agent = self.lock()?; if let Some(file) = agentfile { - let loaded_agent = jacs::load_agent(Some(file)) - .map_err(|e| BindingCoreError::agent_load(format!("Failed to load agent: {}", e)))?; + let loaded_agent = jacs::load_agent(Some(file)).map_err(|e| { + BindingCoreError::agent_load(format!("Failed to load agent: {}", e)) + })?; *agent = loaded_agent; } agent.verify_self_signature().map_err(|e| { - BindingCoreError::verification_failed(format!("Failed to verify agent signature: {}", e)) + BindingCoreError::verification_failed(format!( + "Failed to verify agent signature: {}", + e + )) })?; agent.verify_self_hash().map_err(|e| { @@ -389,23 +473,21 @@ impl AgentWrapper { agreement_fieldname: Option, ) -> BindingResult { let mut agent = self.lock()?; + let base_doc = ensure_editable_agreement_document(&mut agent, document_string)?; + let document_key = base_doc.getkey(); + let agreement_doc = agent + .create_agreement( + &document_key, + agentids.as_slice(), + question.as_deref(), + context.as_deref(), + agreement_fieldname, + ) + .map_err(|e| { + BindingCoreError::agreement_failed(format!("Failed to create agreement: {}", e)) + })?; - jacs::shared::document_add_agreement( - &mut agent, - document_string, - agentids, - None, - None, - question, - context, - None, - None, - false, - agreement_fieldname, - ) - .map_err(|e| { - BindingCoreError::agreement_failed(format!("Failed to create agreement: {}", e)) - }) + Ok(agreement_doc.value.to_string()) } /// Sign an agreement on a document. @@ -415,20 +497,17 @@ impl AgentWrapper { agreement_fieldname: Option, ) -> BindingResult { let mut agent = self.lock()?; + let doc = agent.load_document(document_string).map_err(|e| { + BindingCoreError::document_failed(format!("Failed to load document: {}", e)) + })?; + let document_key = doc.getkey(); + let signed_doc = agent + .sign_agreement(&document_key, agreement_fieldname) + .map_err(|e| { + BindingCoreError::agreement_failed(format!("Failed to sign agreement: {}", e)) + })?; - jacs::shared::document_sign_agreement( - &mut agent, - document_string, - None, - None, - None, - None, - false, - agreement_fieldname, - ) - .map_err(|e| { - BindingCoreError::agreement_failed(format!("Failed to sign agreement: {}", e)) - }) + Ok(signed_doc.value.to_string()) } /// Create a new JACS document. @@ -452,9 +531,7 @@ impl AgentWrapper { attachments, embed, ) - .map_err(|e| { - BindingCoreError::document_failed(format!("Failed to create document: {}", e)) - }) + .map_err(|e| BindingCoreError::document_failed(format!("Failed to create document: {}", e))) } /// Check an agreement on a document. @@ -464,11 +541,88 @@ impl AgentWrapper { agreement_fieldname: Option, ) -> BindingResult { let mut agent = self.lock()?; + let doc = agent.load_document(document_string).map_err(|e| { + BindingCoreError::document_failed(format!("Failed to load document: {}", e)) + })?; + let document_key = doc.getkey(); + let agreement_fieldname_key = agreement_fieldname + .clone() + .unwrap_or_else(|| AGENT_AGREEMENT_FIELDNAME.to_string()); - jacs::shared::document_check_agreement(&mut agent, document_string, None, agreement_fieldname) + agent + .check_agreement(&document_key, Some(agreement_fieldname_key.clone())) .map_err(|e| { BindingCoreError::agreement_failed(format!("Failed to check agreement: {}", e)) + })?; + + let requested = doc + .agreement_requested_agents(Some(agreement_fieldname_key.clone())) + .map_err(|e| { + BindingCoreError::agreement_failed(format!( + "Failed to read requested signers: {}", + e + )) + })?; + + let pending = doc + .agreement_unsigned_agents(Some(agreement_fieldname_key.clone())) + .map_err(|e| { + BindingCoreError::agreement_failed(format!( + "Failed to read pending signers: {}", + e + )) + })?; + + let signatures = doc + .value + .get(&agreement_fieldname_key) + .and_then(|agreement| agreement.get("signatures")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let mut signed_at_by_agent: HashMap = HashMap::new(); + for signature in signatures { + if let Some(agent_id) = signature.get("agentID").and_then(|v| v.as_str()) { + let normalized = normalize_agent_id_for_compare(agent_id).to_string(); + let signed_at = signature + .get("date") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + signed_at_by_agent.insert(normalized, signed_at); + } + } + + let signers = requested + .iter() + .map(|agent_id| { + let normalized = normalize_agent_id_for_compare(agent_id).to_string(); + let signed_at = signed_at_by_agent + .get(&normalized) + .filter(|ts| !ts.is_empty()) + .cloned(); + let signed = signed_at.is_some(); + let mut signer = json!({ + "agentId": agent_id, + "agent_id": agent_id, + "signed": signed + }); + if let Some(ts) = signed_at { + signer["signedAt"] = json!(ts.clone()); + signer["signed_at"] = json!(ts); + } + signer }) + .collect::>(); + + let result = json!({ + "complete": pending.is_empty(), + "signers": signers, + "pending": pending + }); + + Ok(result.to_string()) } /// Sign a request payload (wraps in a JACS document). @@ -480,7 +634,10 @@ impl AgentWrapper { }); let wrapper_string = serde_json::to_string(&wrapper_value).map_err(|e| { - BindingCoreError::serialization_failed(format!("Failed to serialize wrapper JSON: {}", e)) + BindingCoreError::serialization_failed(format!( + "Failed to serialize wrapper JSON: {}", + e + )) })?; jacs::shared::document_create( @@ -492,9 +649,7 @@ impl AgentWrapper { None, Some(false), ) - .map_err(|e| { - BindingCoreError::document_failed(format!("Failed to create document: {}", e)) - }) + .map_err(|e| BindingCoreError::document_failed(format!("Failed to create document: {}", e))) } /// Verify a response payload and return the payload value. @@ -518,6 +673,87 @@ impl AgentWrapper { .map_err(|e| BindingCoreError::verification_failed(e.to_string())) } + /// Verify a document looked up by its ID from storage. + /// + /// This is a convenience method for when you have a document ID rather than + /// the full JSON string. The document ID should be in "uuid:version" format. + pub fn verify_document_by_id(&self, document_id: &str) -> BindingResult { + use jacs::storage::StorageDocumentTraits; + + // Validate format + if !document_id.contains(':') { + return Err(BindingCoreError::invalid_argument(format!( + "Document ID must be in 'uuid:version' format, got '{}'. \ + Use verify_document() with the full JSON string instead.", + document_id + ))); + } + + let storage = jacs::storage::MultiStorage::default_new().map_err(|e| { + BindingCoreError::generic(format!("Failed to initialize storage: {}", e)) + })?; + + let doc = storage.get_document(document_id).map_err(|e| { + BindingCoreError::document_failed(format!( + "Failed to load document '{}' from storage: {}", + document_id, e + )) + })?; + + let doc_str = serde_json::to_string(&doc.value).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize document '{}': {}", + document_id, e + )) + })?; + + self.verify_document(&doc_str) + } + + /// Re-encrypt the agent's private key with a new password. + /// + /// Reads the encrypted private key file, decrypts with old_password, + /// validates new_password, re-encrypts, and writes the updated file. + pub fn reencrypt_key(&self, old_password: &str, new_password: &str) -> BindingResult<()> { + use jacs::crypt::aes_encrypt::reencrypt_private_key; + + // Find key path from config + let agent = self.lock()?; + let key_path = if let Some(config) = &agent.config { + let key_dir = config + .jacs_key_directory() + .as_deref() + .unwrap_or("./jacs_keys"); + let key_file = config + .jacs_agent_private_key_filename() + .as_deref() + .unwrap_or("jacs.private.pem.enc"); + format!("{}/{}", key_dir, key_file) + } else { + "./jacs_keys/jacs.private.pem.enc".to_string() + }; + drop(agent); + + let encrypted_data = std::fs::read(&key_path).map_err(|e| { + BindingCoreError::generic(format!( + "Failed to read private key file '{}': {}", + key_path, e + )) + })?; + + let re_encrypted = reencrypt_private_key(&encrypted_data, old_password, new_password) + .map_err(|e| BindingCoreError::generic(format!("Re-encryption failed: {}", e)))?; + + std::fs::write(&key_path, &re_encrypted).map_err(|e| { + BindingCoreError::generic(format!( + "Failed to write re-encrypted key to '{}': {}", + key_path, e + )) + })?; + + Ok(()) + } + /// Get the agent's JSON representation as a string. /// /// Returns the agent's full JSON document, suitable for registration @@ -533,6 +769,130 @@ impl AgentWrapper { } } +// ============================================================================= +// Standalone verification (no agent required) +// ============================================================================= + +/// Result of verifying a signed JACS document (used by verify_document_standalone). +#[derive(Debug, Clone)] +pub struct VerificationResult { + /// Whether the document's signature and hash are valid. + pub valid: bool, + /// The signer's agent ID from the document's jacsSignature.agentID (empty if unparseable). + pub signer_id: String, +} + +/// Verify a signed JACS document without loading an agent. +/// +/// Creates a minimal verifier context (config with data/key directories and optional +/// key resolution), runs verification, and returns a result with valid flag and signer_id. +/// Does not persist any state. +/// +/// # Arguments +/// +/// * `signed_document` - Full signed JACS document JSON string. +/// * `key_resolution` - Optional key resolution order, e.g. "local" or "local,hai" (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). +/// +/// # Returns +/// +/// * `Ok(VerificationResult { valid: true, signer_id })` when signature and hash are valid. +/// * `Ok(VerificationResult { valid: false, signer_id })` when document parses but verification fails. +/// * `Err` when setup fails (e.g. missing key directory when using local resolution). +pub fn verify_document_standalone( + signed_document: &str, + key_resolution: Option<&str>, + data_directory: Option<&str>, + key_directory: Option<&str>, +) -> BindingResult { + fn signer_id_from_doc(doc: &str) -> String { + serde_json::from_str::(doc) + .ok() + .and_then(|v| { + v.get("jacsSignature") + .and_then(|s| s.get("agentID")) + .and_then(|id| id.as_str()) + .map(String::from) + }) + .unwrap_or_default() + } + + let signer_id = signer_id_from_doc(signed_document); + + let data_dir = data_directory + .map(String::from) + .unwrap_or_else(|| std::env::temp_dir().to_string_lossy().to_string()); + let key_dir = key_directory + .map(String::from) + .unwrap_or_else(|| std::env::temp_dir().to_string_lossy().to_string()); + + let config = Config::new( + Some("false".to_string()), + Some(data_dir.clone()), + Some(key_dir.clone()), + Some("jacs.private.pem.enc".to_string()), + Some("jacs.public.pem".to_string()), + Some("pq2025".to_string()), + None, + Some("".to_string()), + Some("fs".to_string()), + ); + let config_json = serde_json::to_string_pretty(&config).map_err(|e| { + BindingCoreError::serialization_failed(format!("Failed to serialize config: {}", e)) + })?; + + let config_path = std::env::temp_dir().join("jacs_standalone_verify_config.json"); + std::fs::write(&config_path, &config_json) + .map_err(|e| BindingCoreError::generic(format!("Failed to write temp config: {}", e)))?; + + struct EnvGuard(std::option::Option); + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(ref prev) = self.0 { + // SAFETY: single-threaded test/standalone use; restore of previous value + unsafe { std::env::set_var("JACS_KEY_RESOLUTION", prev) } + } else { + unsafe { std::env::remove_var("JACS_KEY_RESOLUTION") } + } + } + } + let _env_guard = if let Some(kr) = key_resolution { + let prev = std::env::var_os("JACS_KEY_RESOLUTION"); + unsafe { std::env::set_var("JACS_KEY_RESOLUTION", kr) } + Some(EnvGuard(prev)) + } else { + None + }; + + let result: BindingResult = (|| { + let wrapper = AgentWrapper::new(); + wrapper.load(config_path.to_string_lossy().to_string())?; + let valid = wrapper.verify_document(signed_document)?; + Ok(VerificationResult { + valid, + signer_id: signer_id.clone(), + }) + })(); + + match result { + Ok(r) => Ok(r), + Err(e) => { + if e.kind == ErrorKind::VerificationFailed + || e.kind == ErrorKind::DocumentFailed + || e.kind == ErrorKind::InvalidArgument + { + Ok(VerificationResult { + valid: false, + signer_id, + }) + } else { + Err(e) + } + } + } +} + // ============================================================================= // Stateless Utility Functions // ============================================================================= @@ -583,8 +943,9 @@ pub fn trust_agent(agent_json: &str) -> BindingResult { /// List all trusted agent IDs. pub fn list_trusted_agents() -> BindingResult> { - jacs::trust::list_trusted_agents() - .map_err(|e| BindingCoreError::trust_failed(format!("Failed to list trusted agents: {}", e))) + jacs::trust::list_trusted_agents().map_err(|e| { + BindingCoreError::trust_failed(format!("Failed to list trusted agents: {}", e)) + }) } /// Remove an agent from the trust store. @@ -604,10 +965,73 @@ pub fn get_trusted_agent(agent_id: &str) -> BindingResult { .map_err(|e| BindingCoreError::trust_failed(format!("Failed to get trusted agent: {}", e))) } +// ============================================================================= +// Audit (security audit and health checks) +// ============================================================================= + +/// Run a read-only security audit and health checks. +/// +/// Returns the audit result as a JSON string (risks, health_checks, summary). +/// Does not modify state. Optional config path and recent document re-verification count. +pub fn audit(config_path: Option<&str>, recent_n: Option) -> BindingResult { + use jacs::audit::{AuditOptions, audit as jacs_audit}; + + let mut opts = AuditOptions::default(); + opts.config_path = config_path.map(String::from); + if let Some(n) = recent_n { + opts.recent_verify_count = Some(n); + } + let result = + jacs_audit(opts).map_err(|e| BindingCoreError::generic(format!("Audit failed: {}", e)))?; + serde_json::to_string_pretty(&result).map_err(|e| { + BindingCoreError::serialization_failed(format!("Failed to serialize audit result: {}", e)) + }) +} + // ============================================================================= // CLI Utility Functions // ============================================================================= +/// Create a JACS agent programmatically (non-interactive). +/// +/// Accepts all creation parameters and returns a JSON string containing agent info. +pub fn create_agent_programmatic( + name: &str, + password: &str, + algorithm: Option<&str>, + data_directory: Option<&str>, + key_directory: Option<&str>, + config_path: Option<&str>, + agent_type: Option<&str>, + description: Option<&str>, + domain: Option<&str>, + default_storage: Option<&str>, +) -> BindingResult { + use jacs::simple::{CreateAgentParams, SimpleAgent}; + + let params = CreateAgentParams { + name: name.to_string(), + password: password.to_string(), + algorithm: algorithm.unwrap_or("pq2025").to_string(), + data_directory: data_directory.unwrap_or("./jacs_data").to_string(), + key_directory: key_directory.unwrap_or("./jacs_keys").to_string(), + config_path: config_path.unwrap_or("./jacs.config.json").to_string(), + agent_type: agent_type.unwrap_or("ai").to_string(), + 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) + .map_err(|e| BindingCoreError::agent_load(format!("Failed to create agent: {}", e)))?; + + serde_json::to_string_pretty(&info).map_err(|e| { + BindingCoreError::serialization_failed(format!("Failed to serialize agent info: {}", e)) + }) +} + /// Create agent and config files interactively. pub fn handle_agent_create(filename: Option<&String>, create_keys: bool) -> BindingResult<()> { jacs::cli_utils::create::handle_agent_create(filename, create_keys) @@ -702,10 +1126,7 @@ pub fn fetch_remote_key(agent_id: &str, version: &str) -> BindingResult BindingResult"] +keywords = ["mcp", "jacs", "ai", "agents", "signing", "provenance"] +categories = ["cryptography", "development-tools"] [features] default = ["mcp"] @@ -21,6 +30,7 @@ serde_json = "1" schemars = "1.0" uuid = { version = "1", features = ["v4"] } url = "2" +sha2 = "0.10.8" [dev-dependencies] assert_cmd = "2" diff --git a/jacs-mcp/README.md b/jacs-mcp/README.md index 73eb7c5a0..7301f5901 100644 --- a/jacs-mcp/README.md +++ b/jacs-mcp/README.md @@ -1,76 +1,75 @@ # JACS MCP Server -A Model Context Protocol (MCP) server providing HAI (Human AI Interface) tools for agent registration, verification, and key management. +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. -## Overview +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. -The JACS MCP Server allows LLMs to interact with HAI services through the MCP protocol. It provides tools for: +## What can it do? -- **Agent Key Management**: Fetch public keys from HAI's key distribution service -- **Agent Registration**: Register agents with HAI to establish identity -- **Agent Verification**: Verify other agents' attestation levels (0-3) -- **Status Checking**: Check registration status with HAI -- **Agent Unregistration**: Remove agent registration from HAI +The server exposes **14 tools** in four categories: -## Quick Start +### Agent State (Data Provenance) -### Step 1: Install JACS CLI +Sign, verify, and manage files that represent agent state (memories, skills, plans, configs, hooks): -First, install the JACS command-line tool: +| 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_list_state` | List signed agent state documents with optional filtering | +| `jacs_adopt_state` | Adopt an external file as signed state, recording its origin | -```bash -# From the JACS repository root -cargo install --path jacs -``` +### Agent Management -### Step 2: Generate Keys +| Tool | Description | +|------|-------------| +| `jacs_create_agent` | Create a new JACS agent with cryptographic keys (requires `JACS_MCP_ALLOW_REGISTRATION=true`) | +| `jacs_reencrypt_key` | Re-encrypt the agent's private key with a new password | -Generate cryptographic keys for your agent: +### Security -```bash -# Set a secure password for key encryption -export JACS_PRIVATE_KEY_PASSWORD="your-secure-password" +| Tool | Description | +|------|-------------| +| `jacs_audit` | Run a read-only security audit and health checks (risks, health_checks, summary). Optional: `config_path`, `recent_n`. | -# Create a directory for your keys -mkdir -p jacs_keys +### HAI Integration (Optional) -# Generate keys (choose an algorithm) -jacs create-keys --algorithm pq-dilithium --output-dir jacs_keys -# Or for Ed25519: jacs create-keys --algorithm ring-Ed25519 --output-dir jacs_keys -``` +Register with [HAI.ai](https://hai.ai) for cross-organization trust and key distribution: + +| 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) | -### Step 3: Create Agent +## Quick Start -Create a new JACS agent: +### Step 1: Install JACS CLI ```bash -# Create data directory -mkdir -p jacs_data - -# Create a new agent -jacs create-agent --name "My Agent" --description "My HAI-enabled agent" +# From the JACS repository root +cargo install --path jacs ``` -### Step 4: Create Configuration File +### Step 2: Create Agent and Keys -Create a `jacs.config.json` file: - -```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", - "jacs_agent_public_key_filename": "jacs.public.pem", - "jacs_agent_key_algorithm": "pq-dilithium", - "jacs_agent_id_and_version": "YOUR-AGENT-ID:YOUR-VERSION-ID", - "jacs_default_storage": "fs" -} +```bash +# Create an agent (generates keys, config, and data directories) +jacs init ``` -Replace `YOUR-AGENT-ID:YOUR-VERSION-ID` with your actual agent ID (found in the agent file created in Step 3). +Or programmatically: -### Step 5: Build and Run the MCP Server +```bash +export JACS_AGENT_PRIVATE_KEY_PASSWORD="Your-Str0ng-P@ss!" +jacs agent create --create-keys true +``` + +### Step 3: Build the MCP Server ```bash cd jacs-mcp @@ -79,153 +78,150 @@ cargo build --release The binary will be at `target/release/jacs-mcp`. -### Step 6: Configure Your MCP Client +### Step 4: Configure Your MCP Client Add to your MCP client configuration (e.g., Claude Desktop): ```json { "mcpServers": { - "jacs-hai": { + "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" + "JACS_PRIVATE_KEY_PASSWORD": "your-secure-password" } } } } ``` -## Installation +To enable HAI integration, add `HAI_API_KEY`: -Build from source: - -```bash -cd jacs-mcp -cargo build --release +```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" + } + } + } +} ``` -The binary will be at `target/release/jacs-mcp`. - ## Configuration -The server requires a JACS agent configuration to operate. Set the following environment variables: - -### Required +### Required Environment Variables - `JACS_CONFIG` - Path to your `jacs.config.json` file - `JACS_PRIVATE_KEY_PASSWORD` - Password for decrypting your private key -### Optional +### Optional Environment Variables -- `HAI_ENDPOINT` - HAI API endpoint (default: `https://api.hai.ai`). Must be an allowed host. +- `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 the register_agent tool (default: disabled) -- `JACS_MCP_ALLOW_UNREGISTRATION` - Set to `true` to enable the unregister_agent tool (default: disabled) +- `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) -### Example Configuration +### 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", "jacs_agent_public_key_filename": "jacs.public.pem", - "jacs_agent_key_algorithm": "pq-dilithium", - "jacs_agent_id_and_version": "your-agent-id:version", + "jacs_agent_key_algorithm": "pq2025", + "jacs_agent_id_and_version": "YOUR-AGENT-ID:YOUR-VERSION-ID", "jacs_default_storage": "fs" } ``` -## Usage +## Tools Reference -### Starting the Server +### jacs_sign_state -```bash -export JACS_CONFIG=/path/to/jacs.config.json -export HAI_API_KEY=your-api-key # optional -./jacs-mcp -``` +Sign an agent state file to create a cryptographically signed JACS document. -The server communicates over stdin/stdout using the MCP JSON-RPC protocol. +**Parameters:** +- `file_path` (required): Path to the file to sign +- `state_type` (required): Type of state: `memory`, `skill`, `plan`, `config`, or `hook` +- `name` (required): Human-readable name for the 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) -### MCP Client Configuration +### jacs_verify_state -Add to your MCP client configuration (e.g., Claude Desktop): +Verify the integrity and authenticity of a signed agent state. -```json -{ - "mcpServers": { - "jacs-hai": { - "command": "/path/to/jacs-mcp", - "env": { - "JACS_CONFIG": "/path/to/jacs.config.json", - "HAI_API_KEY": "your-api-key" - } - } - } -} -``` +**Parameters:** +- `file_path` (optional): Path to the file to verify +- `jacs_id` (optional): JACS document ID to verify -## Tools +At least one of `file_path` or `jacs_id` must be provided. -### fetch_agent_key +### jacs_load_state -Fetch a public key from HAI's key distribution service. +Load a signed agent state document, optionally verifying before returning content. **Parameters:** -- `agent_id` (required): The JACS agent ID (UUID format) -- `version` (optional): Key version to fetch, or "latest" for most recent +- `file_path` (optional): Path to the file to load +- `jacs_id` (optional): JACS document ID to load +- `require_verified` (optional): Whether to require verification before loading (default: true) -**Returns:** -- `success`: Whether the operation succeeded -- `agent_id`: The agent ID -- `version`: The key version -- `algorithm`: Cryptographic algorithm (e.g., "ed25519", "pq-dilithium") -- `public_key_hash`: SHA-256 hash of the public key -- `public_key_base64`: Base64-encoded public key +### jacs_update_state -**Example:** -```json -{ - "name": "fetch_agent_key", - "arguments": { - "agent_id": "550e8400-e29b-41d4-a716-446655440000", - "version": "latest" - } -} -``` +Update a previously signed agent state file with new content and re-sign. -### register_agent +**Parameters:** +- `file_path` (required): Path to the file to update +- `new_content` (optional): New content to write. If omitted, re-signs current content. + +### jacs_list_state -Register the local agent with HAI to establish identity and enable attestation. +List signed agent state documents with optional filtering. **Parameters:** -- `preview` (optional): If true, validates without actually registering +- `state_type` (optional): Filter by type (`memory`, `skill`, `plan`, `config`, `hook`) +- `framework` (optional): Filter by framework identifier +- `tags` (optional): Filter by tags (documents must have all specified tags) -**Returns:** -- `success`: Whether the operation succeeded -- `agent_id`: The registered agent's JACS ID -- `jacs_id`: The JACS document ID -- `dns_verified`: Whether DNS verification was successful -- `preview_mode`: Whether this was preview-only -- `message`: Human-readable status message +### jacs_adopt_state -**Example:** -```json -{ - "name": "register_agent", - "arguments": { - "preview": false - } -} -``` +Adopt an external file as signed agent state, marking its origin as "adopted". + +**Parameters:** +- `file_path` (required): Path to the file to adopt +- `state_type` (required): Type of state +- `name` (required): Human-readable name +- `source_url` (optional): URL where the content was originally obtained +- `description` (optional): Description of the adopted state + +### fetch_agent_key + +Fetch a public key from HAI's key distribution service. + +**Parameters:** +- `agent_id` (required): The JACS agent ID (UUID format) +- `version` (optional): Key version to fetch, or `latest` + +### register_agent + +Register the local agent with HAI. **Requires `JACS_MCP_ALLOW_REGISTRATION=true`.** + +**Parameters:** +- `preview` (optional): If true (default), validates without actually registering ### verify_agent @@ -233,28 +229,13 @@ Verify another agent's attestation level with HAI. **Parameters:** - `agent_id` (required): The JACS agent ID to verify -- `version` (optional): Agent version to verify, or "latest" - -**Returns:** -- `success`: Whether the verification succeeded -- `agent_id`: The verified agent ID -- `attestation_level`: Trust level (0-3): - - Level 0: No attestation - - Level 1: Key registered with HAI - - Level 2: DNS verified - - Level 3: Full HAI signature attestation -- `attestation_description`: Human-readable description -- `key_found`: Whether the agent's public key was found - -**Example:** -```json -{ - "name": "verify_agent", - "arguments": { - "agent_id": "550e8400-e29b-41d4-a716-446655440000" - } -} -``` +- `version` (optional): Agent version to verify, or `latest` + +**Attestation levels:** +- Level 0: No attestation +- Level 1: Key registered with HAI +- Level 2: DNS verified +- Level 3: Full HAI signature attestation ### check_agent_status @@ -263,22 +244,6 @@ Check registration status of an agent with HAI. **Parameters:** - `agent_id` (optional): Agent ID to check. If omitted, checks the local agent. -**Returns:** -- `success`: Whether the operation succeeded -- `agent_id`: The checked agent ID -- `registered`: Whether the agent is registered with HAI -- `registration_id`: HAI registration ID (if registered) -- `registered_at`: Registration timestamp (if registered) -- `signature_count`: Number of HAI signatures on the registration - -**Example:** -```json -{ - "name": "check_agent_status", - "arguments": {} -} -``` - ### unregister_agent Unregister the local agent from HAI. **Requires `JACS_MCP_ALLOW_UNREGISTRATION=true`.** @@ -286,81 +251,35 @@ Unregister the local agent from HAI. **Requires `JACS_MCP_ALLOW_UNREGISTRATION=t **Parameters:** - `preview` (optional): If true (default), validates without actually unregistering -**Returns:** -- `success`: Whether the operation succeeded -- `agent_id`: The unregistered agent's JACS ID -- `preview_mode`: Whether this was preview-only -- `message`: Human-readable status message - -**Example:** -```json -{ - "name": "unregister_agent", - "arguments": { - "preview": false - } -} -``` - ## Security -### Key Security Features - -- **Registration Authorization**: The `register_agent` and `unregister_agent` tools are disabled by default. This prevents prompt injection attacks from registering agents without user consent. -- **Preview Mode by Default**: Even when enabled, registration defaults to preview mode for additional safety. -- **Endpoint Validation**: The `HAI_ENDPOINT` URL is validated against an allowlist to prevent request redirection attacks. -- **Password Protection**: Private keys are encrypted with a password. Never store passwords in config files - use the `JACS_PRIVATE_KEY_PASSWORD` environment variable. -- **Stdio Transport**: The server uses stdio transport, which is inherently local with no network exposure. - -### Enabling Registration - -To enable agent registration (only do this if you trust the LLM): - -```bash -export JACS_MCP_ALLOW_REGISTRATION=true -export JACS_MCP_ALLOW_UNREGISTRATION=true # Optional -``` - -### Allowed HAI Endpoints - -The server validates `HAI_ENDPOINT` against this allowlist: -- `api.hai.ai` -- `dev.api.hai.ai` -- `staging.api.hai.ai` -- `localhost` / `127.0.0.1` (for development) -- Any subdomain of `*.hai.ai` - -### Best Practices - -- Use strong passwords for key encryption (12+ characters) -- Never commit config files with passwords to version control -- Use environment variables or secrets management for sensitive values -- Keep private key files secure (mode 0600) -- Review agent registrations before enabling automatic registration +- **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). +- **Password protection**: Private keys are encrypted. Never store passwords in config files. +- **Stdio transport**: No network exposure -- communicates over stdin/stdout. ## Development -### Running Tests - ```bash +# Run tests cargo test -``` -### Building Debug Version - -```bash +# Build debug version cargo build -``` - -### Environment for Development -```bash -export JACS_CONFIG=/path/to/test/jacs.config.json -export HAI_ENDPOINT=https://dev.api.hai.ai +# Run with debug logging +export JACS_CONFIG=/path/to/jacs.config.json export RUST_LOG=debug cargo run ``` +## Documentation + +- [JACS Book](https://humanassisted.github.io/JACS/) - Full documentation (published book) +- [Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html) +- [Source](https://github.com/HumanAssisted/JACS) - GitHub repository + ## License See the LICENSE file in the parent directory. diff --git a/jacs-mcp/src/hai_tools.rs b/jacs-mcp/src/hai_tools.rs index c80e49f1a..151bcea6a 100644 --- a/jacs-mcp/src/hai_tools.rs +++ b/jacs-mcp/src/hai_tools.rs @@ -17,14 +17,16 @@ //! - **Preview Mode by Default**: Even when enabled, registration defaults to preview mode //! unless `preview=false` is explicitly set. +use jacs::schema::agentstate_crud; use jacs_binding_core::hai::HaiClient; -use jacs_binding_core::{fetch_remote_key, AgentWrapper}; +use jacs_binding_core::{AgentWrapper, fetch_remote_key}; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{Implementation, ServerCapabilities, ServerInfo, Tool, ToolsCapability}; -use rmcp::{tool, tool_handler, tool_router, ServerHandler}; +use rmcp::{ServerHandler, tool, tool_handler, tool_router}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::sync::Arc; use uuid::Uuid; @@ -250,6 +252,392 @@ pub struct UnregisterAgentResult { pub error: Option, } +// ============================================================================= +// Agent Management Request/Response Types +// ============================================================================= + +/// Parameters for creating a new JACS agent programmatically. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateAgentProgrammaticParams { + /// Name for the new agent. + #[schemars(description = "Name for the new agent")] + pub name: String, + + /// Password for encrypting the private key. + #[schemars( + description = "Password for encrypting the private key. Must be at least 8 characters with uppercase, lowercase, digit, and special character." + )] + pub password: String, + + /// Cryptographic algorithm. Default: "pq2025" (ML-DSA-87, FIPS-204). + #[schemars( + description = "Cryptographic algorithm: 'pq2025' (default, post-quantum), 'ring-Ed25519', or 'RSA-PSS'" + )] + pub algorithm: Option, + + /// Directory for data files. Default: "./jacs_data". + #[schemars(description = "Directory for data files (default: ./jacs_data)")] + pub data_directory: Option, + + /// Directory for key files. Default: "./jacs_keys". + #[schemars(description = "Directory for key files (default: ./jacs_keys)")] + pub key_directory: Option, + + /// Optional agent type (e.g., "ai", "human"). + #[schemars(description = "Agent type (default: 'ai')")] + pub agent_type: Option, + + /// Optional description of the agent. + #[schemars(description = "Description of the agent")] + pub description: Option, +} + +/// Result of creating an agent. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateAgentProgrammaticResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The new agent's ID (UUID). + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + + /// The agent name. + pub name: String, + + /// 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 re-encrypting the agent's private key. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ReencryptKeyParams { + /// Current password for the private key. + #[schemars(description = "Current password for the private key")] + pub old_password: String, + + /// New password to encrypt the private key with. + #[schemars( + description = "New password. Must be at least 8 characters with uppercase, lowercase, digit, and special character." + )] + pub new_password: String, +} + +/// Parameters for the JACS security audit tool. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct JacsAuditParams { + /// Optional path to jacs config file. + #[schemars(description = "Optional path to jacs.config.json")] + pub config_path: Option, + + /// Optional number of recent documents to re-verify. + #[schemars(description = "Number of recent documents to re-verify (default from config)")] + pub recent_n: Option, +} + +/// Result of re-encrypting the private key. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ReencryptKeyResult { + /// Whether the operation succeeded. + pub success: bool, + + /// Human-readable status message. + pub message: String, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +// ============================================================================= +// Agent State Request/Response Types +// ============================================================================= + +/// Parameters for signing an agent state file. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SignStateParams { + /// Path to the file to sign. + #[schemars(description = "Path to the file to sign as agent state")] + pub file_path: String, + + /// The type of agent state. + #[schemars(description = "Type of agent state: memory, skill, plan, config, or hook")] + pub state_type: String, + + /// Human-readable name for this state document. + #[schemars(description = "Human-readable name for this state document")] + pub name: String, + + /// Optional description of the state document. + #[schemars(description = "Optional description of what this state document contains")] + pub description: Option, + + /// Optional framework identifier (e.g., "claude-code", "openclaw"). + #[schemars(description = "Optional framework identifier (e.g., 'claude-code', 'openclaw')")] + pub framework: Option, + + /// Optional tags for categorization. + #[schemars(description = "Optional tags for categorization")] + pub tags: Option>, + + /// Whether to embed file content inline. Always true for hooks. + #[schemars( + description = "Whether to embed file content inline (default false, always true for hooks)" + )] + pub embed: Option, +} + +/// Result of signing an agent state file. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SignStateResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The JACS document ID of the signed state. + #[serde(skip_serializing_if = "Option::is_none")] + pub jacs_document_id: Option, + + /// The state type that was signed. + pub state_type: String, + + /// The name of the state document. + pub name: String, + + /// 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 verifying an agent state file or document. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +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)" + )] + 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)" + )] + pub jacs_id: Option, +} + +/// Result of verifying an agent state file. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct VerifyStateResult { + /// Whether the verification succeeded overall. + pub success: bool, + + /// Whether the file hash matches the signed hash. + pub hash_match: bool, + + /// Whether the document signature is valid. + pub signature_valid: bool, + + /// Information about the signing agent. + #[serde(skip_serializing_if = "Option::is_none")] + pub signing_info: 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 loading a signed agent state. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +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)" + )] + 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)" + )] + pub jacs_id: Option, + + /// Whether to require verification before loading (default true). + #[schemars(description = "Whether to require verification before loading (default true)")] + pub require_verified: Option, +} + +/// Result of loading a signed agent state. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct LoadStateResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The loaded content. + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + + /// Whether the document was verified. + pub verified: bool, + + /// Any warnings about the loaded state. + #[serde(skip_serializing_if = "Option::is_none")] + pub warnings: 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 a signed agent state. +#[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)")] + pub file_path: String, + + /// 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." + )] + pub new_content: Option, +} + +/// Result of updating a signed agent state. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct UpdateStateResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The new JACS document version ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub jacs_document_version_id: Option, + + /// The new SHA-256 hash of the content. + #[serde(skip_serializing_if = "Option::is_none")] + pub new_hash: 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 listing signed agent state documents. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListStateParams { + /// Filter by state type. + #[schemars(description = "Filter by state type: memory, skill, plan, config, or hook")] + pub state_type: Option, + + /// Filter by framework. + #[schemars(description = "Filter by framework identifier")] + pub framework: Option, + + /// Filter by tags (documents must have all specified tags). + #[schemars(description = "Filter by tags (documents must have all specified tags)")] + pub tags: Option>, +} + +/// A summary entry for a signed agent state document. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct StateListEntry { + /// The JACS document ID. + pub jacs_document_id: String, + + /// The state type. + pub state_type: String, + + /// The document name. + pub name: String, + + /// The framework, if set. + #[serde(skip_serializing_if = "Option::is_none")] + pub framework: Option, + + /// Tags on the document. + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} + +/// Result of listing signed agent state documents. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListStateResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The list of state documents. + pub documents: Vec, + + /// 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 adopting an external agent state file. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AdoptStateParams { + /// Path to the file to adopt and sign. + #[schemars(description = "Path to the file to adopt and sign as agent state")] + pub file_path: String, + + /// The type of agent state. + #[schemars(description = "Type of agent state: memory, skill, plan, config, or hook")] + pub state_type: String, + + /// Human-readable name for this state document. + #[schemars(description = "Human-readable name for this adopted state document")] + pub name: String, + + /// Optional URL where the content was obtained from. + #[schemars(description = "Optional URL where the content was originally obtained")] + pub source_url: Option, + + /// Optional description of the state document. + #[schemars(description = "Optional description of what this adopted state document contains")] + pub description: Option, +} + +/// Result of adopting an external agent state file. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AdoptStateResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The JACS document ID of the adopted state. + #[serde(skip_serializing_if = "Option::is_none")] + pub jacs_document_id: Option, + + /// The state type that was adopted. + pub state_type: String, + + /// The name of the adopted state document. + pub name: String, + + /// Human-readable status message. + pub message: String, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + // ============================================================================= // MCP Server // ============================================================================= @@ -296,7 +684,9 @@ impl HaiMcpServer { if registration_allowed { tracing::info!("Agent registration is ENABLED (JACS_MCP_ALLOW_REGISTRATION=true)"); } else { - tracing::info!("Agent registration is DISABLED. Set JACS_MCP_ALLOW_REGISTRATION=true to enable."); + tracing::info!( + "Agent registration is DISABLED. Set JACS_MCP_ALLOW_REGISTRATION=true to enable." + ); } Self { @@ -343,6 +733,63 @@ impl HaiMcpServer { 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 \ + a cryptographically signed JACS document. This establishes provenance and \ + integrity for the file's contents.", + Self::jacs_sign_state_schema(), + ), + Tool::new( + "jacs_verify_state", + "Verify the integrity and authenticity of a signed agent state. Checks both \ + the file hash and the cryptographic signature.", + Self::jacs_verify_state_schema(), + ), + Tool::new( + "jacs_load_state", + "Load a signed agent state document and optionally verify it before returning \ + the content.", + Self::jacs_load_state_schema(), + ), + Tool::new( + "jacs_update_state", + "Update a previously signed agent state file. Writes new content (if provided), \ + recomputes the SHA-256 hash, and creates a new signed version.", + Self::jacs_update_state_schema(), + ), + Tool::new( + "jacs_list_state", + "List signed agent state documents, with optional filtering by type, framework, \ + or tags.", + Self::jacs_list_state_schema(), + ), + Tool::new( + "jacs_adopt_state", + "Adopt an external file as signed agent state. Like sign_state but marks the \ + origin as 'adopted' and optionally records the source URL.", + Self::jacs_adopt_state_schema(), + ), + Tool::new( + "jacs_create_agent", + "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.", + Self::jacs_create_agent_schema(), + ), + Tool::new( + "jacs_reencrypt_key", + "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.", + Self::jacs_reencrypt_key_schema(), + ), + Tool::new( + "jacs_audit", + "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).", + Self::jacs_audit_schema(), + ), ] } @@ -385,6 +832,78 @@ impl HaiMcpServer { _ => serde_json::Map::new(), } } + + 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(), + } + } } // Implement the tool router for the server @@ -407,13 +926,17 @@ impl HaiMcpServer { let result = FetchAgentKeyResult { success: false, agent_id: params.agent_id.clone(), - version: params.version.clone().unwrap_or_else(|| "latest".to_string()), + 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), }; - return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } let version = params.version.as_deref().unwrap_or("latest"); @@ -474,7 +997,8 @@ impl HaiMcpServer { .to_string(), error: Some("REGISTRATION_DISABLED".to_string()), }; - return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } // Default to preview mode for additional safety @@ -494,7 +1018,8 @@ impl HaiMcpServer { .to_string(), error: None, }; - return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } let result = match self.hai_client.register(&self.agent).await { @@ -547,7 +1072,8 @@ impl HaiMcpServer { key_found: false, error: Some(e), }; - return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } let version = params.version.as_deref().unwrap_or("latest"); @@ -563,16 +1089,14 @@ impl HaiMcpServer { // 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.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 @@ -707,11 +1231,7 @@ impl HaiMcpServer { registration_id: None, registered_at: None, signature_count: 0, - error: if registered { - Some(error_str) - } else { - None - }, + error: if registered { Some(error_str) } else { None }, } } } @@ -747,7 +1267,8 @@ impl HaiMcpServer { .to_string(), error: Some("UNREGISTRATION_DISABLED".to_string()), }; - return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } // Default to preview mode for safety @@ -764,7 +1285,8 @@ impl HaiMcpServer { .to_string(), error: None, }; - return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); } // Note: HaiClient doesn't currently have an unregister method @@ -781,6 +1303,857 @@ impl HaiMcpServer { 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, + ) { + 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 the document via create_document (no_save=true to avoid filesystem writes) + 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) => { + // 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()); + + 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 { + // 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 { + 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()), + }; + 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 = VerifyStateResult { + success: true, + hash_match, + signature_valid, + signing_info, + message: format!( + "Verification complete: hash_match={}, signature_valid={}", + hash_match, signature_valid + ), + error: None, + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + } + + // 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)) + } + + /// Load a signed agent state document and optionally verify it. + /// + /// Returns the content of the state along with verification status. + #[tool( + name = "jacs_load_state", + description = "Load a signed agent state document, optionally verifying before returning content." + )] + 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)); + } + + 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 { + 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()), + }; + 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, + Err(e) => { + let result = LoadStateResult { + success: false, + content: None, + verified: false, + warnings: 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)); + } + }; + + let mut warnings = Vec::new(); + let mut verified = false; + + // 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)); + } + } + + // 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)); + } + } + } + } else { + warnings.push(format!( + "No signed document found at '{}'. Content is unverified.", + sidecar_path + )); + } + + if require_verified && !verified { + let result = LoadStateResult { + 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) + }, + error: None, + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Update a previously signed agent state file. + /// + /// 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 = "jacs_update_state", + description = "Update a previously signed agent state file with new content and re-sign." + )] + pub async fn jacs_update_state( + &self, + 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 { + 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()), + }; + 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 { + success: false, + jacs_document_version_id: None, + new_hash: None, + message: format!("Failed to read file '{}'", params.file_path), + error: Some(e.to_string()), + }; + 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()), + ); + } + } + } + } + + // If content was embedded at the document level, update it + if doc.get("jacsAgentStateContent").is_some() { + doc["jacsAgentStateContent"] = serde_json::json!(content); + } + + // 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 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 + ); + } + } + } + + // No existing sidecar or update failed - create a fresh signed document + + // 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)) + } + } + } + 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, + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Adopt an external file as signed agent state. + /// + /// Like sign_state but sets the origin to "adopted" and optionally records + /// the source URL where the content was obtained. + #[tool( + name = "jacs_adopt_state", + description = "Adopt an external file as signed agent state, marking it with 'adopted' origin." + )] + pub async fn jacs_adopt_state( + &self, + 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, + Err(e) => { + let result = AdoptStateResult { + 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 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)); + } + + // Sign the document + 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(false), + ) { + 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()); + + AdoptStateResult { + 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() + ), + error: None, + } + } + Err(e) => AdoptStateResult { + success: false, + jacs_document_id: None, + state_type: params.state_type, + name: params.name, + message: "Failed to sign adopted document".to_string(), + error: Some(e.to_string()), + }, + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Create a new JACS agent programmatically. + /// + /// 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_create_agent", + description = "Create a new JACS agent with cryptographic keys (programmatic)." + )] + pub async fn jacs_create_agent( + &self, + 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)); + } + + 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)); + + CreateAgentProgrammaticResult { + success: true, + agent_id, + name: params.name, + message: "Agent created successfully".to_string(), + error: None, + } + } + Err(e) => CreateAgentProgrammaticResult { + success: false, + agent_id: None, + name: params.name, + message: "Failed to create agent".to_string(), + error: Some(e.to_string()), + }, + }; + + 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)) + } + + /// 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(), + } + } } // Implement the tool handler for the server @@ -803,10 +2176,23 @@ impl ServerHandler for HaiMcpServer { website_url: Some("https://hai.ai".to_string()), }, instructions: Some( - "This MCP server provides HAI (Human AI Interface) tools for agent \ - registration, verification, and key management. Use fetch_agent_key \ - to get public keys, register_agent to register with HAI, verify_agent \ - to check attestation levels, and check_agent_status for registration info." + "This MCP server provides data provenance and cryptographic signing for \ + agent state files, plus optional HAI.ai integration for key distribution \ + and attestation. \ + \ + 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). \ + \ + 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). \ + \ + 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)." .to_string(), ), } @@ -901,7 +2287,7 @@ mod tests { #[test] fn test_tools_list() { let tools = HaiMcpServer::tools(); - assert_eq!(tools.len(), 5); + assert_eq!(tools.len(), 14); let names: Vec<&str> = tools.iter().map(|t| &*t.name).collect(); assert!(names.contains(&"fetch_agent_key")); @@ -909,6 +2295,101 @@ mod tests { 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")); + assert!(names.contains(&"jacs_update_state")); + assert!(names.contains(&"jacs_list_state")); + assert!(names.contains(&"jacs_adopt_state")); + assert!(names.contains(&"jacs_create_agent")); + assert!(names.contains(&"jacs_reencrypt_key")); + assert!(names.contains(&"jacs_audit")); + } + + #[test] + fn test_jacs_audit_returns_risks_and_health_checks() { + let json = jacs_binding_core::audit(None, None).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!( + v.get("risks").is_some(), + "jacs_audit response should have risks" + ); + assert!( + v.get("health_checks").is_some(), + "jacs_audit response should have health_checks" + ); + } + + #[test] + fn test_sign_state_params_schema() { + let schema = schemars::schema_for!(SignStateParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("file_path")); + assert!(json.contains("state_type")); + assert!(json.contains("name")); + assert!(json.contains("embed")); + } + + #[test] + fn test_verify_state_params_schema() { + let schema = schemars::schema_for!(VerifyStateParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("file_path")); + assert!(json.contains("jacs_id")); + } + + #[test] + fn test_load_state_params_schema() { + let schema = schemars::schema_for!(LoadStateParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("file_path")); + assert!(json.contains("require_verified")); + } + + #[test] + fn test_update_state_params_schema() { + let schema = schemars::schema_for!(UpdateStateParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("file_path")); + assert!(json.contains("new_content")); + } + + #[test] + fn test_list_state_params_schema() { + let schema = schemars::schema_for!(ListStateParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("state_type")); + assert!(json.contains("framework")); + assert!(json.contains("tags")); + } + + #[test] + fn test_adopt_state_params_schema() { + let schema = schemars::schema_for!(AdoptStateParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("file_path")); + assert!(json.contains("state_type")); + assert!(json.contains("name")); + assert!(json.contains("source_url")); + } + + #[test] + fn test_create_agent_params_schema() { + let schema = schemars::schema_for!(CreateAgentProgrammaticParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("name")); + assert!(json.contains("password")); + assert!(json.contains("algorithm")); + assert!(json.contains("data_directory")); + assert!(json.contains("key_directory")); + } + + #[test] + fn test_reencrypt_key_params_schema() { + let schema = schemars::schema_for!(ReencryptKeyParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("old_password")); + assert!(json.contains("new_password")); } #[test] diff --git a/jacs-mcp/src/main.rs b/jacs-mcp/src/main.rs index 36fa6d7dd..e306158a2 100644 --- a/jacs-mcp/src/main.rs +++ b/jacs-mcp/src/main.rs @@ -1,12 +1,12 @@ -mod handlers; mod hai_tools; +mod handlers; #[cfg(feature = "mcp")] use hai_tools::HaiMcpServer; #[cfg(feature = "mcp")] use jacs_binding_core::AgentWrapper; #[cfg(feature = "mcp")] -use rmcp::{transport::stdio, ServiceExt}; +use rmcp::{ServiceExt, transport::stdio}; /// Allowed HAI endpoint hostnames for security. /// This prevents request redirection attacks via malicious HAI_ENDPOINT values. @@ -55,9 +55,9 @@ fn validate_hai_endpoint(endpoint: &str) -> anyhow::Result { } // Check the host against allowlist - let host = url.host_str().ok_or_else(|| { - anyhow::anyhow!("HAI_ENDPOINT '{}' has no host component.", endpoint) - })?; + 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); @@ -84,9 +84,7 @@ fn validate_hai_endpoint(endpoint: &str) -> anyhow::Result { async fn main() -> anyhow::Result<()> { // Initialize logging - send to stderr so stdout stays clean for MCP JSON-RPC tracing_subscriber::fmt() - .with_env_filter( - std::env::var("RUST_LOG").unwrap_or_else(|_| "info,rmcp=warn".to_string()), - ) + .with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "info,rmcp=warn".to_string())) .with_writer(std::io::stderr) .init(); @@ -112,11 +110,7 @@ async fn main() -> anyhow::Result<()> { ); // Create the MCP server with HAI tools - let server = HaiMcpServer::new( - agent, - &hai_endpoint, - api_key.as_deref(), - ); + let server = HaiMcpServer::new(agent, &hai_endpoint, api_key.as_deref()); tracing::info!("HAI MCP server ready, waiting for client connection on stdio"); @@ -176,21 +170,9 @@ fn load_agent_from_config() -> anyhow::Result { let _ = jacs::config::set_env_vars(true, Some(&cfg_str), false) .map_err(|e| anyhow::anyhow!("Invalid config file '{}': {}", cfg_path, e))?; - // Get the config directory for relative path resolution - let cfg_dir = std::path::Path::new(&cfg_path) - .parent() - .and_then(|p| p.to_str()) - .unwrap_or(".") - .to_string(); - let cfg_dir = if cfg_dir.ends_with('/') { - cfg_dir - } else { - format!("{}/", cfg_dir) - }; - - // Load the agent + // Load the agent using the full config file path agent_wrapper - .load(cfg_dir) + .load(cfg_path.clone()) .map_err(|e| anyhow::anyhow!("Failed to load agent: {}", e))?; tracing::info!("Agent loaded successfully from config"); diff --git a/jacs-mcp/tests/integration.rs b/jacs-mcp/tests/integration.rs index f745c64d2..fb32c52c6 100644 --- a/jacs-mcp/tests/integration.rs +++ b/jacs-mcp/tests/integration.rs @@ -1,67 +1,25 @@ -use assert_cmd::Command; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -fn agent_fixture() -> PathBuf { - // Use an existing agent fixture from the main jacs tests - // This one resides under tests/fixtures/dns/jacs/agent/:.json - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .to_path_buf(); - root.join("jacs/tests/fixtures/dns/jacs/agent/85058eed-81b0-4eb3-878e-c58e7902c4fd:6b2c5ddf-a07b-4e0a-af1b-b081f1b8cb32.json") -} - -fn data_dir() -> PathBuf { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .to_path_buf(); - root.join("jacs/tests/scratch/jacs_data") -} +/// 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"; -fn config_fixture() -> PathBuf { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .to_path_buf(); - root.join("jacs/tests/fixtures/dns/jacs.config.json") -} +/// 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 abs_fixture_dir(rel: &str) -> String { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) +fn jacs_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() - .to_path_buf(); - root.join(rel).to_string_lossy().to_string() + .to_path_buf() } -fn write_temp_config_with_abs_paths() -> PathBuf { - let orig = fs::read_to_string(config_fixture()).expect("read fixture config"); - // Replace relative paths with absolute - let data_abs = abs_fixture_dir("jacs/tests/fixtures/dns/jacs"); - let keys_abs = abs_fixture_dir("jacs/tests/fixtures/dns/jacs_keys"); - let modified = orig - .replace( - "\"jacs_data_directory\": \"./jacs\"", - &format!("\"jacs_data_directory\": \"{}\"", data_abs), - ) - .replace( - "\"jacs_key_directory\": \"./jacs_keys\"", - &format!("\"jacs_key_directory\": \"{}\"", keys_abs), - ); - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(); - let out_path = std::env::temp_dir().join(format!("jacs_mcp_test_config_{}.json", ts)); - fs::write(&out_path, modified).expect("write temp config"); - out_path -} - -fn prepare_temp_workspace(agent_path: &PathBuf) -> (PathBuf, PathBuf, PathBuf) { - // Create temp data and keys directories, copy fixture agent and keys, and return (config, data_dir, keys_dir) +/// 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() @@ -72,35 +30,40 @@ fn prepare_temp_workspace(agent_path: &PathBuf) -> (PathBuf, PathBuf, PathBuf) { fs::create_dir_all(data_dir.join("agent")).expect("mkdir data/agent"); fs::create_dir_all(&keys_dir).expect("mkdir keys"); - // Copy agent JSON - let agent_filename = agent_path.file_name().unwrap(); - fs::copy(agent_path, data_dir.join("agent").join(agent_filename)).expect("copy agent json"); + let root = jacs_root(); - // Copy keys from fixtures - let keys_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join("jacs/tests/fixtures/dns/jacs_keys"); - let priv_key = keys_fixture.join("jacs.private.pem.enc"); - let pub_key = keys_fixture.join("jacs.public.pem"); - fs::copy(priv_key, keys_dir.join("jacs.private.pem.enc")).expect("copy private key"); - fs::copy(pub_key, keys_dir.join("jacs.public.pem")).expect("copy public key"); + // 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 + ) + }); - // Write config - let id_and_version = agent_filename - .to_string_lossy() - .trim_end_matches(".json") - .to_string(); + // 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_domain": "hai.io", - "jacs_agent_id_and_version": id_and_version, - "jacs_agent_key_algorithm": "pq-dilithium", - "jacs_agent_private_key_filename": "jacs.private.pem.enc", - "jacs_agent_public_key_filename": "jacs.public.pem", - "jacs_data_directory": data_dir.to_string_lossy(), + "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": keys_dir.to_string_lossy(), - "jacs_private_key_password": "hello", + "jacs_key_directory": "jacs_keys", "jacs_use_security": "false" }); let cfg_path = base.join("jacs.config.json"); @@ -108,19 +71,36 @@ fn prepare_temp_workspace(agent_path: &PathBuf) -> (PathBuf, PathBuf, PathBuf) { &cfg_path, serde_json::to_string_pretty(&config_json).unwrap(), ) - .expect("write cfg"); - (cfg_path, data_dir, keys_dir) + .expect("write config"); + + (cfg_path, base) } #[test] fn starts_server_with_agent_env() { - let agent = agent_fixture(); - let (config, data, _keys) = prepare_temp_workspace(&agent); - let mut cmd = Command::cargo_bin("jacs-mcp").expect("binary built"); - cmd.env("JACS_AGENT_FILE", agent); - cmd.env("JACS_DATA_DIRECTORY", data); - cmd.env("JACS_CONFIG", config); - cmd.assert().success(); + let (config, base) = prepare_temp_workspace(); + + // 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 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. + assert!( + stderr.contains("Agent loaded successfully"), + "Expected agent to load successfully.\nExit code: {:?}\nstderr:\n{}", + output.status.code(), + stderr + ); } #[test] diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index db179caa5..3dfeac3c0 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs" -version = "0.5.2" +version = "0.6.0" edition = "2024" rust-version = "1.93" resolver = "3" @@ -33,6 +33,10 @@ include = [ "basic-schemas.png", "benches/sign_and_check_sig.rs", "schemas/components/embedding/v1/embedding.schema.json", + "schemas/agentstate/v1/agentstate.schema.json", + "schemas/commitment/v1/commitment.schema.json", + "schemas/todo/v1/todo.schema.json", + "schemas/components/todoitem/v1/todoitem.schema.json", ] description = "JACS JSON AI Communication Standard" readme = "README.md" @@ -117,6 +121,8 @@ 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"] } [lib] crate-type = ["cdylib", "rlib"] @@ -132,6 +138,8 @@ object_store = { version ="0.12.0", features = ["serde","serde_json", "aws", "ht # 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 } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2.100" @@ -151,6 +159,11 @@ observability-convenience = [] # Server integration surfaces (no deps by default; used by sibling binaries) mcp-server = [] +# Database storage backend (PostgreSQL via sqlx) +database = ["dep:sqlx", "dep:tokio"] +# Database integration tests (requires Docker or local PostgreSQL) +database-tests = ["database"] + # 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. diff --git a/jacs/README.md b/jacs/README.md index 3dd9b84db..abc9eaa49 100644 --- a/jacs/README.md +++ b/jacs/README.md @@ -2,6 +2,8 @@ Cryptographic signing and verification for AI agents. +**[Documentation](https://humanassisted.github.io/JACS/)** | **[Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html)** | **[API Reference](https://humanassisted.github.io/JACS/nodejs/api.html)** + ```bash cargo install jacs ``` @@ -38,6 +40,11 @@ assert!(result.valid); - RSA, Ed25519, and post-quantum (ML-DSA) cryptography - JSON Schema validation - Multi-agent agreements +- Signed agent state (memory, skills, plans, configs, hooks, or any document) +- Commitments (shared signed agreements between agents) +- Todo lists (private signed task tracking with cross-references) +- Conversation threading (ordered, signed message chains) +- PostgreSQL database storage (optional, `database` feature flag) - MCP and A2A protocol support - Python, Go, and NPM bindings @@ -68,7 +75,10 @@ jacs verify doc.json # Verify a document - Do **not** open public issues for security vulnerabilities - We aim to respond within 48 hours +**Dependency audit**: To check Rust dependencies for known vulnerabilities, run: `cargo install cargo-audit && cargo audit`. + **Best Practices**: +- Do not put the private key password in config; set `JACS_PRIVATE_KEY_PASSWORD` only. - Use strong passwords (12+ characters with mixed case, numbers, symbols) - Store private keys securely with appropriate file permissions - Keep JACS and its dependencies updated @@ -80,4 +90,4 @@ jacs verify doc.json # Verify a document - [Python](https://pypi.org/project/jacs/) - [Crates.io](https://crates.io/crates/jacs) -**Version**: 0.5.1 | [HAI.AI](https://hai.ai) +**Version**: 0.6.0 | [HAI.AI](https://hai.ai) diff --git a/jacs/SECURITY.md b/jacs/SECURITY.md index 03209c12b..c3fae6a0c 100644 --- a/jacs/SECURITY.md +++ b/jacs/SECURITY.md @@ -1,9 +1,21 @@ # Security Policy +## Security model + +- **Passwords**: The private key password must be set only via the `JACS_PRIVATE_KEY_PASSWORD` environment variable. It is never stored in config files. +- **Keys**: Private keys are encrypted at rest (AES-256-GCM with PBKDF2). Public keys and config may be stored on disk. +- **Paths**: Paths built from untrusted input (e.g. `publicKeyHash` from documents) are validated to prevent traversal (`require_relative_path_safe`); key and data directory path builders enforce this. Validation rejects empty segments, `.`, `..`, null bytes, and Windows drive-prefixed paths (`C:\...`, `D:/...`). +- **Trust store IDs**: Trust operations canonicalize agent identity to `UUID:VERSION_UUID` for filename safety. Canonical agent docs with split `jacsId` + `jacsVersion` are accepted and normalized before path use. +- **Schema filesystem access**: Filesystem schema loading is opt-in (`JACS_ALLOW_FILESYSTEM_SCHEMAS=true`) and restricted to configured allowed roots using normalized/canonical path containment checks. +- **Network transport policy**: HAI registration verification enforces HTTPS for `HAI_API_URL` (localhost HTTP allowed for local testing only). +- **No secrets in config**: Config files and env overrides must not contain passwords or other secrets. +- **A2A foreign verification**: Foreign wrapped-artifact signatures are only marked verified when signer keys are resolved and verified cryptographically; unresolved foreign keys return explicit `Unverified` status. + +## Reporting vulnerabilities + If you think you have identified a security issue with a JACS, do not open a public issue. To responsibly report a security issue, please navigate to the "Security" tab for the repo, and click "Report a vulnerability". Be sure to include as much detail as necessary in your report. As with reporting normal issues, a minimal reproducible example will help the maintainers address the issue faster. Thank you for supporting JACS. - diff --git a/jacs/docs/jacsbook/book/404.html b/jacs/docs/jacsbook/book/404.html index fe318441c..5d9b62b0d 100644 --- a/jacs/docs/jacsbook/book/404.html +++ b/jacs/docs/jacsbook/book/404.html @@ -89,7 +89,7 @@