diff --git a/AGENTS.md b/AGENTS.md index 24db73c3..5bda15c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,33 @@ jacs/ is the directory with the core library. Look for examples in tests for how to use the library. README.md and CHANGELOG.md may be useful to understand some future goals and what has been done. +## Feature Parity Enforcement + +Cross-language feature parity is enforced through canonical JSON fixtures that serve as the single source of truth. When you add or remove a method, error kind, CLI command, MCP tool, or adapter, you must update the relevant fixture — snapshot tests in all languages will fail otherwise. + +### Canonical fixtures + +| Fixture | What it tracks | Consumed by | +|---------|---------------|-------------| +| `binding-core/tests/fixtures/method_parity.json` | 26 `SimpleAgentWrapper` public methods | Rust, Python, Node, Go | +| `binding-core/tests/fixtures/parity_inputs.json` | 13 `ErrorKind` variants + behavioral notes | Rust, Python, Node, Go | +| `binding-core/tests/fixtures/adapter_inventory.json` | Framework adapter modules and public functions | Rust, Python, Node | +| `binding-core/tests/fixtures/cli_mcp_alignment.json` | CLI-to-MCP tool mapping (aligned, CLI-only, MCP-only) | Rust | +| `jacs-cli/contract/cli_commands.json` | 29 CLI commands + 4 feature-gated | Rust (extracted from Clap tree) | +| `jacs-mcp/contract/jacs-mcp-contract.json` | 42 MCP tools with parameter schemas | Python, Node, Go | + +### What to update when + +| Change | Update these fixtures | Tests that will catch you | +|--------|----------------------|--------------------------| +| Add/remove a `SimpleAgentWrapper` method | `method_parity.json` | `method_parity.rs`, `test_method_parity.py`, `method-parity.test.js`, `method_parity_test.go` | +| Add/remove an `ErrorKind` variant | `parity_inputs.json` (error_kinds array) | `parity.rs`, `test_error_parity.py`, `error-parity.test.js`, `error_parity_test.go` | +| Add/remove a CLI command | `cli_commands.json` + `cli_mcp_alignment.json` | `cli_command_snapshot.rs`, `cli_mcp_alignment.rs` | +| Add/remove an MCP tool | `jacs-mcp-contract.json` + `cli_mcp_alignment.json` | `mcp_contract.test.js`, `test_mcp_contract.py`, `mcp_contract_drift_test.go`, `cli_mcp_alignment.rs` | +| Add/remove a framework adapter | `adapter_inventory.json` | `adapter_inventory.rs`, `test_adapter_inventory.py`, `adapter-inventory.test.js` | + +Each language defines its own exclusions and name mappings (e.g., `to_yaml` is excluded from Python/Go because those bindings don't expose it). The fixture is always the source of truth. + ## Releasing See **[RELEASING.md](./RELEASING.md)** for the complete release process, including diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d127edb..7e5a0fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +## 0.9.13 + +### Cross-Language Feature Parity Enforcement + +Added an automated parity enforcement system that catches binding drift across Rust, Python, Node.js, and Go through canonical JSON fixtures and snapshot tests. + +**New fixtures (single source of truth for all languages):** +- `binding-core/tests/fixtures/method_parity.json` — 26 `SimpleAgentWrapper` public methods +- `binding-core/tests/fixtures/parity_inputs.json` — added `error_kinds` array (13 `ErrorKind` variants) and `sign_message_invalid_json_behavior` behavioral note +- `binding-core/tests/fixtures/adapter_inventory.json` — framework adapter modules and exported functions +- `binding-core/tests/fixtures/cli_mcp_alignment.json` — CLI-to-MCP tool mapping (16 aligned, 15 CLI-only, 27 MCP-only) +- `jacs-cli/contract/cli_commands.json` — 29 CLI commands + 4 feature-gated, with `mcp_tool` and `mcp_excluded_reason` fields + +**New tests:** +- Rust: `method_parity.rs` (3), `parity.rs` error kind tests (2), `adapter_inventory.rs` (5), `cli_command_snapshot.rs` (4), `cli_mcp_alignment.rs` (5) — 19 new tests +- Python: `test_method_parity.py` (4), `test_error_parity.py` (9 incl. runtime triggers), `test_adapter_inventory.py` (5+) — 18+ new tests +- Node.js: `method-parity.test.js` (5), `error-parity.test.js` (4), `adapter-inventory.test.js` (5) — 14 new tests +- Go: `method_parity_test.go` (3), `error_parity_test.go` (4), `mcp_contract_drift_test.go` (3) — 10 new tests + +### CLI + +- Re-enabled `task create` command handler (was commented out; may be used by a2a) +- Extracted `build_cli()` public function from `main()` so snapshot tests can walk the Clap tree programmatically + +### Documentation + +- Added `DEVELOPMENT.md` — full API reference for Rust, Python, Node.js, and Go with examples, feature flags, storage backends, security, and framework adapters +- Added "Feature Parity Enforcement" section to `AGENTS.md` with fixture inventory and what-to-update-when guide +- Added "Feature Parity" section to `DEVELOPMENT.md` linking to `AGENTS.md` +- Updated `README.md` with refreshed messaging and streamlined content +- Updated binding READMEs (`jacspy`, `jacsnpm`, `jacsgo`, `jacs-cli`, `jacs-mcp`) — consolidated to reference `DEVELOPMENT.md` + ## 0.9.12 (unreleased) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..3e505858 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,442 @@ +# Development + +Detailed API reference, language-specific usage, framework adapters, and build instructions. + +## Rust library + +```toml +[dependencies] +jacs = "0.9.7" +``` + +```rust +use jacs::simple::{load, sign_message, verify}; + +load(None)?; +let signed = sign_message(&serde_json::json!({"action": "approve"}))?; +let result = verify(&signed.raw)?; +assert!(result.valid); +``` + +### Feature flags + +| Feature | Default | What it enables | +|---------|---------|----------------| +| `sqlite` | Yes | Sync SQLite storage backend (rusqlite) | +| `sqlx-sqlite` | No | Async SQLite storage backend (sqlx + tokio) | +| `a2a` | No | Agent-to-Agent protocol support | +| `agreements` | No | Multi-agent agreement signing with quorum and timeouts | +| `attestation` | No | Evidence-based attestation and DSSE export | +| `otlp-logs` | No | OpenTelemetry log export | +| `otlp-metrics` | No | OpenTelemetry metrics export | +| `otlp-tracing` | No | OpenTelemetry distributed tracing | + +### Storage backends + +Default storage is **filesystem** (`jacs_data/`). For indexed local search, set `jacs_default_storage` to `"rusqlite"`. + +Storage guarantees: +- Every read verifies the stored document before returning it. +- Every write verifies the signed document before persisting it. +- Updating a signed document without re-signing fails. + +| Backend | Crate | Install | +|---------|-------|---------| +| Filesystem | built-in | (always available) | +| SQLite (rusqlite) | built-in (`sqlite` feature) | `cargo add jacs --features sqlite` | +| SQLite (sqlx) | built-in (`sqlx-sqlite` feature) | `cargo add jacs --features sqlx-sqlite` | +| PostgreSQL | `jacs-postgresql` | `cargo add jacs-postgresql` | +| DuckDB | `jacs-duckdb` | `cargo add jacs-duckdb` | +| SurrealDB | `jacs-surrealdb` | `cargo add jacs-surrealdb` | +| Redb | `jacs-redb` | `cargo add jacs-redb` | + +### Document visibility + +| Level | Meaning | +|-------|---------| +| `public` | Fully public — can be shared, listed, and returned to any caller | +| `private` | Private to the owning agent (default) | +| `restricted` | Restricted to explicitly named agent IDs or roles | + +Visibility is part of signed document state. Changing it creates a new signed version. + +## Python + +```bash +pip install jacs +``` + +Prebuilt native bindings via maturin. No Rust compilation during install. + +### Simple API + +```python +import jacs.simple as jacs + +info = jacs.quickstart(name="my-agent", domain="my-agent.example.com") +signed = jacs.sign_message({"action": "approve", "amount": 100}) +result = jacs.verify(signed.raw) +print(f"Valid: {result.valid}, Signer: {result.signer_id}") +``` + +### Loading an existing agent + +```python +agent = jacs.load("./jacs.config.json") +signed = jacs.sign_message({"action": "approve", "amount": 100}) +signed_file = jacs.sign_file("document.pdf", embed=True) +``` + +### Headless loading (no env vars) + +```python +from jacs import JacsAgent + +secret = get_secret_from_manager() +agent = JacsAgent() +agent.set_private_key_password(secret) +info = json.loads(agent.load_with_info("/srv/my-project/jacs.config.json")) +``` + +### Full API reference + +| Operation | Description | +|-----------|-------------| +| `quickstart(name, domain)` | Create a persistent agent with keys on disk | +| `create()` | Create a new agent programmatically | +| `load()` | Load an existing agent from config | +| `verify_self()` | Verify the loaded agent's integrity | +| `update_agent()` | Update the agent document | +| `update_document()` | Update an existing document | +| `sign_message()` | Sign text or JSON data | +| `sign_file()` | Sign a file with optional embedding | +| `verify()` | Verify any signed document | +| `verify_standalone()` | Verify without loading an agent | +| `verify_by_id()` | Verify a document by its storage ID | +| `get_dns_record()` | Get DNS TXT record for the agent | +| `get_well_known_json()` | Get well-known JSON for discovery | +| `export_agent()` | Export agent JSON for sharing | +| `get_public_key()` | Get the agent's public key | +| `reencrypt_key()` | Re-encrypt the private key | +| `trust_agent()` | Add an agent to the local trust store | +| `list_trusted_agents()` | List all trusted agent IDs | +| `untrust_agent()` | Remove an agent from the trust store | +| `is_trusted()` | Check if an agent is trusted | +| `audit()` | Run a security audit | + +### Instance-based API (JacsClient) + +For multiple agents in one process: + +```python +from jacs.client import JacsClient + +client = JacsClient("./jacs.config.json") +signed = client.sign_message({"action": "approve"}) + +# Or zero-config +client = JacsClient.quickstart(name="my-agent", domain="example.com") + +# Ephemeral (in-memory, for tests) +client = JacsClient.ephemeral() +``` + +### Agreements + +```python +from datetime import datetime, timedelta, timezone + +agreement = client.create_agreement( + document={"proposal": "Deploy model v2"}, + agent_ids=[alice.agent_id, bob.agent_id, mediator.agent_id], + question="Do you approve?", + quorum=2, + timeout=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), +) + +signed = alice.sign_agreement(agreement) +status = alice.check_agreement(signed) +``` + +### Framework adapters + +```bash +pip install jacs[langchain] # LangChain / LangGraph +pip install jacs[fastapi] # FastAPI / Starlette +pip install jacs[crewai] # CrewAI +pip install jacs[anthropic] # Anthropic / Claude SDK +pip install jacs[all] # Everything +``` + +**LangChain:** +```python +from jacs.adapters.langchain import jacs_signing_middleware +agent = create_agent(model="openai:gpt-4o", tools=tools, middleware=[jacs_signing_middleware()]) +``` + +**FastAPI:** +```python +from jacs.adapters.fastapi import JacsMiddleware +app.add_middleware(JacsMiddleware) +``` + +**CrewAI:** +```python +from jacs.adapters.crewai import jacs_guardrail +task = Task(description="Analyze data", agent=my_agent, guardrail=jacs_guardrail()) +``` + +**Anthropic:** +```python +from jacs.adapters.anthropic import signed_tool + +@signed_tool() +def get_weather(location: str) -> str: + return f"Weather in {location}: sunny" +``` + +### A2A protocol + +```python +from jacs.client import JacsClient + +client = JacsClient.quickstart(name="my-agent", domain="example.com") +card = client.export_agent_card("http://localhost:8080") +signed = client.sign_artifact({"action": "classify", "input": "hello"}, "task") +``` + +### Testing + +```python +from jacs.testing import jacs_agent + +def test_sign_and_verify(jacs_agent): + signed = jacs_agent.sign_message({"test": True}) + result = jacs_agent.verify(signed.raw_json) + assert result.valid +``` + +### Build from source + +```bash +make setup # Install dependencies (uv) +make dev # Build Rust extension +make test # Run all tests +``` + +## Node.js + +```bash +npm install @hai.ai/jacs +``` + +Prebuilt native bindings. No Rust compilation during install. + +### Simple API + +```javascript +const jacs = require('@hai.ai/jacs/simple'); + +await jacs.quickstart({ name: 'my-agent', domain: 'agent.example.com' }); +const signed = await jacs.signMessage({ action: 'approve', amount: 100 }); +const result = await jacs.verify(signed.raw); +console.log(`Valid: ${result.valid}, Signer: ${result.signerId}`); +``` + +All operations are async by default. Sync variants with `Sync` suffix. + +### Full API reference + +| Function | Sync Variant | Description | +|----------|-------------|-------------| +| `quickstart(options)` | `quickstartSync()` | Create a persistent agent | +| `create(options)` | `createSync()` | Create a new agent | +| `load(configPath)` | `loadSync()` | Load from config | +| `signMessage(data)` | `signMessageSync()` | Sign JSON data | +| `signFile(path, embed)` | `signFileSync()` | Sign a file | +| `verify(doc)` | `verifySync()` | Verify a document | +| `verifyById(id)` | `verifyByIdSync()` | Verify by storage ID | +| `createAgreement(...)` | `createAgreementSync()` | Create multi-party agreement | +| `signAgreement(doc)` | `signAgreementSync()` | Co-sign an agreement | +| `checkAgreement(doc)` | `checkAgreementSync()` | Check agreement status | +| `createAttestation(params)` | `createAttestationSync()` | Create attestation | +| `verifyAttestation(doc)` | `verifyAttestationSync()` | Verify attestation | +| `audit(options)` | `auditSync()` | Security audit | + +Pure sync functions (no suffix needed): `verifyStandalone`, `getPublicKey`, `isLoaded`, `getDnsRecord`, `getWellKnownJson`, `trustAgent`, `listTrustedAgents`, `isTrusted`. + +### Instance-based API (JacsClient) + +```typescript +import { JacsClient } from '@hai.ai/jacs/client'; + +const client = await JacsClient.quickstart({ name: 'my-agent', domain: 'example.com' }); +const signed = await client.signMessage({ action: 'approve' }); + +// Ephemeral (in-memory, for tests) +const test = await JacsClient.ephemeral('ring-Ed25519'); +``` + +### Framework adapters + +**Vercel AI SDK:** +```typescript +import { withProvenance } from '@hai.ai/jacs/vercel-ai'; +const model = withProvenance(openai('gpt-4o'), { client }); +``` + +**Express:** +```typescript +import { jacsMiddleware } from '@hai.ai/jacs/express'; +app.use(jacsMiddleware({ client, verify: true })); +``` + +**LangChain.js:** +```typescript +import { createJacsTools } from '@hai.ai/jacs/langchain'; +const jacsTools = createJacsTools({ client }); +``` + +**MCP:** +```typescript +import { registerJacsTools } from '@hai.ai/jacs/mcp'; +registerJacsTools(server, client); +``` + +All framework dependencies are optional peer deps. + +### A2A protocol + +```typescript +const client = await JacsClient.quickstart({ name: 'my-agent', domain: 'example.com' }); +const card = client.exportAgentCard(); +const signed = await client.signArtifact({ action: 'classify', input: 'hello' }, 'task'); +``` + +### Testing + +```typescript +import { createTestClient } from '@hai.ai/jacs/testing'; + +const client = await createTestClient('ring-Ed25519'); +const signed = await client.signMessage({ hello: 'test' }); +const result = await client.verify(signed.raw); +assert(result.valid); +``` + +## Go + +```bash +go get github.com/HumanAssisted/JACS/jacsgo +``` + +Uses CGo to call the JACS Rust library via FFI. Requires a Rust toolchain to build from source. + +### Quick start + +```go +import jacs "github.com/HumanAssisted/JACS/jacsgo" + +jacs.Load(nil) +signed, _ := jacs.SignMessage(map[string]interface{}{"action": "approve", "amount": 100}) +result, _ := jacs.Verify(signed.Raw) +fmt.Printf("Valid: %t, Signer: %s\n", result.Valid, result.SignerID) +``` + +### API reference + +| Function | Description | +|----------|-------------| +| `Load(configPath)` | Load agent from config | +| `Create(name, opts)` | Create new agent with keys | +| `SignMessage(data)` | Sign any JSON data | +| `SignFile(path, embed)` | Sign a file | +| `Verify(doc)` | Verify signed document | +| `VerifyStandalone(doc, opts)` | Verify without an agent | +| `ExportAgent()` | Export agent JSON | +| `GetPublicKeyPEM()` | Get public key | +| `Audit(opts)` | Security audit | + +For concurrent use, create instances with `NewJacsAgent()`. + +### Build + +```bash +cd jacsgo && make build +``` + +## Integrations + +| Integration | Import | Status | +|-------------|--------|--------| +| Python + LangChain | `from jacs.adapters.langchain import jacs_signing_middleware` | Experimental | +| Python + CrewAI | `from jacs.adapters.crewai import jacs_guardrail` | Experimental | +| Python + FastAPI | `from jacs.adapters.fastapi import JacsMiddleware` | Experimental | +| Python + Anthropic SDK | `from jacs.adapters.anthropic import signed_tool` | Experimental | +| Node.js + Vercel AI SDK | `require('@hai.ai/jacs/vercel-ai')` | Experimental | +| Node.js + Express | `require('@hai.ai/jacs/express')` | Experimental | +| Node.js + LangChain.js | `require('@hai.ai/jacs/langchain')` | Experimental | +| MCP (Rust, canonical) | `jacs mcp` | Stable | +| A2A Protocol | `client.get_a2a()` | Experimental | + +## Feature Parity + +Cross-language feature parity is enforced by canonical JSON fixtures in `binding-core/tests/fixtures/` and contract files in `jacs-cli/contract/` and `jacs-mcp/contract/`. Snapshot tests in Rust, Python, Node, and Go validate that each binding covers the same methods, error kinds, CLI commands, MCP tools, and framework adapters. + +If you add or remove a public method, error kind, CLI command, MCP tool, or adapter, update the relevant fixture. Tests across all languages will fail until you do. See **[AGENTS.md](./AGENTS.md#feature-parity-enforcement)** for the full fixture inventory and update guide. + +Key fixtures: +- `binding-core/tests/fixtures/method_parity.json` — SimpleAgentWrapper methods (all languages) +- `binding-core/tests/fixtures/parity_inputs.json` — ErrorKind variants (all languages) +- `binding-core/tests/fixtures/cli_mcp_alignment.json` — CLI-to-MCP mapping +- `jacs-cli/contract/cli_commands.json` — CLI commands (validated against Clap tree) +- `jacs-mcp/contract/jacs-mcp-contract.json` — MCP tool schemas + +## Security + +### Hardening + +- Password entropy validation for key encryption (minimum 28 bits) +- Thread-safe environment variable handling +- TLS certificate validation (strict by default) +- Private key zeroization on drop +- Algorithm identification embedded in signatures with downgrade prevention +- DNSSEC-validated identity verification +- 260+ automated tests + +### Best practices + +- Prefer the OS keychain on desktops when available. +- On Linux/headless services, use `JACS_PASSWORD_FILE` from a secret mount and set `JACS_KEYCHAIN_BACKEND=disabled`. +- `JACS_PRIVATE_KEY_PASSWORD` is supported but less desirable for long-running services. +- Use strong passwords (12+ characters with mixed case, numbers, symbols). +- Keep JACS and its dependencies updated. + +### Reporting vulnerabilities + +Email: security@hai.ai. Do not open public issues for security vulnerabilities. We aim to respond within 48 hours. + +### Dependency audit + +```bash +cargo install cargo-audit && cargo audit +``` + +## Trust policies + +| Policy | Behavior | +|--------|----------| +| `open` | Accept all signatures without key resolution | +| `verified` | Require key resolution before accepting (default) | +| `strict` | Require the signer to be in your local trust store | + +## Password requirements + +Passwords must be at least 8 characters and include uppercase, lowercase, a digit, and a special character. + +For headless environments, prefer `JACS_PASSWORD_FILE` from a secret mount: + +```bash +export JACS_PASSWORD_FILE=/run/secrets/jacs-password +export JACS_KEYCHAIN_BACKEND=disabled +``` diff --git a/LINES_OF_CODE.md b/LINES_OF_CODE.md index 4128469c..a9c37ba3 100644 --- a/LINES_OF_CODE.md +++ b/LINES_OF_CODE.md @@ -1,13 +1,13 @@ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Language Files Lines Code Comments Blanks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Go 11 4661 3463 557 641 - Python 185 45034 35321 1971 7742 - TypeScript 30 8814 6292 1813 709 + Go 13 5859 4344 708 807 + Python 188 46396 36376 2037 7983 + TypeScript 30 9105 6380 2000 725 ───────────────────────────────────────────────────────────────────────────────── - Rust 424 136258 113356 6889 16013 - |- Markdown 319 27457 681 20436 6340 - (Total) 163715 114037 27325 22353 + Rust 258 106330 87477 6054 12799 + |- Markdown 213 9703 491 7231 1981 + (Total) 116033 87968 13285 14780 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Total 650 222224 159113 31666 31445 + Total 489 177393 135068 18030 24295 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/README.md b/README.md index 8b7fba4a..eecfa26c 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # JACS -**Prove who said what, cryptographically.** +**Cryptographic identity, data provenance, and trust for AI agents.** -Cryptographic signatures for AI agent outputs. No server. No account. Three lines of code. +JACS gives every AI agent a verifiable identity, signs everything it produces, and lets any other agent or system verify who said what — without a central server. -`pip install jacs` | `npm install @hai.ai/jacs` | `cargo install jacs-cli` +`cargo install jacs-cli` | `brew install jacs` -> For a higher-level agent framework built on JACS, see [haiai](https://github.com/HumanAssisted/haiai). +> For the HAI.AI platform (agent email, benchmarks, leaderboard), see [haiai](https://github.com/HumanAssisted/haiai). [![Rust](https://github.com/HumanAssisted/JACS/actions/workflows/rust.yml/badge.svg)](https://github.com/HumanAssisted/JACS/actions/workflows/rust.yml) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/HumanAssisted/JACS/blob/main/LICENSE) @@ -14,248 +14,125 @@ Cryptographic signatures for AI agent outputs. No server. No account. Three line [![npm](https://img.shields.io/npm/v/@hai.ai/jacs)](https://www.npmjs.com/package/@hai.ai/jacs) [![PyPI](https://img.shields.io/pypi/v/jacs)](https://pypi.org/project/jacs/) [![Rust 1.93+](https://img.shields.io/badge/rust-1.93+-DEA584.svg?logo=rust)](https://www.rust-lang.org/) - [![Homebrew](https://github.com/HumanAssisted/JACS/actions/workflows/homebrew.yml/badge.svg)](https://github.com/HumanAssisted/JACS/actions/workflows/homebrew.yml) +## What JACS does -## The Simple Contract - -JACS has four core operations. Everything else builds on these: - -| Operation | What it does | -|-----------|-------------| -| **Create** | Generate an agent identity with a cryptographic key pair | -| **Sign** | Attach a tamper-evident signature to any JSON payload or file | -| **Verify** | Prove a signed document is authentic and unmodified | -| **Export** | Share your agent's public key or signed documents with others | - -## Quick Start +| Capability | What it means | +|-----------|---------------| +| **Agent Identity** | Generate a cryptographic keypair that uniquely identifies your agent. Post-quantum ready (ML-DSA-87/FIPS-204) by default. | +| **Data Provenance** | Sign any JSON document or file. Every signature is tamper-evident — anyone can verify the content hasn't been modified and who produced it. | +| **Agent Trust** | Verify other agents' identities, manage a local trust store, and establish trust policies (`open`, `verified`, `strict`) for cross-agent interactions. | -### Password Setup - -```bash -# Developer / desktop workflow -export JACS_PRIVATE_KEY_PASSWORD='use-a-strong-password' -``` - -For Linux or other headless service environments, prefer a secret-mounted -password file over keeping the password in the process environment: - -```bash -export JACS_PASSWORD_FILE=/run/secrets/jacs-password -export JACS_KEYCHAIN_BACKEND=disabled -``` - -### Python - -```python -import jacs.simple as jacs - -info = jacs.quickstart(name="payments-agent", domain="payments.example.com") -signed = jacs.sign_message({"action": "approve", "amount": 100}) -result = jacs.verify(signed.raw) -print(f"Valid: {result.valid}, Signer: {result.signer_id}") -``` - -### Node.js - -```javascript -const jacs = require('@hai.ai/jacs/simple'); - -const info = await jacs.quickstart({ - name: 'payments-agent', - domain: 'payments.example.com', -}); -const signed = await jacs.signMessage({ action: 'approve', amount: 100 }); -const result = await jacs.verify(signed.raw); -console.log(`Valid: ${result.valid}, Signer: ${result.signerId}`); -``` - -### Go - -```go -import jacs "github.com/HumanAssisted/JACS/jacsgo" - -jacs.Load(nil) -signed, _ := jacs.SignMessage(map[string]interface{}{"action": "approve", "amount": 100}) -result, _ := jacs.Verify(signed.Raw) -fmt.Printf("Valid: %t, Signer: %s\n", result.Valid, result.SignerID) -``` - -### Rust / CLI +## Quick start ```bash cargo install jacs-cli -jacs quickstart --name payments-agent --domain payments.example.com + +export JACS_PRIVATE_KEY_PASSWORD='your-password' +jacs quickstart --name my-agent --domain example.com jacs document create -f mydata.json jacs verify signed-document.json ``` -### Homebrew (macOS) +Or via Homebrew: ```bash brew tap HumanAssisted/homebrew-jacs brew install jacs ``` -## Verify a Signed Document +## MCP server -No agent needed. One command or one function call. +JACS includes a built-in MCP server for AI tool integration (Claude Desktop, Cursor, Claude Code, etc.): ```bash -jacs verify signed-document.json # exit code 0 = valid -jacs verify --remote https://example.com/doc.json --json # fetch + verify +jacs mcp ``` -```python -result = jacs.verify_standalone(signed_json, key_directory="./keys") +```json +{ + "mcpServers": { + "jacs": { + "command": "jacs", + "args": ["mcp"] + } + } +} ``` -```typescript -const r = verifyStandalone(signedJson, { keyDirectory: './keys' }); -``` +The MCP server uses **stdio transport only** — no HTTP endpoints. This is a deliberate security choice: the server holds the agent's private key, so it runs as a subprocess of your MCP client. The key never leaves the local process and no ports are opened. -## Use Cases +**Core profile** (default) — 7 tool families: state, document, trust, audit, memory, search, key. -JACS is optimized for five scenarios: +**Full profile** (`jacs mcp --profile full`) — adds agreements, messaging, A2A, and attestation tools. -**U1. Local Provenance** -- An agent creates, signs, verifies, and exports its identity and documents locally. No server required. This is the baseline JACS promise. +## Core operations -**U2. Trusted Local Memory** -- An agent stores memories, plans, tool audit trails, and configs as signed local documents with searchable metadata and visibility controls (`public`/`private`/`restricted`). +| Operation | What it does | +|-----------|-------------| +| **Create** | Generate an agent identity with a cryptographic keypair | +| **Sign** | Attach a tamper-evident signature to any JSON payload or file | +| **Verify** | Prove a signed document is authentic and unmodified | +| **Export** | Share your agent's public key or signed documents with others | -**U3. Public Signed Publishing** -- An agent publishes agent cards, public keys, attestations, and shared artifacts that anyone can verify. +## Use cases -**U4. Platform Workflows** -- A [haiai](https://github.com/HumanAssisted/haiai) client uses the same JACS identity to register with HAI, send signed email, and exchange signed artifacts with platform services. +**Local provenance** — An agent creates, signs, verifies, and exports documents locally. No server required. -**U5. Advanced Provenance** -- Multi-agent agreements, A2A provenance chains, attestation, and richer storage backends. These are feature-gated and optional -- they do not define the default onboarding story. +**Trusted local memory** — Store agent memories, plans, configs as signed documents with searchable metadata and visibility controls (`public`/`private`/`restricted`). -See [USECASES.md](USECASES.md) for detailed scenario walkthroughs. +**Platform workflows** — Use the same JACS identity with [haiai](https://github.com/HumanAssisted/haiai) to register with HAI.AI, send signed email, and run benchmarks. -## When You DON'T Need JACS +**Multi-agent trust** — Agreements with quorum signing, A2A interoperability, attestation chains, and DNS-verified identity discovery. + +## When you DON'T need JACS - **Single developer, single service.** Standard logging is fine. - **Internal-only prototypes.** No trust boundaries, no value in signing. - **Simple checksums.** If you only need to detect accidental corruption, use SHA-256. -JACS adds value when data crosses trust boundaries -- between organizations, between services with different operators, or into regulated audit trails. - -## Storage - -The default storage backend is **filesystem**: signed documents live as JSON on disk under `jacs_data/`. For indexed local search, set `jacs_default_storage` to `"rusqlite"` and JACS stores document rows in `jacs_data/jacs_documents.sqlite3`. - -`DocumentService` storage in JACS core currently guarantees: - -- Every read verifies the stored JACS document before returning it. -- Every create and update verifies the signed document before persisting it. -- If an update payload modifies an already-signed JACS document without re-signing it, the write fails. - -Additional backends are available as separate crates: - -| Backend | Crate | Install | -|---------|-------|---------| -| Filesystem | built-in | (always available) | -| Local indexed SQLite (`rusqlite`) | built-in (`sqlite` feature, default) | `cargo add jacs --features sqlite` | -| SQLite (async, sqlx) | built-in (`sqlx-sqlite` feature) | `cargo add jacs --features sqlx-sqlite` | -| PostgreSQL | `jacs-postgresql` | `cargo add jacs-postgresql` | -| DuckDB | `jacs-duckdb` | `cargo add jacs-duckdb` | -| SurrealDB | `jacs-surrealdb` | `cargo add jacs-surrealdb` | -| Redb | `jacs-redb` | `cargo add jacs-redb` | - -JACS core resolves the unified `DocumentService` for `fs` and `rusqlite`. Extracted backend crates expose the same traits in their own packages. See [Storage Backends](https://humanassisted.github.io/JACS/advanced/storage.html) for current configuration details. - -## Document Visibility - -Every document has a visibility level that controls access: - -| Level | Meaning | -|-------|---------| -| `public` | Fully public -- can be shared, listed, and returned to any caller | -| `private` | Private to the owning agent (default) | -| `restricted` | Restricted to explicitly named agent IDs or roles | - -Visibility is part of signed document state. Changing it creates a new signed version. +JACS adds value when data crosses trust boundaries — between organizations, between services with different operators, or into regulated audit trails. -## Feature Flags - -JACS uses Cargo features to keep the default build minimal: - -| Feature | Default | What it enables | -|---------|---------|----------------| -| `sqlite` | Yes | Sync SQLite storage backend (rusqlite) | -| `sqlx-sqlite` | No | Async SQLite storage backend (sqlx + tokio) | -| `a2a` | No | Agent-to-Agent protocol support | -| `agreements` | No | Multi-agent agreement signing with quorum and timeouts | -| `attestation` | No | Evidence-based attestation and DSSE export | -| `otlp-logs` | No | OpenTelemetry log export | -| `otlp-metrics` | No | OpenTelemetry metrics export | -| `otlp-tracing` | No | OpenTelemetry distributed tracing | - -## MCP Server - -JACS includes a Model Context Protocol (MCP) server for AI tool integration: - -```bash -jacs mcp # start with core tools (default) -jacs mcp --profile full # start with all tools -``` - -**Core profile** (default) -- 7 tool families: state, document, trust, audit, memory, search, key. - -**Full profile** -- Core + 4 advanced families: agreements, messaging, a2a, attestation. - -Set the profile via `--profile ` or `JACS_MCP_PROFILE` environment variable. - -The MCP server uses stdio only. It does not expose HTTP endpoints. +## Features -For Linux/headless startup, provide both the config path and a non-interactive -password source before launching: +- **Post-quantum ready** — ML-DSA-87 (FIPS-204) default, with Ed25519 and RSA-PSS. +- **Cross-language** — Sign in Rust, verify in Python or Node.js. Tested on every commit. +- **Pluggable storage** — Filesystem, SQLite, PostgreSQL, DuckDB, SurrealDB, Redb. +- **Document visibility** — `public`, `private`, or `restricted` access control. +- **Trust policies** — `open`, `verified` (default), or `strict` modes. +- **Multi-agent agreements** — Quorum signing, timeouts, algorithm requirements (feature-gated). +- **A2A interoperability** — Every JACS agent is an A2A agent with zero config (feature-gated). -```bash -export JACS_CONFIG=/srv/my-project/jacs.config.json -export JACS_PASSWORD_FILE=/run/secrets/jacs-password -export JACS_KEYCHAIN_BACKEND=disabled -jacs mcp -``` +## Language bindings (experimental) -For embedded Python/Node processes, prefer in-memory secret injection over -environment variables when possible. The low-level bindings expose per-agent -password setters for that use case. +The MCP server and CLI are the recommended integration paths. Native bindings exist for direct library use: -## Integrations +| Language | Install | Status | +|----------|---------|--------| +| Python | `pip install jacs` | Experimental | +| Node.js | `npm install @hai.ai/jacs` | Experimental | +| Go | `go get github.com/HumanAssisted/JACS/jacsgo` | Experimental | -Framework adapters for signing AI outputs with zero infrastructure: +See [DEVELOPMENT.md](DEVELOPMENT.md) for library APIs, framework adapters, and build instructions. -| Integration | Import | Status | -|-------------|--------|--------| -| Python + LangChain | `from jacs.adapters.langchain import jacs_signing_middleware` | Experimental | -| Python + CrewAI | `from jacs.adapters.crewai import jacs_guardrail` | Experimental | -| Python + FastAPI | `from jacs.adapters.fastapi import JacsMiddleware` | Experimental | -| Python + Anthropic SDK | `from jacs.adapters.anthropic import signed_tool` | Experimental | -| Node.js + Vercel AI SDK | `require('@hai.ai/jacs/vercel-ai')` | Experimental | -| Node.js + Express | `require('@hai.ai/jacs/express')` | Experimental | -| Node.js + LangChain.js | `require('@hai.ai/jacs/langchain')` | Experimental | -| MCP (Rust, canonical) | `jacs mcp` | Stable | -| A2A Protocol | `client.get_a2a()` | Experimental | -| Go bindings | `jacsgo` | Experimental | +## Security -## Features +- **Private keys are encrypted** with password-based key derivation. +- **MCP server is stdio-only** — no network exposure. +- **260+ automated tests** covering cryptographic operations, password validation, agent lifecycle, DNS verification, and attack scenarios. +- **Post-quantum default** — ML-DSA-87 (FIPS-204) composite signatures. -- **Post-quantum ready** -- ML-DSA-87 (FIPS-204) is the default algorithm alongside Ed25519 and RSA-PSS. -- **Cross-language** -- Sign in Rust, verify in Python or Node.js. Tested on every commit. -- **Multi-agent agreements** -- Quorum signing, timeouts, algorithm requirements (feature-gated). -- **A2A interoperability** -- Every JACS agent is an A2A agent with zero additional config (feature-gated). -- **Trust policies** -- `open`, `verified` (default), or `strict` modes. -- **Document visibility** -- `public`, `private`, or `restricted` access control on every document. -- **Pluggable storage** -- Filesystem, SQLite, PostgreSQL, DuckDB, SurrealDB, Redb via trait-based backends. -- **MCP integration** -- Full MCP server with core and full tool profiles. +Report vulnerabilities to security@hai.ai. Do not open public issues for security concerns. -### Links +## Links - [Documentation](https://humanassisted.github.io/JACS/) -- [Full Quick Start Guide](https://humanassisted.github.io/JACS/getting-started/quick-start.html) +- [Quick Start Guide](https://humanassisted.github.io/JACS/getting-started/quick-start.html) - [Algorithm Guide](https://humanassisted.github.io/JACS/advanced/algorithm-guide.html) -- [API Reference](https://humanassisted.github.io/JACS/nodejs/api.html) - [Use Cases](USECASES.md) +- [Development Guide](DEVELOPMENT.md) +- [HAI.AI Platform](https://hai.ai) --- diff --git a/binding-core/Cargo.toml b/binding-core/Cargo.toml index fb3983ec..d79634d5 100644 --- a/binding-core/Cargo.toml +++ b/binding-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-binding-core" -version = "0.9.12" +version = "0.9.13" edition = "2024" rust-version = "1.93" resolver = "3" @@ -19,7 +19,7 @@ attestation = ["jacs/attestation"] pq-tests = [] [dependencies] -jacs = { version = "0.9.12", path = "../jacs" } +jacs = { version = "0.9.13", path = "../jacs" } serde_json = "1.0" base64 = "0.22.1" serde = { version = "1.0", features = ["derive"] } diff --git a/binding-core/src/simple_wrapper.rs b/binding-core/src/simple_wrapper.rs index 3dec18e9..69e0dc17 100644 --- a/binding-core/src/simple_wrapper.rs +++ b/binding-core/src/simple_wrapper.rs @@ -24,6 +24,11 @@ const _: () = { }; impl SimpleAgentWrapper { + // WARNING: If you add or remove a public method here, update BOTH: + // 1. binding-core/tests/fixtures/method_parity.json (canonical method list) + // 2. binding-core/tests/method_parity.rs::known_methods() (compile-time anchor) + // All language bindings (Python, Node, Go) have parity tests against that fixture. + // ========================================================================= // Constructors // ========================================================================= diff --git a/binding-core/tests/adapter_inventory.rs b/binding-core/tests/adapter_inventory.rs new file mode 100644 index 00000000..91fd266c --- /dev/null +++ b/binding-core/tests/adapter_inventory.rs @@ -0,0 +1,115 @@ +//! Framework adapter inventory structural test. +//! +//! Validates that `adapter_inventory.json` is well-formed and internally +//! consistent. Each language binding has its own test that validates +//! the adapters listed for that language actually exist. + +use serde_json::Value; + +fn load_adapter_inventory() -> Value { + let fixture_bytes = include_bytes!("fixtures/adapter_inventory.json"); + serde_json::from_slice(fixture_bytes).expect("adapter_inventory.json should be valid JSON") +} + +#[test] +fn test_adapter_inventory_is_valid_json() { + let inventory = load_adapter_inventory(); + assert!( + inventory["adapters"].is_object(), + "adapter_inventory.json should have an 'adapters' object" + ); +} + +#[test] +fn test_adapter_inventory_has_expected_languages() { + let inventory = load_adapter_inventory(); + let adapters = inventory["adapters"] + .as_object() + .expect("adapters should be an object"); + + assert!( + adapters.contains_key("python"), + "adapter inventory should have 'python' entry" + ); + assert!( + adapters.contains_key("node"), + "adapter inventory should have 'node' entry" + ); + assert!( + adapters.contains_key("go"), + "adapter inventory should have 'go' entry" + ); +} + +#[test] +fn test_adapter_inventory_python_has_expected_adapters() { + let inventory = load_adapter_inventory(); + let python = inventory["adapters"]["python"] + .as_object() + .expect("python should be an object"); + + let expected_adapters = ["mcp", "langchain", "crewai", "fastapi", "anthropic"]; + for adapter in &expected_adapters { + assert!( + python.contains_key(*adapter), + "Python should have '{}' adapter entry", + adapter + ); + } + assert_eq!( + python.len(), + expected_adapters.len(), + "Python should have exactly {} adapters. Found {}.", + expected_adapters.len(), + python.len() + ); +} + +#[test] +fn test_adapter_inventory_node_has_expected_adapters() { + let inventory = load_adapter_inventory(); + let node = inventory["adapters"]["node"] + .as_object() + .expect("node should be an object"); + + assert!( + node.contains_key("mcp"), + "Node should have 'mcp' adapter entry" + ); +} + +#[test] +fn test_adapter_inventory_entries_have_required_fields() { + let inventory = load_adapter_inventory(); + let adapters = inventory["adapters"].as_object().unwrap(); + + for (lang, lang_adapters) in adapters { + if let Some(obj) = lang_adapters.as_object() { + for (adapter_name, adapter) in obj { + // Skip _note fields + if adapter_name.starts_with('_') { + continue; + } + assert!( + adapter["module"].is_string(), + "{}/{} should have a 'module' string", + lang, + adapter_name + ); + assert!( + adapter["public_functions"].is_array(), + "{}/{} should have a 'public_functions' array", + lang, + adapter_name + ); + let funcs = adapter["public_functions"].as_array().unwrap(); + assert!( + !funcs.is_empty(), + "{}/{} should have at least one public function", + lang, + adapter_name + ); + } + } + } +} diff --git a/binding-core/tests/cli_mcp_alignment.rs b/binding-core/tests/cli_mcp_alignment.rs new file mode 100644 index 00000000..34194cbe --- /dev/null +++ b/binding-core/tests/cli_mcp_alignment.rs @@ -0,0 +1,260 @@ +//! CLI-MCP alignment parity test. +//! +//! Validates that `binding-core/tests/fixtures/cli_mcp_alignment.json` +//! accounts for every CLI command (from `jacs-cli/contract/cli_commands.json`) +//! and every MCP tool (from `jacs-mcp/contract/jacs-mcp-contract.json`). +//! +//! If a CLI command or MCP tool is added without updating the alignment +//! fixture, this test fails. This ensures the CLI-MCP boundary is +//! explicitly documented and tracked. + +use serde_json::Value; +use std::collections::HashSet; + +fn load_alignment_fixture() -> Value { + let fixture_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/cli_mcp_alignment.json" + ); + let data = std::fs::read_to_string(fixture_path) + .unwrap_or_else(|e| panic!("Failed to read cli_mcp_alignment.json: {}", e)); + serde_json::from_str(&data).expect("cli_mcp_alignment.json should be valid JSON") +} + +fn load_cli_commands_fixture() -> Value { + let fixture_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../jacs-cli/contract/cli_commands.json" + ); + let data = std::fs::read_to_string(fixture_path) + .unwrap_or_else(|e| panic!("Failed to read cli_commands.json: {}", e)); + serde_json::from_str(&data).expect("cli_commands.json should be valid JSON") +} + +fn load_mcp_contract() -> Value { + let fixture_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../jacs-mcp/contract/jacs-mcp-contract.json" + ); + let data = std::fs::read_to_string(fixture_path) + .unwrap_or_else(|e| panic!("Failed to read jacs-mcp-contract.json: {}", e)); + serde_json::from_str(&data).expect("jacs-mcp-contract.json should be valid JSON") +} + +/// Every CLI command from cli_commands.json must appear in either +/// `alignments[].cli_command` or `cli_only[].cli_command` in the +/// alignment fixture. +#[test] +fn test_all_cli_commands_are_in_alignment_fixture() { + let alignment = load_alignment_fixture(); + let cli = load_cli_commands_fixture(); + + // Collect all CLI commands from the fixture + let mut cli_commands: HashSet = HashSet::new(); + for cmd in cli["commands"].as_array().expect("commands array") { + cli_commands.insert(cmd["path"].as_str().unwrap().to_string()); + } + + // Collect CLI commands referenced in alignment fixture + let mut alignment_cli: HashSet = HashSet::new(); + + // From alignments (paired commands) + for entry in alignment["alignments"] + .as_array() + .expect("alignments array") + { + alignment_cli.insert(entry["cli_command"].as_str().unwrap().to_string()); + } + + // From cli_only + for entry in alignment["cli_only"].as_array().expect("cli_only array") { + alignment_cli.insert(entry["cli_command"].as_str().unwrap().to_string()); + } + + let missing: Vec<&String> = cli_commands.difference(&alignment_cli).collect(); + let extra: Vec<&String> = alignment_cli.difference(&cli_commands).collect(); + + assert!( + missing.is_empty(), + "CLI commands in cli_commands.json but NOT in cli_mcp_alignment.json: {:?}\n\ + Add them to either 'alignments' or 'cli_only' in the alignment fixture.", + missing + ); + assert!( + extra.is_empty(), + "CLI commands in cli_mcp_alignment.json but NOT in cli_commands.json: {:?}\n\ + Remove stale entries from the alignment fixture.", + extra + ); +} + +/// Every feature-gated CLI command must appear in `cli_only_feature_gated[]`. +#[test] +fn test_all_feature_gated_cli_commands_are_in_alignment_fixture() { + let alignment = load_alignment_fixture(); + let cli = load_cli_commands_fixture(); + + let mut cli_gated: HashSet = HashSet::new(); + for cmd in cli["feature_gated_commands"] + .as_array() + .expect("feature_gated_commands array") + { + cli_gated.insert(cmd["path"].as_str().unwrap().to_string()); + } + + let mut alignment_gated: HashSet = HashSet::new(); + for entry in alignment["cli_only_feature_gated"] + .as_array() + .expect("cli_only_feature_gated array") + { + alignment_gated.insert(entry["cli_command"].as_str().unwrap().to_string()); + } + + let missing: Vec<&String> = cli_gated.difference(&alignment_gated).collect(); + let extra: Vec<&String> = alignment_gated.difference(&cli_gated).collect(); + + assert!( + missing.is_empty(), + "Feature-gated CLI commands not in alignment fixture: {:?}", + missing + ); + assert!( + extra.is_empty(), + "Stale feature-gated entries in alignment fixture: {:?}", + extra + ); +} + +/// Every MCP tool from jacs-mcp-contract.json must appear in either +/// `alignments[].mcp_tool` or `mcp_only[].mcp_tool`. +#[test] +fn test_all_mcp_tools_are_in_alignment_fixture() { + let alignment = load_alignment_fixture(); + let mcp = load_mcp_contract(); + + // Collect all MCP tools from the contract + let mut mcp_tools: HashSet = HashSet::new(); + for tool in mcp["tools"].as_array().expect("tools array") { + mcp_tools.insert(tool["name"].as_str().unwrap().to_string()); + } + + // Collect MCP tools referenced in alignment fixture + let mut alignment_mcp: HashSet = HashSet::new(); + + // From alignments (paired tools) + for entry in alignment["alignments"] + .as_array() + .expect("alignments array") + { + alignment_mcp.insert(entry["mcp_tool"].as_str().unwrap().to_string()); + } + + // From mcp_only + for entry in alignment["mcp_only"].as_array().expect("mcp_only array") { + alignment_mcp.insert(entry["mcp_tool"].as_str().unwrap().to_string()); + } + + let missing: Vec<&String> = mcp_tools.difference(&alignment_mcp).collect(); + let extra: Vec<&String> = alignment_mcp.difference(&mcp_tools).collect(); + + assert!( + missing.is_empty(), + "MCP tools in jacs-mcp-contract.json but NOT in cli_mcp_alignment.json: {:?}\n\ + Add them to either 'alignments' or 'mcp_only' in the alignment fixture.", + missing + ); + assert!( + extra.is_empty(), + "MCP tools in cli_mcp_alignment.json but NOT in jacs-mcp-contract.json: {:?}\n\ + Remove stale entries from the alignment fixture.", + extra + ); +} + +/// Summary counts in the fixture must be accurate. +#[test] +fn test_alignment_summary_counts_are_accurate() { + let alignment = load_alignment_fixture(); + let summary = &alignment["summary"]; + + let alignments_count = alignment["alignments"] + .as_array() + .expect("alignments array") + .len(); + let cli_only_count = alignment["cli_only"] + .as_array() + .expect("cli_only array") + .len(); + let cli_only_gated_count = alignment["cli_only_feature_gated"] + .as_array() + .expect("cli_only_feature_gated array") + .len(); + let mcp_only_arr = alignment["mcp_only"].as_array().expect("mcp_only array"); + let mcp_only_count = mcp_only_arr.len(); + + let mcp_only_intentional = mcp_only_arr + .iter() + .filter(|e| e["classification"].as_str() == Some("intentional")) + .count(); + let mcp_only_gap = mcp_only_arr + .iter() + .filter(|e| e["classification"].as_str() == Some("gap")) + .count(); + + assert_eq!( + summary["aligned_pairs"].as_u64().unwrap() as usize, + alignments_count, + "summary.aligned_pairs does not match alignments array length" + ); + assert_eq!( + summary["cli_only_count"].as_u64().unwrap() as usize, + cli_only_count, + "summary.cli_only_count does not match cli_only array length" + ); + assert_eq!( + summary["cli_only_feature_gated_count"].as_u64().unwrap() as usize, + cli_only_gated_count, + "summary.cli_only_feature_gated_count does not match" + ); + assert_eq!( + summary["mcp_only_count"].as_u64().unwrap() as usize, + mcp_only_count, + "summary.mcp_only_count does not match mcp_only array length" + ); + assert_eq!( + summary["mcp_only_intentional"].as_u64().unwrap() as usize, + mcp_only_intentional, + "summary.mcp_only_intentional does not match actual count" + ); + assert_eq!( + summary["mcp_only_gap"].as_u64().unwrap() as usize, + mcp_only_gap, + "summary.mcp_only_gap does not match actual count" + ); +} + +/// Every MCP-only entry must have a valid classification. +#[test] +fn test_mcp_only_entries_have_valid_classification() { + let alignment = load_alignment_fixture(); + let valid_classifications = ["intentional", "gap"]; + + for entry in alignment["mcp_only"].as_array().expect("mcp_only array") { + let tool = entry["mcp_tool"].as_str().unwrap(); + let classification = entry["classification"] + .as_str() + .unwrap_or_else(|| panic!("MCP-only tool {} missing 'classification' field", tool)); + assert!( + valid_classifications.contains(&classification), + "MCP-only tool {} has invalid classification '{}'. Must be one of: {:?}", + tool, + classification, + valid_classifications + ); + assert!( + entry["reason"].as_str().is_some() && !entry["reason"].as_str().unwrap().is_empty(), + "MCP-only tool {} missing 'reason' field", + tool + ); + } +} diff --git a/binding-core/tests/fixtures/adapter_inventory.json b/binding-core/tests/fixtures/adapter_inventory.json new file mode 100644 index 00000000..c176e0e0 --- /dev/null +++ b/binding-core/tests/fixtures/adapter_inventory.json @@ -0,0 +1,37 @@ +{ + "_description": "Framework adapter inventory. Each binding must implement the adapters listed for its language. If a new adapter is added, update this file and add a corresponding test in the affected binding.", + "_scope_note": "This fixture lists all discovered public functions, which may exceed what the PRD (Task 011) originally specified. This is intentional -- the fixture reflects the actual code, not the minimum PRD spec.", + "adapters": { + "python": { + "mcp": { + "module": "jacs.adapters.mcp", + "public_functions": ["register_jacs_tools", "register_trust_tools", "register_a2a_tools"] + }, + "langchain": { + "module": "jacs.adapters.langchain", + "public_functions": ["jacs_wrap_tool_call", "jacs_awrap_tool_call", "jacs_signing_middleware", "JacsSigningMiddleware", "signed_tool", "with_jacs_signing"] + }, + "crewai": { + "module": "jacs.adapters.crewai", + "public_functions": ["jacs_guardrail", "signed_task", "JacsSignedTool", "JacsVerifiedInput"] + }, + "fastapi": { + "module": "jacs.adapters.fastapi", + "public_functions": ["JacsMiddleware", "jacs_route"] + }, + "anthropic": { + "module": "jacs.adapters.anthropic", + "public_functions": ["signed_tool", "JacsToolHook"] + } + }, + "node": { + "mcp": { + "module": "mcp", + "public_functions": ["registerJacsTools", "getJacsMcpToolDefinitions", "handleJacsMcpToolCall", "createJACSTransportProxy", "createJACSTransportProxyAsync", "JACSTransportProxy"] + } + }, + "go": { + "_note": "Go has no framework adapters currently. MCP is consumed via the CLI binary." + } + } +} diff --git a/binding-core/tests/fixtures/cli_mcp_alignment.json b/binding-core/tests/fixtures/cli_mcp_alignment.json new file mode 100644 index 00000000..73a6fa45 --- /dev/null +++ b/binding-core/tests/fixtures/cli_mcp_alignment.json @@ -0,0 +1,365 @@ +{ + "_description": "CLI-to-MCP alignment mapping. Every CLI command and every MCP tool must appear here with a classification. If a new CLI command or MCP tool is added without updating this fixture, the alignment test fails.", + "schema_version": 1, + "alignments": [ + { + "cli_command": "agent create", + "mcp_tool": "jacs_create_agent", + "status": "aligned", + "notes": "Both create a new agent with keys" + }, + { + "cli_command": "document create", + "mcp_tool": "jacs_sign_document", + "status": "aligned", + "notes": "CLI 'create' signs a new document; MCP jacs_sign_document does the same" + }, + { + "cli_command": "document create", + "mcp_tool": "jacs_sign_state", + "status": "aligned", + "notes": "jacs_sign_state is the state-file variant of document signing" + }, + { + "cli_command": "document update", + "mcp_tool": "jacs_update_state", + "status": "aligned", + "notes": "Both create a new version of a signed document" + }, + { + "cli_command": "document verify", + "mcp_tool": "jacs_verify_document", + "status": "aligned", + "notes": "Both verify document hash, signatures, and schema" + }, + { + "cli_command": "document verify", + "mcp_tool": "jacs_verify_state", + "status": "aligned", + "notes": "jacs_verify_state is the state-file variant of document verification" + }, + { + "cli_command": "document check-agreement", + "mcp_tool": "jacs_check_agreement", + "status": "aligned", + "notes": "Both list agents that should sign a document" + }, + { + "cli_command": "document create-agreement", + "mcp_tool": "jacs_create_agreement", + "status": "aligned", + "notes": "Both create an agreement for a document" + }, + { + "cli_command": "document sign-agreement", + "mcp_tool": "jacs_sign_agreement", + "status": "aligned", + "notes": "Both sign the agreement section of a document" + }, + { + "cli_command": "key reencrypt", + "mcp_tool": "jacs_reencrypt_key", + "status": "aligned", + "notes": "Both re-encrypt the private key with a new password" + }, + { + "cli_command": "attest create", + "mcp_tool": "jacs_attest_create", + "status": "aligned", + "notes": "Both create a signed attestation" + }, + { + "cli_command": "attest verify", + "mcp_tool": "jacs_attest_verify", + "status": "aligned", + "notes": "Both verify an attestation document" + }, + { + "cli_command": "attest export-dsse", + "mcp_tool": "jacs_attest_export_dsse", + "status": "aligned", + "notes": "Both export an attestation as a DSSE envelope" + }, + { + "cli_command": "a2a assess", + "mcp_tool": "jacs_assess_a2a_agent", + "status": "aligned", + "notes": "Both assess trust level of a remote A2A Agent Card" + }, + { + "cli_command": "a2a trust", + "mcp_tool": "jacs_trust_agent", + "status": "aligned", + "notes": "Both add a remote agent to the local trust store" + }, + { + "cli_command": "verify", + "mcp_tool": "jacs_verify_document", + "status": "aligned", + "notes": "Standalone verify command (no agent required) maps to same MCP tool" + } + ], + "cli_only": [ + { + "cli_command": "config create", + "mcp_excluded_reason": "Setup command: creates jacs.config.json interactively, not applicable to MCP runtime" + }, + { + "cli_command": "config read", + "mcp_excluded_reason": "Diagnostic command: displays config to terminal, not applicable to MCP" + }, + { + "cli_command": "agent dns", + "mcp_excluded_reason": "Emits DNS TXT record commands for manual DNS provisioning; requires shell access" + }, + { + "cli_command": "agent lookup", + "mcp_excluded_reason": "DNS discovery helper; could be an MCP tool but currently CLI-only" + }, + { + "cli_command": "agent verify", + "mcp_excluded_reason": "Agent self-verification; MCP agents verify implicitly on load" + }, + { + "cli_command": "a2a discover", + "mcp_excluded_reason": "Discovers remote A2A agents via well-known URL; interactive CLI workflow" + }, + { + "cli_command": "a2a serve", + "mcp_excluded_reason": "Starts HTTP server for A2A discovery endpoints; long-running process, not an MCP tool" + }, + { + "cli_command": "a2a quickstart", + "mcp_excluded_reason": "Combined create-and-serve convenience; orchestration command not suitable for MCP" + }, + { + "cli_command": "document extract", + "mcp_excluded_reason": "Extracts embedded content from JACS documents; could be an MCP tool in the future" + }, + { + "cli_command": "task create", + "mcp_excluded_reason": "Task creation; could become MCP tool when task workflow matures" + }, + { + "cli_command": "task update", + "mcp_excluded_reason": "Task update; could become MCP tool when task workflow matures" + }, + { + "cli_command": "quickstart", + "mcp_excluded_reason": "Setup command: creates/loads persistent agent; not applicable to MCP runtime" + }, + { + "cli_command": "init", + "mcp_excluded_reason": "Setup command: initializes config and agent; not applicable to MCP runtime" + }, + { + "cli_command": "mcp", + "mcp_excluded_reason": "Meta command: starts the MCP server itself; cannot be an MCP tool" + }, + { + "cli_command": "version", + "mcp_excluded_reason": "Meta command: prints version info; not applicable to MCP" + }, + { + "cli_command": "convert", + "mcp_excluded_reason": "Format conversion (JSON/YAML/HTML); could be an MCP tool in the future" + } + ], + "cli_only_feature_gated": [ + { + "cli_command": "keychain set", + "feature": "keychain", + "mcp_excluded_reason": "OS keychain integration; requires local system access, not applicable to MCP" + }, + { + "cli_command": "keychain get", + "feature": "keychain", + "mcp_excluded_reason": "OS keychain integration; requires local system access, not applicable to MCP" + }, + { + "cli_command": "keychain delete", + "feature": "keychain", + "mcp_excluded_reason": "OS keychain integration; requires local system access, not applicable to MCP" + }, + { + "cli_command": "keychain status", + "feature": "keychain", + "mcp_excluded_reason": "OS keychain integration; requires local system access, not applicable to MCP" + } + ], + "mcp_only": [ + { + "mcp_tool": "jacs_adopt_state", + "category": "state_management", + "classification": "intentional", + "reason": "MCP-centric: adopts external files as signed state within MCP workflows" + }, + { + "mcp_tool": "jacs_list_state", + "category": "state_management", + "classification": "intentional", + "reason": "MCP-centric: lists signed state documents managed by the MCP server" + }, + { + "mcp_tool": "jacs_load_state", + "category": "state_management", + "classification": "intentional", + "reason": "MCP-centric: loads a previously signed state document in the MCP server" + }, + { + "mcp_tool": "jacs_memory_save", + "category": "memory", + "classification": "intentional", + "reason": "AI agent memory system; designed for MCP AI agent workflows, not human CLI use" + }, + { + "mcp_tool": "jacs_memory_recall", + "category": "memory", + "classification": "intentional", + "reason": "AI agent memory system; designed for MCP AI agent workflows, not human CLI use" + }, + { + "mcp_tool": "jacs_memory_list", + "category": "memory", + "classification": "intentional", + "reason": "AI agent memory system; designed for MCP AI agent workflows, not human CLI use" + }, + { + "mcp_tool": "jacs_memory_forget", + "category": "memory", + "classification": "intentional", + "reason": "AI agent memory system; designed for MCP AI agent workflows, not human CLI use" + }, + { + "mcp_tool": "jacs_memory_update", + "category": "memory", + "classification": "intentional", + "reason": "AI agent memory system; designed for MCP AI agent workflows, not human CLI use" + }, + { + "mcp_tool": "jacs_message_send", + "category": "messaging", + "classification": "intentional", + "reason": "Agent-to-agent messaging; designed for MCP multi-agent workflows" + }, + { + "mcp_tool": "jacs_message_receive", + "category": "messaging", + "classification": "intentional", + "reason": "Agent-to-agent messaging; designed for MCP multi-agent workflows" + }, + { + "mcp_tool": "jacs_message_agree", + "category": "messaging", + "classification": "intentional", + "reason": "Agent-to-agent messaging; designed for MCP multi-agent workflows" + }, + { + "mcp_tool": "jacs_message_update", + "category": "messaging", + "classification": "intentional", + "reason": "Agent-to-agent messaging; designed for MCP multi-agent workflows" + }, + { + "mcp_tool": "jacs_untrust_agent", + "category": "trust_store", + "classification": "gap", + "reason": "Trust store management should be available from CLI; recommend adding 'jacs trust remove' command" + }, + { + "mcp_tool": "jacs_list_trusted_agents", + "category": "trust_store", + "classification": "gap", + "reason": "Trust store management should be available from CLI; recommend adding 'jacs trust list' command" + }, + { + "mcp_tool": "jacs_is_trusted", + "category": "trust_store", + "classification": "gap", + "reason": "Trust store management should be available from CLI; recommend adding 'jacs trust check' command" + }, + { + "mcp_tool": "jacs_get_trusted_agent", + "category": "trust_store", + "classification": "gap", + "reason": "Trust store management should be available from CLI; recommend adding 'jacs trust get' command" + }, + { + "mcp_tool": "jacs_audit", + "category": "audit", + "classification": "gap", + "reason": "Security audit should be available from CLI; recommend adding 'jacs audit run' command" + }, + { + "mcp_tool": "jacs_audit_log", + "category": "audit", + "classification": "gap", + "reason": "Audit trail logging should be available from CLI; recommend adding 'jacs audit log' command" + }, + { + "mcp_tool": "jacs_audit_query", + "category": "audit", + "classification": "gap", + "reason": "Audit trail query should be available from CLI; recommend adding 'jacs audit query' command" + }, + { + "mcp_tool": "jacs_audit_export", + "category": "audit", + "classification": "gap", + "reason": "Audit trail export should be available from CLI; recommend adding 'jacs audit export' command" + }, + { + "mcp_tool": "jacs_search", + "category": "search", + "classification": "gap", + "reason": "Search across signed documents should be available from CLI; recommend adding 'jacs search' command" + }, + { + "mcp_tool": "jacs_attest_lift", + "category": "attestation", + "classification": "gap", + "reason": "Lifting signed documents to attestations could be useful from CLI; recommend adding 'jacs attest lift' command" + }, + { + "mcp_tool": "jacs_export_agent", + "category": "agent_export", + "classification": "intentional", + "reason": "MCP-centric: exports full agent JSON for MCP inter-agent sharing" + }, + { + "mcp_tool": "jacs_export_agent_card", + "category": "agent_export", + "classification": "intentional", + "reason": "MCP-centric: exports A2A Agent Card; CLI has 'a2a serve' for discovery instead" + }, + { + "mcp_tool": "jacs_generate_well_known", + "category": "agent_export", + "classification": "intentional", + "reason": "MCP-centric: generates well-known documents; CLI has 'a2a serve' for this" + }, + { + "mcp_tool": "jacs_verify_a2a_artifact", + "category": "a2a_artifacts", + "classification": "intentional", + "reason": "A2A artifact verification is part of MCP multi-agent workflows" + }, + { + "mcp_tool": "jacs_wrap_a2a_artifact", + "category": "a2a_artifacts", + "classification": "intentional", + "reason": "A2A artifact wrapping is part of MCP multi-agent workflows" + } + ], + "summary": { + "total_cli_commands": 30, + "total_cli_feature_gated": 4, + "total_mcp_tools": 42, + "aligned_pairs": 16, + "cli_only_count": 16, + "cli_only_feature_gated_count": 4, + "mcp_only_count": 27, + "mcp_only_intentional": 17, + "mcp_only_gap": 10 + } +} diff --git a/binding-core/tests/fixtures/method_parity.json b/binding-core/tests/fixtures/method_parity.json new file mode 100644 index 00000000..556d92ab --- /dev/null +++ b/binding-core/tests/fixtures/method_parity.json @@ -0,0 +1,18 @@ +{ + "_description": "Canonical list of SimpleAgentWrapper public methods. All language bindings must expose these methods (with documented exclusions). Update this file when adding/removing methods.", + "simple_agent_wrapper_methods": { + "constructors": ["create", "load", "load_with_info", "ephemeral", "create_with_params", "from_agent"], + "identity": ["get_agent_id", "key_id", "is_strict", "config_path", "export_agent", "get_public_key_pem", "get_public_key_base64", "diagnostics", "inner_ref"], + "verification": ["verify_self", "verify_json", "verify_with_key_json", "verify_by_id_json"], + "signing": ["sign_message_json", "sign_raw_bytes_base64", "sign_file_json"], + "conversion": ["to_yaml", "from_yaml", "to_html", "from_html"] + }, + "all_methods_flat": [ + "config_path", "create", "create_with_params", "diagnostics", "ephemeral", + "export_agent", "from_agent", "from_html", "from_yaml", "get_agent_id", + "get_public_key_base64", "get_public_key_pem", "inner_ref", "is_strict", + "key_id", "load", "load_with_info", "sign_file_json", "sign_message_json", + "sign_raw_bytes_base64", "to_html", "to_yaml", "verify_by_id_json", + "verify_json", "verify_self", "verify_with_key_json" + ] +} diff --git a/binding-core/tests/fixtures/parity_inputs.json b/binding-core/tests/fixtures/parity_inputs.json index 1759404a..dee2abf1 100644 --- a/binding-core/tests/fixtures/parity_inputs.json +++ b/binding-core/tests/fixtures/parity_inputs.json @@ -54,5 +54,28 @@ "required": ["valid"], "optional": ["signer_id", "timestamp", "errors", "data", "attachments"] }, - "algorithms": ["ed25519", "pq2025"] + "sign_message_invalid_json_behavior": { + "_description": "Documents a known cross-language behavioral difference for sign_message with invalid JSON input. Python's sign_message takes any Python object and serializes it to JSON first (so a raw string becomes a valid JSON string value). Node's signMessage takes a JSON string directly and rejects invalid JSON. Both behaviors are correct for their API contracts.", + "input": "{{{bad json", + "rust_binding_core": "rejects (InvalidArgument: serde_json::from_str fails)", + "python": "succeeds (PyObject -> serde_json::Value serialization wraps string as JSON string value)", + "node": "rejects (passes raw string to binding-core sign_message_json which expects valid JSON)", + "go": "rejects (passes raw string to C FFI which calls sign_message_json)" + }, + "algorithms": ["ed25519", "pq2025"], + "error_kinds": [ + "AgreementFailed", + "AgentLoad", + "DocumentFailed", + "Generic", + "InvalidArgument", + "KeyNotFound", + "LockFailed", + "NetworkFailed", + "SerializationFailed", + "SigningFailed", + "TrustFailed", + "Validation", + "VerificationFailed" + ] } diff --git a/binding-core/tests/method_parity.rs b/binding-core/tests/method_parity.rs new file mode 100644 index 00000000..d867fe69 --- /dev/null +++ b/binding-core/tests/method_parity.rs @@ -0,0 +1,168 @@ +//! Method enumeration parity test for `SimpleAgentWrapper`. +//! +//! Validates that `binding-core/tests/fixtures/method_parity.json` accurately +//! lists all public methods on `SimpleAgentWrapper`. If a method is added or +//! removed from the wrapper without updating the fixture, this test fails. +//! +//! This is a *structural* test (method names), not a *behavioral* test +//! (sign/verify roundtrips). It complements, not duplicates, `parity.rs`. + +use serde_json::Value; + +/// Hardcoded, sorted list of all public methods on `SimpleAgentWrapper`. +/// +/// This list MUST be updated whenever a public method is added to or removed +/// from `binding-core/src/simple_wrapper.rs`. It serves as a compile-time +/// anchor so that the fixture and the implementation stay in sync. +fn known_methods() -> Vec<&'static str> { + let mut methods = vec![ + // Constructors + "create", + "load", + "load_with_info", + "ephemeral", + "create_with_params", + "from_agent", + // Identity / Introspection + "get_agent_id", + "key_id", + "is_strict", + "config_path", + "export_agent", + "get_public_key_pem", + "get_public_key_base64", + "diagnostics", + "inner_ref", + // Verification + "verify_self", + "verify_json", + "verify_with_key_json", + "verify_by_id_json", + // Signing + "sign_message_json", + "sign_raw_bytes_base64", + "sign_file_json", + // Conversion + "to_yaml", + "from_yaml", + "to_html", + "from_html", + ]; + methods.sort(); + methods +} + +fn load_method_parity_fixture() -> Value { + let fixture_bytes = include_bytes!("fixtures/method_parity.json"); + serde_json::from_slice(fixture_bytes).expect("method_parity.json should be valid JSON") +} + +#[test] +fn test_method_parity_fixture_matches_impl() { + let fixture = load_method_parity_fixture(); + + // Extract the flat sorted list from the fixture + let fixture_methods: Vec = fixture["all_methods_flat"] + .as_array() + .expect("all_methods_flat should be an array") + .iter() + .map(|v| { + v.as_str() + .expect("each method should be a string") + .to_string() + }) + .collect(); + + let known = known_methods(); + let known_strings: Vec = known.iter().map(|s| s.to_string()).collect(); + + // Both lists should already be sorted + assert_eq!( + fixture_methods, + known_strings, + "\nFixture method list does not match known SimpleAgentWrapper methods.\n\ + \nFixture has {} methods, known list has {} methods.\n\ + \nIn fixture but not in known list: {:?}\n\ + In known list but not in fixture: {:?}\n\ + \nIf you added a method to SimpleAgentWrapper, update BOTH:\n\ + 1. binding-core/tests/fixtures/method_parity.json\n\ + 2. The known_methods() list in binding-core/tests/method_parity.rs", + fixture_methods.len(), + known_strings.len(), + fixture_methods + .iter() + .filter(|m| !known_strings.contains(m)) + .collect::>(), + known_strings + .iter() + .filter(|m| !fixture_methods.contains(m)) + .collect::>(), + ); +} + +#[test] +fn test_method_parity_fixture_categories_cover_all() { + let fixture = load_method_parity_fixture(); + let categories = fixture["simple_agent_wrapper_methods"] + .as_object() + .expect("simple_agent_wrapper_methods should be an object"); + + // Collect all methods from categories + let mut category_methods: Vec = Vec::new(); + for (_category_name, methods) in categories { + for method in methods.as_array().expect("category should be an array") { + category_methods.push( + method + .as_str() + .expect("method should be a string") + .to_string(), + ); + } + } + category_methods.sort(); + + // Compare against flat list + let flat_methods: Vec = fixture["all_methods_flat"] + .as_array() + .expect("all_methods_flat should be an array") + .iter() + .map(|v| { + v.as_str() + .expect("each method should be a string") + .to_string() + }) + .collect(); + + assert_eq!( + category_methods, + flat_methods, + "\nCategorized methods do not match all_methods_flat.\n\ + This means the fixture is internally inconsistent.\n\ + \nIn categories but not in flat: {:?}\n\ + In flat but not in categories: {:?}", + category_methods + .iter() + .filter(|m| !flat_methods.contains(m)) + .collect::>(), + flat_methods + .iter() + .filter(|m| !category_methods.contains(m)) + .collect::>(), + ); +} + +#[test] +fn test_method_parity_fixture_count() { + let fixture = load_method_parity_fixture(); + let flat_methods = fixture["all_methods_flat"] + .as_array() + .expect("all_methods_flat should be an array"); + + assert_eq!( + flat_methods.len(), + 26, + "SimpleAgentWrapper should have exactly 26 public methods. \ + Found {}. If you added or removed a method, update the fixture.", + flat_methods.len() + ); +} diff --git a/binding-core/tests/parity.rs b/binding-core/tests/parity.rs index 7c166ade..f4bd684b 100644 --- a/binding-core/tests/parity.rs +++ b/binding-core/tests/parity.rs @@ -611,3 +611,80 @@ fn test_parity_create_with_params() { std::env::remove_var("JACS_PRIVATE_KEY_PASSWORD"); } } + +// ============================================================================= +// 11. Error kind parity: fixture must list all ErrorKind variants +// ============================================================================= + +#[test] +fn test_error_kinds_fixture_matches_enum() { + let fixtures = load_parity_inputs(); + let fixture_kinds: Vec = fixtures["error_kinds"] + .as_array() + .expect("error_kinds should be an array in parity_inputs.json") + .iter() + .map(|v| { + v.as_str() + .expect("each error kind should be a string") + .to_string() + }) + .collect(); + + // Hardcoded sorted list of all ErrorKind variants from binding-core/src/lib.rs. + // This MUST be updated when a variant is added or removed. + let known_kinds: Vec<&str> = vec![ + "AgreementFailed", + "AgentLoad", + "DocumentFailed", + "Generic", + "InvalidArgument", + "KeyNotFound", + "LockFailed", + "NetworkFailed", + "SerializationFailed", + "SigningFailed", + "TrustFailed", + "Validation", + "VerificationFailed", + ]; + + let known_strings: Vec = known_kinds.iter().map(|s| s.to_string()).collect(); + + assert_eq!( + fixture_kinds, + known_strings, + "\nerror_kinds in parity_inputs.json does not match known ErrorKind variants.\n\ + \nFixture has {} kinds, known list has {} kinds.\n\ + \nIn fixture but not in known: {:?}\n\ + In known but not in fixture: {:?}\n\ + \nIf you added an ErrorKind variant, update BOTH:\n\ + 1. binding-core/tests/fixtures/parity_inputs.json (error_kinds array)\n\ + 2. The known_kinds list in this test", + fixture_kinds.len(), + known_strings.len(), + fixture_kinds + .iter() + .filter(|k| !known_strings.contains(k)) + .collect::>(), + known_strings + .iter() + .filter(|k| !fixture_kinds.contains(k)) + .collect::>(), + ); +} + +#[test] +fn test_error_kinds_fixture_count() { + let fixtures = load_parity_inputs(); + let error_kinds = fixtures["error_kinds"] + .as_array() + .expect("error_kinds should be an array"); + + assert_eq!( + error_kinds.len(), + 13, + "ErrorKind should have exactly 13 variants. Found {}. \ + If you added or removed a variant, update parity_inputs.json.", + error_kinds.len() + ); +} diff --git a/jacs-cli/Cargo.toml b/jacs-cli/Cargo.toml index c4acf0d0..40c6f68e 100644 --- a/jacs-cli/Cargo.toml +++ b/jacs-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-cli" -version = "0.9.12" +version = "0.9.13" edition = "2024" rust-version = "1.93" description = "JACS CLI: command-line interface for JSON AI Communication Standard" @@ -12,6 +12,10 @@ repository = "https://github.com/HumanAssisted/JACS" keywords = ["cryptography", "json", "ai", "data", "ml-ops"] categories = ["cryptography", "text-processing", "data-structures"] +[lib] +name = "jacs_cli" +path = "src/main.rs" + [[bin]] name = "jacs" path = "src/main.rs" @@ -23,8 +27,8 @@ attestation = ["jacs/attestation"] keychain = ["jacs/keychain"] [dependencies] -jacs = { version = "0.9.12", path = "../jacs" } -jacs-mcp = { version = "0.9.12", path = "../jacs-mcp", features = ["mcp", "full-tools"], optional = true } +jacs = { version = "0.9.13", path = "../jacs" } +jacs-mcp = { version = "0.9.13", path = "../jacs-mcp", features = ["mcp", "full-tools"], optional = true } clap = { version = "4.5.4", features = ["derive", "cargo"] } rpassword = "7.3.1" reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "json", "rustls"] } @@ -35,8 +39,10 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [dev-dependencies] assert_cmd = "2" +clap = { version = "4.5.4", features = ["derive", "cargo"] } predicates = "3.1" serial_test = "3.2.0" +serde_json = "1.0" tempfile = "3" [package.metadata.cargo-install] diff --git a/jacs-cli/README.md b/jacs-cli/README.md index 0b23e95d..2c7a2232 100644 --- a/jacs-cli/README.md +++ b/jacs-cli/README.md @@ -1,89 +1,66 @@ # jacs-cli -Single binary for the JACS command-line interface and MCP server. +CLI and MCP server for JACS — cryptographic identity, signing, and verification for AI agents. ```bash cargo install jacs-cli ``` -This installs the `jacs` binary with CLI and MCP server built in. - -## Quick Start - -```bash -# Developer / desktop workflow -export JACS_PRIVATE_KEY_PASSWORD='use-a-strong-password' - -# Create an agent and start signing -jacs quickstart --name my-agent --domain my-agent.example.com -jacs document create -f mydata.json - -# Start the MCP server (stdio transport) -jacs mcp -``` - -For Linux or other headless service environments, prefer a secret-mounted -password file: - -```bash -export JACS_CONFIG=/srv/my-project/jacs.config.json -export JACS_PASSWORD_FILE=/run/secrets/jacs-password -export JACS_KEYCHAIN_BACKEND=disabled -jacs mcp -``` - -## Homebrew (macOS) +Or via Homebrew: ```bash brew tap HumanAssisted/homebrew-jacs brew install jacs ``` -## From Source +This installs the `jacs` binary with CLI and MCP server built in. + +## Quick start ```bash -git clone https://github.com/HumanAssisted/JACS -cd JACS -cargo install --path jacs-cli -``` +export JACS_PRIVATE_KEY_PASSWORD='your-password' -## MCP Server +jacs quickstart --name my-agent --domain example.com +jacs document create -f mydata.json +jacs verify signed-document.json +``` -The MCP server is built into the binary. No separate install step needed. +## MCP server ```bash jacs mcp ``` -Configure in `.mcp.json` for Claude Code or similar clients: +The MCP server uses **stdio transport only** — no HTTP endpoints. This is deliberate: the server holds the agent's private key, so it runs as a subprocess of your MCP client. No ports are opened. + +Configure in your MCP client (Claude Desktop, Cursor, Claude Code, etc.): ```json { "mcpServers": { "jacs": { "command": "jacs", - "args": ["mcp"], - "env": { - "JACS_CONFIG": "/srv/my-project/jacs.config.json", - "JACS_PASSWORD_FILE": "/run/secrets/jacs-password", - "JACS_KEYCHAIN_BACKEND": "disabled" - } + "args": ["mcp"] } } } ``` -The MCP server uses stdio transport only (no HTTP) for security. +For headless/server environments: -`JACS_PRIVATE_KEY_PASSWORD` is still supported, but for Linux/headless services -`JACS_PASSWORD_FILE` is the preferred deployment path. +```bash +export JACS_CONFIG=/srv/my-project/jacs.config.json +export JACS_PASSWORD_FILE=/run/secrets/jacs-password +export JACS_KEYCHAIN_BACKEND=disabled +jacs mcp +``` -## Documentation +## Links - [Full Documentation](https://humanassisted.github.io/JACS/) - [Quick Start Guide](https://humanassisted.github.io/JACS/getting-started/quick-start.html) - [CLI Command Reference](https://humanassisted.github.io/JACS/rust/cli.html) - [MCP Integration](https://humanassisted.github.io/JACS/integrations/mcp.html) -- [JACS core library on crates.io](https://crates.io/crates/jacs) +- [JACS on crates.io](https://crates.io/crates/jacs) v0.9.7 | [Apache 2.0 with Common Clause](../LICENSE) diff --git a/jacs-cli/contract/cli_commands.json b/jacs-cli/contract/cli_commands.json new file mode 100644 index 00000000..07e9d503 --- /dev/null +++ b/jacs-cli/contract/cli_commands.json @@ -0,0 +1,43 @@ +{ + "_description": "Canonical list of JACS CLI commands. Update this file when adding/removing CLI commands. A snapshot test validates this fixture against the actual Clap command tree. The mcp_tool and mcp_excluded_reason fields document CLI-MCP alignment (see also binding-core/tests/fixtures/cli_mcp_alignment.json).", + "schema_version": 1, + "cli_name": "jacs", + "commands": [ + {"path": "version", "about": "Prints version and build information", "mcp_tool": null, "mcp_excluded_reason": "Meta command: prints version info, not applicable to MCP"}, + {"path": "config create", "about": "create a config file", "mcp_tool": null, "mcp_excluded_reason": "Setup command: creates jacs.config.json interactively, not applicable to MCP runtime"}, + {"path": "config read", "about": "read configuration and display to screen", "mcp_tool": null, "mcp_excluded_reason": "Diagnostic command: displays config to terminal, not applicable to MCP"}, + {"path": "agent dns", "about": "emit DNS TXT commands for publishing agent fingerprint", "mcp_tool": null, "mcp_excluded_reason": "Emits DNS TXT record commands for manual DNS provisioning; requires shell access"}, + {"path": "agent create", "about": "create an agent", "mcp_tool": "jacs_create_agent", "mcp_excluded_reason": null}, + {"path": "agent verify", "about": "verify an agent", "mcp_tool": null, "mcp_excluded_reason": "Agent self-verification; MCP agents verify implicitly on load"}, + {"path": "agent lookup", "about": "Look up another agent's public key and DNS info from their domain", "mcp_tool": null, "mcp_excluded_reason": "DNS discovery helper; could be an MCP tool but currently CLI-only"}, + {"path": "task create", "about": "create a new JACS Task file", "mcp_tool": null, "mcp_excluded_reason": "Task creation; could become MCP tool when task workflow matures"}, + {"path": "task update", "about": "update an existing task document", "mcp_tool": null, "mcp_excluded_reason": "Task update; could become MCP tool when task workflow matures"}, + {"path": "document create", "about": "create a new JACS file", "mcp_tool": "jacs_sign_document", "mcp_excluded_reason": null}, + {"path": "document update", "about": "create a new version of document", "mcp_tool": "jacs_update_state", "mcp_excluded_reason": null}, + {"path": "document check-agreement", "about": "list agents that should sign document", "mcp_tool": "jacs_check_agreement", "mcp_excluded_reason": null}, + {"path": "document create-agreement", "about": "create agreement for document", "mcp_tool": "jacs_create_agreement", "mcp_excluded_reason": null}, + {"path": "document sign-agreement", "about": "sign the agreement section of a document", "mcp_tool": "jacs_sign_agreement", "mcp_excluded_reason": null}, + {"path": "document verify", "about": "verify document hash, signatures, and schema", "mcp_tool": "jacs_verify_document", "mcp_excluded_reason": null}, + {"path": "document extract", "about": "extract embedded contents from JACS document", "mcp_tool": null, "mcp_excluded_reason": "Could be an MCP tool in the future"}, + {"path": "key reencrypt", "about": "Re-encrypt the private key with a new password", "mcp_tool": "jacs_reencrypt_key", "mcp_excluded_reason": null}, + {"path": "mcp", "about": "Start the built-in JACS MCP server", "mcp_tool": null, "mcp_excluded_reason": "Meta command: starts the MCP server itself, cannot be an MCP tool"}, + {"path": "a2a assess", "about": "Assess trust level of a remote A2A Agent Card", "mcp_tool": "jacs_assess_a2a_agent", "mcp_excluded_reason": null}, + {"path": "a2a trust", "about": "Add a remote A2A agent to the local trust store", "mcp_tool": "jacs_trust_agent", "mcp_excluded_reason": null}, + {"path": "a2a discover", "about": "Discover a remote A2A agent via well-known Agent Card", "mcp_tool": null, "mcp_excluded_reason": "Interactive CLI workflow for discovering remote A2A agents"}, + {"path": "a2a serve", "about": "Serve well-known endpoints for A2A discovery", "mcp_tool": null, "mcp_excluded_reason": "Long-running HTTP server process, not suitable as MCP tool"}, + {"path": "a2a quickstart", "about": "Create/load agent and start serving A2A endpoints", "mcp_tool": null, "mcp_excluded_reason": "Combined create-and-serve convenience; orchestration command not suitable for MCP"}, + {"path": "quickstart", "about": "Create or load a persistent agent for instant sign/verify", "mcp_tool": null, "mcp_excluded_reason": "Setup command: creates/loads persistent agent, not applicable to MCP runtime"}, + {"path": "init", "about": "Initialize JACS by creating both config and agent", "mcp_tool": null, "mcp_excluded_reason": "Setup command: initializes config and agent, not applicable to MCP runtime"}, + {"path": "attest create", "about": "Create a signed attestation", "mcp_tool": "jacs_attest_create", "mcp_excluded_reason": null}, + {"path": "attest verify", "about": "Verify an attestation document", "mcp_tool": "jacs_attest_verify", "mcp_excluded_reason": null}, + {"path": "attest export-dsse", "about": "Export an attestation as a DSSE envelope", "mcp_tool": "jacs_attest_export_dsse", "mcp_excluded_reason": null}, + {"path": "verify", "about": "Verify a signed JACS document (no agent required)", "mcp_tool": "jacs_verify_document", "mcp_excluded_reason": null}, + {"path": "convert", "about": "Convert JACS documents between JSON, YAML, and HTML formats", "mcp_tool": null, "mcp_excluded_reason": "Format conversion could be an MCP tool in the future"} + ], + "feature_gated_commands": [ + {"path": "keychain set", "feature": "keychain", "about": "Store a password in the OS keychain for an agent", "mcp_tool": null, "mcp_excluded_reason": "OS keychain integration; requires local system access, not applicable to MCP"}, + {"path": "keychain get", "feature": "keychain", "about": "Retrieve the stored password for an agent", "mcp_tool": null, "mcp_excluded_reason": "OS keychain integration; requires local system access, not applicable to MCP"}, + {"path": "keychain delete", "feature": "keychain", "about": "Remove the stored password for an agent from the OS keychain", "mcp_tool": null, "mcp_excluded_reason": "OS keychain integration; requires local system access, not applicable to MCP"}, + {"path": "keychain status", "feature": "keychain", "about": "Check if a password is stored for an agent in the OS keychain", "mcp_tool": null, "mcp_excluded_reason": "OS keychain integration; requires local system access, not applicable to MCP"} + ] +} diff --git a/jacs-cli/src/main.rs b/jacs-cli/src/main.rs index 7a24adf5..66de020b 100644 --- a/jacs-cli/src/main.rs +++ b/jacs-cli/src/main.rs @@ -11,7 +11,7 @@ use jacs::cli_utils::document::{ check_agreement, create_agreement, create_documents, extract_documents, sign_documents, update_documents, verify_documents, }; -// use jacs::create_task; // unused +use jacs::create_task; // re-enabled: may be used by a2a later use jacs::dns::bootstrap as dns_bootstrap; use jacs::shutdown::{ShutdownGuard, install_signal_handler}; @@ -245,13 +245,12 @@ fn wrap_quickstart_error_with_password_help( // install/download functions removed — MCP is now built into the CLI -pub fn main() -> Result<(), Box> { - // Install signal handler for graceful shutdown (Ctrl+C, SIGTERM) - install_signal_handler(); - - // Create shutdown guard to ensure cleanup on exit (including early returns) - let _shutdown_guard = ShutdownGuard::new(); - let matches = Command::new(crate_name!()) +/// Build the Clap `Command` tree for the JACS CLI. +/// +/// Exposed as a public function so that snapshot tests can walk +/// the command tree programmatically without hardcoded lists. +pub fn build_cli() -> Command { + let cmd = Command::new(crate_name!()) .version(env!("CARGO_PKG_VERSION")) .about(env!("CARGO_PKG_DESCRIPTION")) .subcommand( @@ -420,6 +419,24 @@ pub fn main() -> Result<(), Box> { .value_parser(value_parser!(String)), ) ) + .subcommand( + Command::new("update") + .about("update an existing task document") + .arg( + Arg::new("filename") + .short('f') + .required(true) + .help("Path to the updated task JSON file") + .value_parser(value_parser!(String)), + ) + .arg( + Arg::new("task-key") + .short('k') + .required(true) + .help("Task document key (id:version)") + .value_parser(value_parser!(String)), + ) + ) ) .subcommand( @@ -1103,7 +1120,7 @@ pub fn main() -> Result<(), Box> { // OS keychain subcommand (only when keychain feature is enabled) #[cfg(feature = "keychain")] - let matches = matches.subcommand( + let cmd = cmd.subcommand( Command::new("keychain") .about("Manage private key passwords in the OS keychain (per-agent)") .subcommand( @@ -1159,7 +1176,7 @@ pub fn main() -> Result<(), Box> { .arg_required_else_help(true), ); - let matches = matches.subcommand( + let cmd = cmd.subcommand( Command::new("convert") .about( "Convert JACS documents between JSON, YAML, and HTML formats (no agent required)", @@ -1194,7 +1211,16 @@ pub fn main() -> Result<(), Box> { ), ); - let matches = matches.arg_required_else_help(true).get_matches(); + cmd +} + +pub fn main() -> Result<(), Box> { + // Install signal handler for graceful shutdown (Ctrl+C, SIGTERM) + install_signal_handler(); + + // Create shutdown guard to ensure cleanup on exit (including early returns) + let _shutdown_guard = ShutdownGuard::new(); + let matches = build_cli().arg_required_else_help(true).get_matches(); match matches.subcommand() { Some(("version", _sub_matches)) => { @@ -1441,21 +1467,39 @@ pub fn main() -> Result<(), Box> { _ => println!("please enter subcommand see jacs agent --help"), }, - // Some(("task", task_matches)) => match task_matches.subcommand() { - // Some(("create", create_matches)) => { - // let _agentfile = create_matches.get_one::("agent-file"); - // let mut agent: Agent = load_agent().expect("REASON"); - // let name = create_matches.get_one::("name").expect("REASON"); - // let description = create_matches - // .get_one::("description") - // .expect("REASON"); - // println!( - // "{}", - // create_task(&mut agent, name.to_string(), description.to_string()).unwrap() - // ); - // } - // _ => println!("please enter subcommand see jacs task --help"), - // }, + Some(("task", task_matches)) => match task_matches.subcommand() { + Some(("create", create_matches)) => { + let _agentfile = create_matches.get_one::("agent-file"); + let mut agent: Agent = load_agent().expect("failed to load agent for task create"); + let name = create_matches + .get_one::("name") + .expect("task name is required"); + let description = create_matches + .get_one::("description") + .expect("task description is required"); + println!( + "{}", + create_task(&mut agent, name.to_string(), description.to_string()).unwrap() + ); + } + Some(("update", update_matches)) => { + let mut agent: Agent = + load_agent().expect("failed to load agent for task update"); + let task_key = update_matches + .get_one::("task-key") + .expect("task key is required"); + let filename = update_matches + .get_one::("filename") + .expect("filename is required"); + let updated_json = std::fs::read_to_string(filename) + .unwrap_or_else(|e| panic!("Failed to read '{}': {}", filename, e)); + println!( + "{}", + jacs::update_task(&mut agent, task_key, &updated_json).unwrap() + ); + } + _ => println!("please enter subcommand see jacs task --help"), + }, Some(("document", document_matches)) => match document_matches.subcommand() { Some(("create", create_matches)) => { let filename = create_matches.get_one::("filename"); diff --git a/jacs-cli/tests/cli_command_snapshot.rs b/jacs-cli/tests/cli_command_snapshot.rs new file mode 100644 index 00000000..b9a8f99e --- /dev/null +++ b/jacs-cli/tests/cli_command_snapshot.rs @@ -0,0 +1,238 @@ +//! CLI command parity snapshot test. +//! +//! Validates that `jacs-cli/contract/cli_commands.json` accurately lists all +//! CLI commands. If a command is added to or removed from the CLI without +//! updating the fixture, this test fails. +//! +//! This test extracts commands programmatically from the Clap `Command` tree +//! via `build_cli()`, so adding a new subcommand in `main.rs` without +//! updating the fixture is caught automatically -- no hardcoded list to +//! maintain. +//! +//! This is the CLI equivalent of the MCP contract snapshot test. + +use clap::Command; +use jacs_cli::build_cli; +use serde_json::Value; + +fn load_cli_commands_fixture() -> Value { + let fixture_path = concat!(env!("CARGO_MANIFEST_DIR"), "/contract/cli_commands.json"); + let data = std::fs::read_to_string(fixture_path).unwrap_or_else(|e| { + panic!( + "Failed to read cli_commands.json at {}: {}", + fixture_path, e + ) + }); + serde_json::from_str(&data).expect("cli_commands.json should be valid JSON") +} + +/// Extract all command paths from the Clap tree built by `build_cli()`. +/// +/// Skips hidden (deprecated) subcommands. Feature-gated commands (like +/// keychain) are separated into a second return value so that the fixture's +/// `commands` vs `feature_gated_commands` can be validated independently. +fn extract_clap_command_paths() -> Vec { + let cli = build_cli(); + let mut paths = Vec::new(); + + // Commands that belong in the feature_gated_commands section of the fixture. + // When compiled with the feature, they appear in the Clap tree, but the + // fixture tracks them separately. + let feature_gated_parents: std::collections::HashSet<&str> = + ["keychain"].iter().copied().collect(); + + for sub in cli.get_subcommands() { + let name = sub.get_name(); + + // Skip feature-gated parent commands (tracked separately in fixture) + if feature_gated_parents.contains(name) { + continue; + } + + // Collect visible (non-hidden) children + let visible_children: Vec<&Command> = + sub.get_subcommands().filter(|c| !c.is_hide_set()).collect(); + + if visible_children.is_empty() { + // Leaf command or command with only hidden subcommands + // (e.g., "mcp" has hidden deprecated install/run) + paths.push(name.to_string()); + } else { + // Has visible subcommands (e.g., "config create", "config read") + for child in visible_children { + paths.push(format!("{} {}", name, child.get_name())); + } + } + } + + paths.sort(); + paths +} + +#[test] +fn test_cli_commands_fixture_matches_clap_tree() { + let fixture = load_cli_commands_fixture(); + + // Extract command paths from fixture + let mut fixture_paths: Vec = fixture["commands"] + .as_array() + .expect("commands should be an array") + .iter() + .map(|cmd| { + cmd["path"] + .as_str() + .expect("path should be a string") + .to_string() + }) + .collect(); + fixture_paths.sort(); + + let clap_paths = extract_clap_command_paths(); + + assert_eq!( + fixture_paths, + clap_paths, + "\nCLI commands fixture does not match the actual Clap command tree.\n\ + \nFixture has {} commands, Clap tree has {} commands.\n\ + \nIn fixture but not in Clap tree: {:?}\n\ + In Clap tree but not in fixture: {:?}\n\ + \nIf you added a CLI command, update jacs-cli/contract/cli_commands.json", + fixture_paths.len(), + clap_paths.len(), + fixture_paths + .iter() + .filter(|p| !clap_paths.contains(p)) + .collect::>(), + clap_paths + .iter() + .filter(|p| !fixture_paths.contains(p)) + .collect::>(), + ); +} + +#[test] +fn test_cli_feature_gated_commands_in_fixture() { + let fixture = load_cli_commands_fixture(); + + let mut fixture_paths: Vec = fixture["feature_gated_commands"] + .as_array() + .expect("feature_gated_commands should be an array") + .iter() + .map(|cmd| { + cmd["path"] + .as_str() + .expect("path should be a string") + .to_string() + }) + .collect(); + fixture_paths.sort(); + + // Feature-gated commands (keychain) are only in the Clap tree when + // compiled with --features keychain. We can't test them programmatically + // without that feature, so we validate the fixture has the expected set. + // + // When the keychain feature IS enabled at compile time, the commands + // will also appear in the Clap tree and be caught by the main test above. + let mut expected = vec![ + "keychain set", + "keychain get", + "keychain delete", + "keychain status", + ]; + expected.sort(); + let expected_strings: Vec = expected.iter().map(|s| s.to_string()).collect(); + + assert_eq!( + fixture_paths, + expected_strings, + "\nFeature-gated commands fixture does not match expected.\n\ + \nIn fixture but not expected: {:?}\n\ + Expected but not in fixture: {:?}", + fixture_paths + .iter() + .filter(|p| !expected_strings.contains(p)) + .collect::>(), + expected_strings + .iter() + .filter(|p| !fixture_paths.contains(p)) + .collect::>(), + ); +} + +#[test] +fn test_cli_commands_fixture_count() { + let fixture = load_cli_commands_fixture(); + + let commands = fixture["commands"] + .as_array() + .expect("commands should be an array"); + + // Count should match what the Clap tree produces + let clap_count = extract_clap_command_paths().len(); + assert_eq!( + commands.len(), + clap_count, + "Fixture has {} commands but Clap tree has {}. Update cli_commands.json.", + commands.len(), + clap_count + ); + + let feature_gated = fixture["feature_gated_commands"] + .as_array() + .expect("feature_gated_commands should be an array"); + assert_eq!( + feature_gated.len(), + 4, + "CLI should have exactly 4 feature-gated commands. Found {}.", + feature_gated.len() + ); +} + +#[test] +fn test_cli_commands_fixture_structure() { + let fixture = load_cli_commands_fixture(); + + assert_eq!( + fixture["schema_version"].as_i64().unwrap(), + 1, + "schema_version should be 1" + ); + assert_eq!( + fixture["cli_name"].as_str().unwrap(), + "jacs", + "cli_name should be 'jacs'" + ); + + // Every command should have path and about + for cmd in fixture["commands"].as_array().unwrap() { + assert!( + cmd["path"].as_str().is_some(), + "command missing 'path': {:?}", + cmd + ); + assert!( + cmd["about"].as_str().is_some(), + "command missing 'about': {:?}", + cmd + ); + } + + // Every feature-gated command should have path, feature, and about + for cmd in fixture["feature_gated_commands"].as_array().unwrap() { + assert!( + cmd["path"].as_str().is_some(), + "feature-gated command missing 'path': {:?}", + cmd + ); + assert!( + cmd["feature"].as_str().is_some(), + "feature-gated command missing 'feature': {:?}", + cmd + ); + assert!( + cmd["about"].as_str().is_some(), + "feature-gated command missing 'about': {:?}", + cmd + ); + } +} diff --git a/jacs-duckdb/Cargo.toml b/jacs-duckdb/Cargo.toml index ed95b27b..f65b09ad 100644 --- a/jacs-duckdb/Cargo.toml +++ b/jacs-duckdb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-duckdb" -version = "0.1.6" +version = "0.1.7" edition = "2024" rust-version.workspace = true description = "DuckDB storage backend for JACS documents" @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "duckdb", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.12", path = "../jacs", default-features = false } +jacs = { version = "0.9.13", path = "../jacs", default-features = false } duckdb = { version = "1.4", features = ["bundled", "json"] } serde_json = "1.0" diff --git a/jacs-mcp/Cargo.toml b/jacs-mcp/Cargo.toml index b0603e16..4fac39ab 100644 --- a/jacs-mcp/Cargo.toml +++ b/jacs-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-mcp" -version = "0.9.12" +version = "0.9.13" edition = "2024" rust-version = "1.93" description = "MCP server for JACS: data provenance and cryptographic signing of agent state" @@ -45,8 +45,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } rmcp = { version = "0.12", features = ["client", "server", "transport-io", "transport-child-process", "macros"], optional = true } tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time"], optional = true } -jacs = { version = "0.9.12", path = "../jacs", default-features = true } -jacs-binding-core = { version = "0.9.12", path = "../binding-core", features = ["a2a"] } +jacs = { version = "0.9.13", path = "../jacs", default-features = true } +jacs-binding-core = { version = "0.9.13", path = "../binding-core", features = ["a2a"] } serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1.0" diff --git a/jacs-mcp/README.md b/jacs-mcp/README.md index d85ca56f..1e1e4f56 100644 --- a/jacs-mcp/README.md +++ b/jacs-mcp/README.md @@ -1,10 +1,10 @@ # JACS MCP Server -A Model Context Protocol (MCP) server for **data provenance and cryptographic signing** of agent state, messaging, agreements, and A2A interoperability. +MCP server for agent identity, data provenance, and trust — sign, verify, and manage agent state, documents, agreements, and A2A artifacts. -This is the canonical full JACS MCP server. The checked-in contract snapshot for downstream adapters lives at [`contract/jacs-mcp-contract.json`](contract/jacs-mcp-contract.json). +Uses **stdio transport only** for security — the server holds the agent's private key, so no HTTP endpoints are exposed. -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 checked-in contract snapshot for downstream adapters lives at [`contract/jacs-mcp-contract.json`](contract/jacs-mcp-contract.json). ## What can it do? diff --git a/jacs-mcp/contract/jacs-mcp-contract.json b/jacs-mcp/contract/jacs-mcp-contract.json index 357a73b9..fca4df21 100644 --- a/jacs-mcp/contract/jacs-mcp-contract.json +++ b/jacs-mcp/contract/jacs-mcp-contract.json @@ -3,7 +3,7 @@ "server": { "name": "jacs-mcp", "title": "JACS MCP Server", - "version": "0.9.12", + "version": "0.9.13", "website_url": "https://humanassisted.github.io/JACS/", "instructions": "This MCP server provides data provenance and cryptographic signing for agent state files and agent-to-agent messaging. Agent state tools: jacs_sign_state (sign files), jacs_verify_state (verify integrity), jacs_load_state (load with verification), jacs_update_state (update and re-sign), jacs_list_state (list signed docs), jacs_adopt_state (adopt external files). Memory tools: jacs_memory_save (save a memory), jacs_memory_recall (search memories by query), jacs_memory_list (list all memories), jacs_memory_forget (soft-delete a memory), jacs_memory_update (update an existing memory). Messaging tools: jacs_message_send (create and sign a message), jacs_message_update (update and re-sign a message), jacs_message_agree (co-sign/agree to a message), jacs_message_receive (verify and extract a received message). Agent management: jacs_create_agent (create new agent with keys), jacs_reencrypt_key (rotate private key password). A2A artifacts: jacs_wrap_a2a_artifact (sign artifact with provenance), jacs_verify_a2a_artifact (verify wrapped artifact), jacs_assess_a2a_agent (assess remote agent trust level). A2A discovery: jacs_export_agent_card (export Agent Card), jacs_generate_well_known (generate .well-known documents), jacs_export_agent (export full agent JSON). Trust store: jacs_trust_agent (add agent to trust store), jacs_untrust_agent (remove from trust store, requires JACS_MCP_ALLOW_UNTRUST=true), jacs_list_trusted_agents (list all trusted agent IDs), jacs_is_trusted (check if agent is trusted), jacs_get_trusted_agent (get trusted agent JSON). Attestation: jacs_attest_create (create signed attestation with claims), jacs_attest_verify (verify attestation, optionally with evidence checks), jacs_attest_lift (lift signed document into attestation), jacs_attest_export_dsse (export attestation as DSSE envelope). Security: jacs_audit (read-only security audit and health checks). Audit trail: jacs_audit_log (record events as signed audit entries), jacs_audit_query (search audit trail by action, target, time range), jacs_audit_export (export audit trail as signed bundle). Search: jacs_search (unified search across all signed documents)." }, diff --git a/jacs-postgresql/Cargo.toml b/jacs-postgresql/Cargo.toml index 0ed9fcd1..452fa90d 100644 --- a/jacs-postgresql/Cargo.toml +++ b/jacs-postgresql/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-postgresql" -version = "0.1.6" +version = "0.1.7" edition = "2024" rust-version.workspace = true description = "PostgreSQL storage backend for JACS documents" @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "postgresql", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.12", path = "../jacs", default-features = false } +jacs = { version = "0.9.13", path = "../jacs", default-features = false } sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres"] } tokio = { version = "1.0", features = ["rt-multi-thread"] } serde_json = "1.0" diff --git a/jacs-redb/Cargo.toml b/jacs-redb/Cargo.toml index 896b093b..88e734ff 100644 --- a/jacs-redb/Cargo.toml +++ b/jacs-redb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-redb" -version = "0.1.6" +version = "0.1.7" edition = "2024" rust-version.workspace = true readme.workspace = true @@ -13,7 +13,7 @@ categories.workspace = true description = "Redb (pure-Rust embedded KV) storage backend for JACS documents" [dependencies] -jacs = { version = "0.9.12", path = "../jacs", default-features = false } +jacs = { version = "0.9.13", path = "../jacs", default-features = false } redb = "3.1" chrono = "0.4.40" serde_json = "1.0" diff --git a/jacs-surrealdb/Cargo.toml b/jacs-surrealdb/Cargo.toml index ba765283..08314cab 100644 --- a/jacs-surrealdb/Cargo.toml +++ b/jacs-surrealdb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-surrealdb" -version = "0.1.6" +version = "0.1.7" edition = "2024" rust-version.workspace = true description = "SurrealDB storage backend for JACS documents" @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "surrealdb", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.12", path = "../jacs", default-features = false } +jacs = { version = "0.9.13", path = "../jacs", default-features = false } surrealdb = { version = "3.0.2", default-features = false, features = ["kv-mem"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index 88f43a2b..25c912c0 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs" -version = "0.9.12" +version = "0.9.13" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacs/README.md b/jacs/README.md index 0a5347a6..6e0fbaed 100644 --- a/jacs/README.md +++ b/jacs/README.md @@ -1,97 +1,59 @@ # JACS: JSON Agent Communication Standard -Cryptographic signing and verification for AI agents. +Cryptographic identity, 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)** +**[Documentation](https://humanassisted.github.io/JACS/)** | **[Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html)** | **[API Reference](https://docs.rs/jacs/latest/jacs/)** ```bash cargo install jacs-cli ``` -## Quick Start +## What it does + +| Capability | Description | +|-----------|-------------| +| **Agent Identity** | Generate a cryptographic keypair. Post-quantum (ML-DSA-87), Ed25519, or RSA-PSS. | +| **Data Provenance** | Sign any JSON document or file with tamper-evident signatures. | +| **Agent Trust** | Verify identities, manage trust stores, enforce trust policies across agents. | + +## Quick start (Rust) ```rust use jacs::simple::{load, sign_message, verify}; -// Load agent load(None)?; -// Sign a message let signed = sign_message(&serde_json::json!({"action": "approve"}))?; -// Verify it let result = verify(&signed.raw)?; assert!(result.valid); ``` -## 6 Core Operations - -| Operation | Description | -|-----------|-------------| -| `create()` | Create a new agent with keys | -| `load()` | Load agent from config | -| `verify_self()` | Verify agent integrity | -| `sign_message()` | Sign JSON data | -| `sign_file()` | Sign files with embedding | -| `verify()` | Verify any signed document | - -## Features - -- 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) -- Verified document storage via filesystem and local `rusqlite` search/indexing -- Trait-based storage interfaces with additional backends in separate crates -- MCP and A2A protocol support -- Python, Go, and NPM bindings - ## CLI ```bash -jacs create # Create new agent -jacs sign-message "hi" # Sign a message -jacs sign-file doc.pdf # Sign a file -jacs verify doc.json # Verify a document +jacs quickstart --name my-agent --domain example.com +jacs document create -f mydata.json +jacs verify signed-document.json +jacs mcp # start MCP server (stdio only) ``` ## Security -**Security Hardening**: This library includes: -- Password entropy validation for key encryption (minimum 28 bits, 35 bits for single character class) -- Thread-safe environment variable handling -- TLS certificate validation (strict by default; set `JACS_STRICT_TLS=false` only for local development) +- Password entropy validation for key encryption - Private key zeroization on drop -- Algorithm identification embedded in signatures -- Verification claim enforcement with downgrade prevention -- DNSSEC-validated identity verification for verified agents - -**Test Coverage**: JACS includes 260+ automated tests covering cryptographic operations (RSA, Ed25519, post-quantum ML-DSA), password validation, agent lifecycle, DNS identity verification, trust store operations, and claim-based security enforcement. Security-critical paths are tested with boundary conditions, failure cases, and attack scenarios (replay attacks, downgrade attempts, key mismatches). - -**Reporting Vulnerabilities**: Please report security issues responsibly. -- Email: security@hai.ai -- 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`. +- Algorithm identification embedded in signatures with downgrade prevention +- DNSSEC-validated identity verification +- MCP server uses stdio only — no network exposure +- 260+ automated tests covering cryptographic operations and attack scenarios -**Best Practices**: -- Do not put the private key password in config. -- On desktops, prefer the OS keychain when available. -- On Linux/headless services, prefer `JACS_PASSWORD_FILE` from a secret mount and set `JACS_KEYCHAIN_BACKEND=disabled`. -- `JACS_PRIVATE_KEY_PASSWORD` is supported, but is less desirable for long-running service processes. -- Use strong passwords (12+ characters with mixed case, numbers, symbols) -- Store private keys securely with appropriate file permissions -- Keep JACS and its dependencies updated +Report vulnerabilities to security@hai.ai. ## Links - [Documentation](https://humanassisted.github.io/JACS/) - [Rust API](https://docs.rs/jacs/latest/jacs/) -- [Python](https://pypi.org/project/jacs/) - [Crates.io](https://crates.io/crates/jacs) +- [Development Guide](../DEVELOPMENT.md) **Version**: 0.9.7 | [HAI.AI](https://hai.ai) diff --git a/jacs/src/agent/mod.rs b/jacs/src/agent/mod.rs index 97602dc3..1ad1be2c 100644 --- a/jacs/src/agent/mod.rs +++ b/jacs/src/agent/mod.rs @@ -241,12 +241,30 @@ pub(crate) fn decrypt_with_agent_password( } #[derive(Debug)] +/// # Thread Safety +/// +/// `Agent` is **not internally synchronized** by design. All mutable fields +/// (value, id, version, keys, etc.) are unprotected because every production +/// entry point wraps `Agent` in an external `Mutex`: +/// +/// - `SimpleAgent` (`simple/core.rs`): `agent: Mutex` +/// - `AgentWrapper` (`binding-core`): `inner: Arc>` +/// - `FilesystemDocumentService`: `Arc>` +/// - `JacsMcpServer`: `Arc` (which holds `Arc>`) +/// +/// Adding field-level synchronization (e.g., `Arc>` on each field) +/// would double-lock with the external Mutex, adding overhead without benefit. +/// The one exception is `document_schemas` which uses `Arc>` because +/// it was designed for independent schema loading that can outlive a single +/// lock scope. +/// +/// **If you use `Agent` directly (without `SimpleAgent` or `AgentWrapper`), +/// you must provide your own synchronization.** pub struct Agent { /// the JSONSchema used /// todo use getter pub schema: Schema, - /// the agent JSON Struct - /// TODO make this threadsafe + /// the agent JSON struct — protected by external Mutex (see struct-level docs) value: Option, /// use getter pub config: Option, diff --git a/jacs/src/crypt/private_key.rs b/jacs/src/crypt/private_key.rs index 17a2aaa1..16a7cb9e 100644 --- a/jacs/src/crypt/private_key.rs +++ b/jacs/src/crypt/private_key.rs @@ -123,6 +123,26 @@ impl LockedVec { } } +impl PartialEq for LockedVec { + fn eq(&self, other: &Self) -> bool { + self.inner == other.inner + } +} + +impl Eq for LockedVec {} + +impl PartialEq> for LockedVec { + fn eq(&self, other: &Vec) -> bool { + self.inner == *other + } +} + +impl PartialEq for Vec { + fn eq(&self, other: &LockedVec) -> bool { + *self == other.inner + } +} + impl AsRef<[u8]> for LockedVec { fn as_ref(&self) -> &[u8] { &self.inner diff --git a/jacs/src/keystore/mod.rs b/jacs/src/keystore/mod.rs index 9ee9851f..5f2dd459 100644 --- a/jacs/src/keystore/mod.rs +++ b/jacs/src/keystore/mod.rs @@ -85,7 +85,10 @@ pub struct KeySpec { pub trait KeyStore: Send + Sync + fmt::Debug { fn generate(&self, _spec: &KeySpec) -> Result<(Vec, Vec), JacsError>; - fn load_private(&self) -> Result, JacsError>; + /// Load the private key material. Returns `LockedVec` so that bytes remain + /// mlock'd (pinned to RAM, excluded from core dumps) for the caller's + /// entire usage lifetime. + fn load_private(&self) -> Result; fn load_public(&self) -> Result, JacsError>; fn sign_detached( &self, @@ -334,7 +337,7 @@ impl KeyStore for FsEncryptedStore { Ok((priv_key, pub_key)) } - fn load_private(&self) -> Result, JacsError> { + fn load_private(&self) -> Result { let key_dir = &self.paths.key_directory; let storage = Self::storage_for_key_dir(key_dir)?; let priv_path = self.paths.private_key_path(); @@ -376,21 +379,9 @@ impl KeyStore for FsEncryptedStore { e ) })?; - // Wrap in LockedVec so the decrypted bytes are mlock'd (pinned to RAM) - // during the brief window before being returned to the caller. The - // LockedVec is dropped at end of scope, which zeroizes + munlocks. - // - // SECURITY NOTE: The returned Vec is NOT mlock'd — the key material - // spends most of its lifetime in regular heap memory that could be swapped - // to disk or included in core dumps. InMemoryKeyStore avoids this by - // keeping keys in LockedVec for their full lifetime. - // - // TODO: When KeyStore::load_private() return type changes to LockedVec, - // this intermediate copy can be eliminated (breaking trait change). - let locked = LockedVec::new(decrypted.as_slice().to_vec()); - let result = locked.as_slice().to_vec(); - // locked is dropped here -> zeroize + munlock - Ok(result) + // Return LockedVec directly — key material stays mlock'd (pinned to + // RAM, excluded from core dumps) for the caller's entire usage lifetime. + Ok(LockedVec::new(decrypted.as_slice().to_vec())) } fn load_public(&self) -> Result, JacsError> { @@ -503,7 +494,7 @@ macro_rules! unimplemented_store { fn generate(&self, _spec: &KeySpec) -> Result<(Vec, Vec), JacsError> { Err(concat!(stringify!($name), " not implemented").into()) } - fn load_private(&self) -> Result, JacsError> { + fn load_private(&self) -> Result { Err(concat!(stringify!($name), " not implemented").into()) } fn load_public(&self) -> Result, JacsError> { @@ -597,15 +588,14 @@ impl KeyStore for InMemoryKeyStore { Ok((priv_key, pub_key)) } - fn load_private(&self) -> Result, JacsError> { - // TODO: When KeyStore::load_private() return type changes to LockedVec, - // this clone can be eliminated. For now we clone out of locked storage - // into a transient Vec that callers will use briefly for signing. + fn load_private(&self) -> Result { + // Clone into a fresh LockedVec so the caller gets its own mlock'd copy + // without holding the Mutex beyond this scope. self.private_key .lock() .unwrap() .as_ref() - .map(|lv| lv.as_slice().to_vec()) + .map(|lv| LockedVec::new(lv.as_slice().to_vec())) .ok_or_else(|| "InMemoryKeyStore: no private key generated yet".into()) } @@ -1196,17 +1186,17 @@ mod tests { }; let (_priv_key, pub_key) = ks.generate(&spec).unwrap(); - // load_private() clones from the LockedVec + // load_private() returns LockedVec — key material stays mlock'd let loaded_priv = ks.load_private().unwrap(); assert!( !loaded_priv.is_empty(), "loaded private key should not be empty" ); - // Sign using the loaded key + // Sign using the loaded key (LockedVec → &[u8] via as_ref) let message = b"test message for locked key signing"; let sig_bytes = ks - .sign_detached(&loaded_priv, message, "ring-Ed25519") + .sign_detached(loaded_priv.as_ref(), message, "ring-Ed25519") .unwrap(); assert!(!sig_bytes.is_empty(), "signature should not be empty"); diff --git a/jacs/src/lib.rs b/jacs/src/lib.rs index cf719a44..d4ef0474 100644 --- a/jacs/src/lib.rs +++ b/jacs/src/lib.rs @@ -422,8 +422,33 @@ pub fn create_task( } } -/// Update a task document (placeholder -- not yet implemented). -pub fn update_task(_: String) -> Result { - // TODO: implement proper task update logic - Ok("".to_string()) +/// Update an existing task document. +/// +/// Fetches the task by `task_key` (format: "id:version"), applies the caller's +/// modifications from `updated_task_json`, re-signs, versions, and persists. +/// The result is validated against the task schema before returning. +pub fn update_task( + agent: &mut Agent, + task_key: &str, + updated_task_json: &str, +) -> Result { + let jacs_doc = agent.update_document(task_key, updated_task_json, None, None)?; + + let task_value = jacs_doc.value; + let validation_result = agent.schema.taskschema.validate(&task_value); + match validation_result { + Ok(_) => Ok(task_value.to_string()), + Err(err) => { + let schema_name = task_value + .get("$schema") + .and_then(|v| v.as_str()) + .unwrap_or("task.schema.json"); + let error_message = format!( + "Task update failed: {}", + schema::format_schema_validation_error(&err, schema_name, &task_value) + ); + error!("{}", error_message); + Err(error_message.into()) + } + } } diff --git a/jacsgo/README.md b/jacsgo/README.md index 753b01ff..2fb21c8b 100644 --- a/jacsgo/README.md +++ b/jacsgo/README.md @@ -1,20 +1,16 @@ # JACS Go Bindings -**Sign it. Prove it.** +Cryptographic identity, signing, and verification for AI agents — from Go. -Cryptographic signatures for AI agent outputs -- so anyone can verify who said what and whether it was changed. No server. Three lines of code. - -**Note:** Go bindings are community-maintained and may not include all features available in the Python and Node.js bindings. The Go MCP code in this repo is demo/example code, not a full canonical JACS MCP server. For the full MCP surface, use the Rust `jacs-mcp` server. - -[Which integration should I use?](https://humanassisted.github.io/JACS/getting-started/decision-tree.html) | [Full documentation](https://humanassisted.github.io/JACS/) - -## Installation +**Note:** Go bindings are community-maintained and may not include all features available in the Rust, Python, and Node.js implementations. ```bash go get github.com/HumanAssisted/JACS/jacsgo ``` -## Quick Start +[Full documentation](https://humanassisted.github.io/JACS/) | [Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html) + +## Quick start ```go package main @@ -22,26 +18,21 @@ package main import ( "fmt" "log" - jacs "github.com/HumanAssisted/JACS/jacsgo" ) func main() { - // Load your agent if err := jacs.Load(nil); err != nil { - log.Fatal("Run: jacs create --name my-agent") + log.Fatal("Run: jacs quickstart --name my-agent --domain example.com") } - // Sign a message signed, _ := jacs.SignMessage(map[string]interface{}{ "action": "approve", "amount": 100, }) - fmt.Printf("Signed: %s\n", signed.DocumentID) - // Verify it result, _ := jacs.Verify(signed.Raw) - fmt.Printf("Valid: %t\n", result.Valid) + fmt.Printf("Valid: %t, Signer: %s\n", result.Valid, result.SignerID) } ``` @@ -49,133 +40,21 @@ func main() { | Function | Description | |----------|-------------| -| `Load(configPath *string)` | Load agent from config (nil = default `./jacs.config.json`) | -| `Create(name, opts)` | Create new agent with keys (programmatic) | -| `VerifySelf()` | Verify agent's own integrity | +| `Load(configPath)` | Load agent from config | +| `Create(name, opts)` | Create new agent with keys | | `SignMessage(data)` | Sign any JSON data | | `SignFile(path, embed)` | Sign a file | -| `Verify(doc)` | Verify signed document (JSON string) | -| `VerifyStandalone(doc, opts?)` | Verify without loading an agent; opts = *VerifyOptions (KeyResolution, DataDirectory, KeyDirectory) | -| `VerifyById(id)` | Verify a document by storage ID (`uuid:version`) | -| `GetDnsRecord(domain, ttl)` | Get DNS TXT record line for the agent | -| `GetWellKnownJson()` | Get well-known JSON for `/.well-known/jacs-pubkey.json` | -| `ReencryptKey(oldPw, newPw)` | Re-encrypt private key with new password | -| `ExportAgent()` | Get agent's JSON for sharing | -| `GetPublicKeyPEM()` | Get public key for sharing | -| `Audit(opts?)` | Read-only security audit; opts = *AuditOptions (ConfigPath, RecentN) | - -For concurrent use (multiple agents in one process), use `NewJacsAgent()`, then `agent.Load(path)` and agent methods (`SignRequest`, `VerifyDocument`, etc.). Call `agent.Close()` when done. - -## Types - -```go -// Returned from SignMessage/SignFile -type SignedDocument struct { - Raw string // Full JSON document - DocumentID string // UUID - AgentID string // Signer's ID - Timestamp string // ISO 8601 -} - -// Returned from Verify -type VerificationResult struct { - Valid bool - Data interface{} - SignerID string - Timestamp string - Attachments []Attachment - Errors []string -} -``` - -## Programmatic Agent Creation - -```go -import jacs "github.com/HumanAssisted/JACS/jacsgo" - -info, err := jacs.Create("my-agent", &jacs.CreateAgentOptions{ - Password: os.Getenv("JACS_PRIVATE_KEY_PASSWORD"), // required (or set env) - Algorithm: "pq2025", // default; also: "ring-Ed25519", "RSA-PSS" -}) -if err != nil { - log.Fatal(err) -} -fmt.Printf("Created: %s\n", info.AgentID) -``` - -### Verify by Document ID - -```go -result, err := jacs.VerifyById("550e8400-e29b-41d4-a716-446655440000:1") -if err == nil && result.Valid { - fmt.Println("Document verified") -} -``` - -### Re-encrypt Private Key - -```go -err := jacs.ReencryptKey("old-password-123!", "new-Str0ng-P@ss!") -``` - -### Password Requirements - -Passwords must be at least 8 characters and include uppercase, lowercase, a digit, and a special character. - -### Post-Quantum Algorithm - -Use `pq2025` (ML-DSA-87, FIPS-204) for post-quantum signing. - -## Examples - -### Sign and Verify - -```go -// Sign data -signed, err := jacs.SignMessage(myData) -if err != nil { - log.Fatal(err) -} - -// Send signed.Raw to another party... - -// Verify received document -result, err := jacs.Verify(receivedJSON) -if err != nil { - log.Fatal(err) -} +| `Verify(doc)` | Verify signed document | +| `VerifyStandalone(doc, opts)` | Verify without loading an agent | +| `ExportAgent()` | Export agent JSON for sharing | +| `Audit(opts)` | Run a security audit | -if result.Valid { - fmt.Printf("Signed by: %s\n", result.SignerID) - fmt.Printf("Data: %v\n", result.Data) -} -``` - -### File Signing - -```go -// Reference only (hash stored, content not embedded) -signed, _ := jacs.SignFile("contract.pdf", false) - -// Embed content (for portable documents) -signed, _ := jacs.SignFile("contract.pdf", true) -``` +Uses CGo to call the JACS Rust library via FFI. Requires a Rust toolchain to build from source. -## Platform Integration - -For platform-level features (agent registration, key discovery, benchmarking), see the [haisdk](https://github.com/HumanAssisted/haisdk) package. Attestation, A2A (agent cards, trust policy), and protocol helpers (e.g. `BuildAuthHeader`, `CanonicalizeJson`) are available on `JacsAgent` and as package-level wrappers; see godoc. - -## Building - -The Go bindings use CGo to call the JACS Rust library via FFI. Requires the Rust toolchain to build from source. From the jacsgo directory: - -```bash -make build -``` +See [DEVELOPMENT.md](https://github.com/HumanAssisted/JACS/blob/main/DEVELOPMENT.md) for the full API reference and build instructions. -## See Also +## Links -- [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 +- [JACS Documentation](https://humanassisted.github.io/JACS/) +- [Source](https://github.com/HumanAssisted/JACS) - [Examples](./examples/) diff --git a/jacsgo/error_parity_test.go b/jacsgo/error_parity_test.go new file mode 100644 index 00000000..326213e3 --- /dev/null +++ b/jacsgo/error_parity_test.go @@ -0,0 +1,215 @@ +package jacs + +// Error kind parity test for the Go binding. +// +// Validates that all error kinds listed in the `error_kinds` array of +// binding-core/tests/fixtures/parity_inputs.json are recognized by the +// Go binding's error handling. +// +// Go maps Rust ErrorKind variants through: +// 1. Named sentinel errors in errors.go (ErrSigningFailed, etc.) +// 2. Error message strings from the CGo FFI layer +// +// This test complements, not duplicates, the behavioral error tests in +// simple_agent_parity_test.go. +// +// KNOWN LIMITATION: 8 of 13 error kinds are validated structurally only +// (mapping existence in errorKindMap), not behaviorally (actually triggered +// at runtime). Untriggerable kinds require states impractical in unit tests +// (e.g., mutex poisoning, network calls, trust store setup). + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "sort" + "testing" +) + +type parityInputsForErrors struct { + ErrorKinds []string `json:"error_kinds"` +} + +func loadErrorKindsFromFixture(t *testing.T) []string { + t.Helper() + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + fixturePath := filepath.Join(filepath.Dir(thisFile), fixtureRelPath) + + data, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("failed to read parity_inputs.json at %s: %v", fixturePath, err) + } + + var p parityInputsForErrors + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("failed to parse parity_inputs.json: %v", err) + } + + if len(p.ErrorKinds) == 0 { + t.Fatal("error_kinds array is empty in parity_inputs.json") + } + + return p.ErrorKinds +} + +// errorKindInfo documents how each Rust ErrorKind is represented in Go. +type errorKindInfo struct { + // goSentinel is the name of the Go sentinel error variable (if any). + // Empty string means no dedicated Go sentinel exists. + goSentinel string + // messagePattern is a substring expected in error messages for this kind. + messagePattern string + // triggerable indicates if this error can be reliably triggered in tests. + triggerable bool +} + +// errorKindMap documents how each Rust ErrorKind variant maps to Go. +// This must be updated when a new ErrorKind variant is added. +var errorKindMap = map[string]errorKindInfo{ + "LockFailed": { + goSentinel: "", + messagePattern: "lock", + triggerable: false, // Requires concurrent mutex poisoning + }, + "AgentLoad": { + goSentinel: "ErrConfigNotFound", + messagePattern: "config", + triggerable: true, + }, + "Validation": { + goSentinel: "ErrConfigInvalid", + messagePattern: "invalid", + triggerable: true, + }, + "SigningFailed": { + goSentinel: "ErrSigningFailed", + messagePattern: "sign", + triggerable: true, + }, + "VerificationFailed": { + goSentinel: "ErrVerificationFailed", + messagePattern: "verification", + triggerable: true, + }, + "DocumentFailed": { + goSentinel: "ErrInvalidDocument", + messagePattern: "document", + triggerable: false, + }, + "AgreementFailed": { + goSentinel: "", + messagePattern: "agreement", + triggerable: false, + }, + "SerializationFailed": { + goSentinel: "", + messagePattern: "serialization", + triggerable: false, + }, + "InvalidArgument": { + goSentinel: "", + messagePattern: "invalid", + triggerable: true, + }, + "TrustFailed": { + goSentinel: "ErrAgentNotTrusted", + messagePattern: "trust", + triggerable: false, + }, + "NetworkFailed": { + goSentinel: "", + messagePattern: "network", + triggerable: false, + }, + "KeyNotFound": { + goSentinel: "ErrKeyNotFound", + messagePattern: "key", + triggerable: false, + }, + "Generic": { + goSentinel: "", + messagePattern: "", + triggerable: false, + }, +} + +// TestErrorKindParityFromFixture validates all error kinds from the fixture +// are mapped in the Go binding. +func TestErrorKindParityFromFixture(t *testing.T) { + errorKinds := loadErrorKindsFromFixture(t) + + unmapped := []string{} + for _, kind := range errorKinds { + if _, ok := errorKindMap[kind]; !ok { + unmapped = append(unmapped, kind) + } + } + + if len(unmapped) > 0 { + sort.Strings(unmapped) + t.Errorf("Error kinds from fixture not mapped in Go: %v.\n"+ + "Add entries to errorKindMap in error_parity_test.go.", unmapped) + } +} + +// TestErrorKindMapHasNoStaleEntries validates the Go map doesn't have extras. +func TestErrorKindMapHasNoStaleEntries(t *testing.T) { + errorKinds := loadErrorKindsFromFixture(t) + fixtureSet := make(map[string]bool) + for _, k := range errorKinds { + fixtureSet[k] = true + } + + stale := []string{} + for kind := range errorKindMap { + if !fixtureSet[kind] { + stale = append(stale, kind) + } + } + + if len(stale) > 0 { + sort.Strings(stale) + t.Errorf("errorKindMap contains stale entries not in fixture: %v. Remove them.", stale) + } +} + +// TestErrorKindCount validates there are exactly 13 error kinds. +func TestErrorKindCount(t *testing.T) { + errorKinds := loadErrorKindsFromFixture(t) + + if len(errorKinds) != 13 { + t.Errorf("expected 13 error kinds in fixture, got %d", len(errorKinds)) + } + if len(errorKindMap) != 13 { + t.Errorf("expected 13 entries in errorKindMap, got %d", len(errorKindMap)) + } +} + +// TestGoSentinelErrorsExist validates that referenced Go sentinel errors +// are actually defined (compile-time check via reference). +func TestGoSentinelErrorsExist(t *testing.T) { + // These references ensure the sentinel errors exist at compile time. + sentinels := map[string]error{ + "ErrConfigNotFound": ErrConfigNotFound, + "ErrConfigInvalid": ErrConfigInvalid, + "ErrSigningFailed": ErrSigningFailed, + "ErrVerificationFailed": ErrVerificationFailed, + "ErrInvalidDocument": ErrInvalidDocument, + "ErrAgentNotTrusted": ErrAgentNotTrusted, + "ErrKeyNotFound": ErrKeyNotFound, + } + + for name, sentinel := range sentinels { + if sentinel == nil { + t.Errorf("sentinel error %s should not be nil", name) + } + if sentinel.Error() == "" { + t.Errorf("sentinel error %s should have a non-empty message", name) + } + } +} diff --git a/jacsgo/lib/Cargo.toml b/jacsgo/lib/Cargo.toml index af90641c..f77fdece 100644 --- a/jacsgo/lib/Cargo.toml +++ b/jacsgo/lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacsgo" -version = "0.9.12" +version = "0.9.13" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacsgo/mcp_contract_drift_test.go b/jacsgo/mcp_contract_drift_test.go new file mode 100644 index 00000000..1b07b335 --- /dev/null +++ b/jacsgo/mcp_contract_drift_test.go @@ -0,0 +1,215 @@ +package jacs + +// MCP contract drift test for the Go binding. +// +// This test loads the canonical Rust MCP contract from +// jacs-mcp/contract/jacs-mcp-contract.json and validates that Go's +// understanding of the available MCP tools matches the contract. +// +// Go does not have a native MCP adapter with tool definitions; it +// consumes the Rust MCP server via the CLI binary. This test ensures +// that if Rust adds, removes, or renames an MCP tool, the Go +// ecosystem is aware of the change. +// +// Pattern matches: jacspy/tests/test_mcp_contract_drift.py and +// jacsnpm/test/mcp-contract.test.js. + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "sort" + "testing" +) + +const mcpContractRelPath = "../jacs-mcp/contract/jacs-mcp-contract.json" + +// mcpContract represents the top-level structure of the MCP contract JSON. +type mcpContract struct { + SchemaVersion int `json:"schema_version"` + Server mcpServer `json:"server"` + Tools []mcpTool `json:"tools"` +} + +type mcpServer struct { + Name string `json:"name"` + Title string `json:"title"` + Version string `json:"version"` +} + +type mcpTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema interface{} `json:"input_schema"` +} + +func loadMcpContract(t *testing.T) mcpContract { + t.Helper() + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + contractPath := filepath.Join(filepath.Dir(thisFile), mcpContractRelPath) + + data, err := os.ReadFile(contractPath) + if err != nil { + t.Fatalf("failed to read MCP contract at %s: %v", contractPath, err) + } + + var c mcpContract + if err := json.Unmarshal(data, &c); err != nil { + t.Fatalf("failed to parse MCP contract: %v", err) + } + return c +} + +// expectedMcpTools returns the canonical list of expected MCP tool names. +// This is the single source of truth -- both TestMcpContractDrift and +// TestMcpContractToolCount use it, so there is only one place to update +// when tools are added or removed. +// +// MAINTENANCE NOTE (Issue 015): Unlike Python and Node drift tests which +// dynamically discover tools from their native MCP adapters, Go has no +// native MCP adapter. This hardcoded list IS the test. When a tool is +// added to or removed from jacs-mcp/contract/jacs-mcp-contract.json, +// this list must be manually updated. The contract_snapshot.rs test in +// Rust will fail first, signaling that this list also needs updating. +func expectedMcpTools() []string { + tools := []string{ + "jacs_adopt_state", + "jacs_assess_a2a_agent", + "jacs_attest_create", + "jacs_attest_export_dsse", + "jacs_attest_lift", + "jacs_attest_verify", + "jacs_audit", + "jacs_audit_export", + "jacs_audit_log", + "jacs_audit_query", + "jacs_check_agreement", + "jacs_create_agent", + "jacs_create_agreement", + "jacs_export_agent", + "jacs_export_agent_card", + "jacs_generate_well_known", + "jacs_get_trusted_agent", + "jacs_is_trusted", + "jacs_list_state", + "jacs_list_trusted_agents", + "jacs_load_state", + "jacs_memory_forget", + "jacs_memory_list", + "jacs_memory_recall", + "jacs_memory_save", + "jacs_memory_update", + "jacs_message_agree", + "jacs_message_receive", + "jacs_message_send", + "jacs_message_update", + "jacs_reencrypt_key", + "jacs_search", + "jacs_sign_agreement", + "jacs_sign_document", + "jacs_sign_state", + "jacs_trust_agent", + "jacs_untrust_agent", + "jacs_update_state", + "jacs_verify_a2a_artifact", + "jacs_verify_document", + "jacs_verify_state", + "jacs_wrap_a2a_artifact", + } + sort.Strings(tools) + return tools +} + +// TestMcpContractDrift validates that the canonical MCP contract tool names +// match the expected set known to Go. If a tool is added or removed from the +// Rust contract, this test fails until expectedMcpTools() is updated. +func TestMcpContractDrift(t *testing.T) { + contract := loadMcpContract(t) + + // Extract tool names from contract + var contractTools []string + for _, tool := range contract.Tools { + contractTools = append(contractTools, tool.Name) + } + sort.Strings(contractTools) + + expectedTools := expectedMcpTools() + + if len(contractTools) != len(expectedTools) { + t.Errorf("MCP contract has %d tools, expected %d.\nContract tools: %v\nExpected tools: %v", + len(contractTools), len(expectedTools), contractTools, expectedTools) + } + + // Find differences + inContractOnly := diff(contractTools, expectedTools) + inExpectedOnly := diff(expectedTools, contractTools) + + if len(inContractOnly) > 0 { + t.Errorf("Tools in MCP contract but not in Go expected set (need to add): %v", inContractOnly) + } + if len(inExpectedOnly) > 0 { + t.Errorf("Tools in Go expected set but not in MCP contract (need to remove): %v", inExpectedOnly) + } +} + +// TestMcpContractStructure validates the contract JSON structure is well-formed. +func TestMcpContractStructure(t *testing.T) { + contract := loadMcpContract(t) + + if contract.SchemaVersion != 1 { + t.Errorf("expected schema_version 1, got %d", contract.SchemaVersion) + } + if contract.Server.Name != "jacs-mcp" { + t.Errorf("expected server.name 'jacs-mcp', got %q", contract.Server.Name) + } + if contract.Server.Version == "" { + t.Error("server.version should not be empty") + } + if len(contract.Tools) == 0 { + t.Error("contract should have at least one tool") + } + + // Every tool should have a name and description + for i, tool := range contract.Tools { + if tool.Name == "" { + t.Errorf("tool[%d] has empty name", i) + } + if tool.Description == "" { + t.Errorf("tool[%d] (%s) has empty description", i, tool.Name) + } + } +} + +// TestMcpContractToolCount validates the total number of tools. +// The expected count is derived from the expectedTools list in +// TestMcpContractDrift, so there is only one place to update. +func TestMcpContractToolCount(t *testing.T) { + contract := loadMcpContract(t) + + // Use the same expected tool list as TestMcpContractDrift + expectedCount := len(expectedMcpTools()) + if len(contract.Tools) != expectedCount { + t.Errorf("expected %d MCP tools, got %d. If tools were added/removed, update expectedMcpTools() and TestMcpContractDrift.", + expectedCount, len(contract.Tools)) + } +} + +// diff returns elements in a that are not in b. +func diff(a, b []string) []string { + bSet := make(map[string]bool, len(b)) + for _, s := range b { + bSet[s] = true + } + var result []string + for _, s := range a { + if !bSet[s] { + result = append(result, s) + } + } + return result +} diff --git a/jacsgo/method_parity_test.go b/jacsgo/method_parity_test.go new file mode 100644 index 00000000..846e6729 --- /dev/null +++ b/jacsgo/method_parity_test.go @@ -0,0 +1,232 @@ +package jacs + +// Method enumeration parity test for the Go binding. +// +// Validates that all methods listed in +// binding-core/tests/fixtures/method_parity.json are exposed on the +// Go JacsSimpleAgent struct, with documented exclusions and Go-style +// name mappings. +// +// This is a *structural* test (method names), not a *behavioral* test. +// It complements, not duplicates, simple_agent_parity_test.go. + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "sort" + "testing" +) + +const methodFixtureRelPath = "../binding-core/tests/fixtures/method_parity.json" + +type methodParityFixture struct { + AllMethodsFlat []string `json:"all_methods_flat"` +} + +func loadMethodParityFixture(t *testing.T) methodParityFixture { + t.Helper() + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + fixturePath := filepath.Join(filepath.Dir(thisFile), methodFixtureRelPath) + + data, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("failed to read method_parity.json at %s: %v", fixturePath, err) + } + + var f methodParityFixture + if err := json.Unmarshal(data, &f); err != nil { + t.Fatalf("failed to parse method_parity.json: %v", err) + } + return f +} + +// Methods that are intentionally not exposed in Go (with reasons). +var excludedFromGo = map[string]string{ + // inner_ref returns a raw Rust reference; not meaningful across CGo FFI + "inner_ref": "raw Rust reference, not FFI-safe", + // from_agent wraps a Rust SimpleAgent; not callable from Go + "from_agent": "Rust-internal constructor", + // load_with_info is an internal Rust helper; Go uses LoadSimpleAgent() + "load_with_info": "internal helper, Go uses LoadSimpleAgent", + // Conversion methods are not exposed via the CGo FFI layer. + // They would require additional C wrapper functions. + "to_yaml": "not exposed via CGo FFI", + "from_yaml": "not exposed via CGo FFI", + "to_html": "not exposed via CGo FFI", + "from_html": "not exposed via CGo FFI", +} + +// Rust snake_case -> Go PascalCase method name mapping. +// Constructors are package-level functions, not methods on *JacsSimpleAgent. +var goNameMap = map[string]string{ + "create": "NewSimpleAgent", // constructor (package-level) + "load": "LoadSimpleAgent", // constructor (package-level) + "ephemeral": "EphemeralSimpleAgent", // constructor (package-level) + "create_with_params": "CreateSimpleAgentWithParams", // constructor (package-level) + "get_agent_id": "GetAgentID", + "key_id": "KeyID", + "is_strict": "IsStrict", + "config_path": "ConfigPath", + "export_agent": "ExportAgent", + "get_public_key_pem": "GetPublicKeyPEM", + "get_public_key_base64": "GetPublicKeyBase64", + "diagnostics": "Diagnostics", + "verify_self": "VerifySelf", + "verify_json": "Verify", + "verify_with_key_json": "VerifyWithKey", + "verify_by_id_json": "VerifyByID", + "sign_message_json": "SignMessage", + "sign_raw_bytes_base64": "SignRawBytes", + "sign_file_json": "SignFile", +} + +// Constructors are package-level functions, not methods on *JacsSimpleAgent. +var goConstructors = map[string]bool{ + "NewSimpleAgent": true, + "LoadSimpleAgent": true, + "EphemeralSimpleAgent": true, + "CreateSimpleAgentWithParams": true, +} + +// goConstructorFuncs references actual constructor functions so the compiler +// catches removals. If any constructor is renamed or deleted, this file fails +// to compile -- no runtime test needed. +var goConstructorFuncs = map[string]interface{}{ + "NewSimpleAgent": NewSimpleAgent, + "LoadSimpleAgent": LoadSimpleAgent, + "EphemeralSimpleAgent": EphemeralSimpleAgent, + "CreateSimpleAgentWithParams": CreateSimpleAgentWithParams, +} + +// TestMethodParityAgainstFixture validates that all expected methods exist +// on Go's JacsSimpleAgent. +func TestMethodParityAgainstFixture(t *testing.T) { + fixture := loadMethodParityFixture(t) + + // Get all methods on *JacsSimpleAgent via reflection + agentType := reflect.TypeOf(&JacsSimpleAgent{}) + instanceMethods := make(map[string]bool) + for i := 0; i < agentType.NumMethod(); i++ { + instanceMethods[agentType.Method(i).Name] = true + } + + missing := []string{} + for _, rustName := range fixture.AllMethodsFlat { + if _, excluded := excludedFromGo[rustName]; excluded { + continue + } + + goName, mapped := goNameMap[rustName] + if !mapped { + missing = append(missing, rustName+" (no goNameMap entry)") + continue + } + + if goConstructors[goName] { + // Constructors are package-level functions, verified at + // compile time via goConstructorFuncs (references the actual + // functions). If a constructor is removed, this file fails + // to compile. + continue + } + + if !instanceMethods[goName] { + missing = append(missing, rustName+" -> "+goName) + } + } + + if len(missing) > 0 { + sort.Strings(missing) + t.Errorf("Go JacsSimpleAgent is missing %d methods from method_parity.json:\n%s\n\n"+ + "If a method was intentionally excluded, add it to excludedFromGo.\n"+ + "If it has a different Go name, add it to goNameMap.", + len(missing), formatLines(missing)) + } +} + +// TestMethodParityExclusionsAreValid verifies every excluded method exists in the fixture. +func TestMethodParityExclusionsAreValid(t *testing.T) { + fixture := loadMethodParityFixture(t) + allMethods := make(map[string]bool) + for _, m := range fixture.AllMethodsFlat { + allMethods[m] = true + } + + invalid := []string{} + for excluded := range excludedFromGo { + if !allMethods[excluded] { + invalid = append(invalid, excluded) + } + } + + if len(invalid) > 0 { + t.Errorf("excludedFromGo contains methods not in the fixture: %v. Remove stale exclusions.", invalid) + } +} + +// TestMethodParityNameMapCoversAll verifies the name map covers all non-excluded methods. +func TestMethodParityNameMapCoversAll(t *testing.T) { + fixture := loadMethodParityFixture(t) + + unmapped := []string{} + for _, rustName := range fixture.AllMethodsFlat { + if _, excluded := excludedFromGo[rustName]; excluded { + continue + } + if _, mapped := goNameMap[rustName]; !mapped { + unmapped = append(unmapped, rustName) + } + } + + if len(unmapped) > 0 { + t.Errorf("Methods without goNameMap entry: %v. Add a mapping.", unmapped) + } +} + +// TestMethodParityExclusionsAreStillNeeded checks if excluded methods +// have since been exposed on *JacsSimpleAgent. If a method was excluded +// because it wasn't in the CGo FFI layer but has since been added, this +// test fails to prompt removal of the exclusion. +func TestMethodParityExclusionsAreStillNeeded(t *testing.T) { + agentType := reflect.TypeOf(&JacsSimpleAgent{}) + + // Only check conversion-method exclusions (the ones likely to change). + // internal-only exclusions (inner_ref, from_agent, load_with_info) will + // never appear as Go methods. + conversionExclusions := map[string]string{ + "to_yaml": "ToYaml", + "from_yaml": "FromYaml", + "to_html": "ToHtml", + "from_html": "FromHtml", + } + + newlyAvailable := []string{} + for rustName, goName := range conversionExclusions { + _, found := agentType.MethodByName(goName) + if found { + newlyAvailable = append(newlyAvailable, rustName+" -> "+goName) + } + } + + if len(newlyAvailable) > 0 { + sort.Strings(newlyAvailable) + t.Errorf("Excluded methods are now available on *JacsSimpleAgent. "+ + "Remove them from excludedFromGo and verify they work:\n%s", + formatLines(newlyAvailable)) + } +} + +func formatLines(items []string) string { + result := "" + for _, item := range items { + result += " - " + item + "\n" + } + return result +} diff --git a/jacsnpm/Cargo.toml b/jacsnpm/Cargo.toml index 81f79829..6fda89c0 100644 --- a/jacsnpm/Cargo.toml +++ b/jacsnpm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacsnpm" -version = "0.9.12" +version = "0.9.13" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacsnpm/README.md b/jacsnpm/README.md index 33f161a6..e968bbc2 100644 --- a/jacsnpm/README.md +++ b/jacsnpm/README.md @@ -1,44 +1,16 @@ # JACS for Node.js -**Sign it. Prove it.** - -Cryptographic signatures for AI agent outputs -- so anyone can verify who said what and whether it was changed. No server. Three lines of code. - -[Which integration should I use?](https://humanassisted.github.io/JACS/getting-started/decision-tree.html) | [Full documentation](https://humanassisted.github.io/JACS/) - -**Dependencies**: The `overrides` in `package.json` for `body-parser` and `qs` are for security (CVE-2024-45590). Do not remove them without re-auditing. - -## Installation +Cryptographic identity, signing, and verification for AI agents — from Node.js. ```bash npm install @hai.ai/jacs ``` -The npm package ships prebuilt native bindings for supported targets and does not compile Rust during `npm install`. - -## v0.9.0: Attestation Support - -New in v0.9.0: **attestation** -- evidence-based trust proofs on top of cryptographic signing. Create attestations with claims, evidence, and derivation chains. Verify locally (signature + hash) or fully (evidence + chain). Export as DSSE for in-toto/SLSA compatibility. All attestation APIs are available as async and sync variants. - -### v0.8.0: Framework Adapters - -First-class adapters for **Vercel AI SDK**, **Express**, **Koa**, **LangChain.js**, and a full **MCP tool suite**. All framework dependencies are optional peer deps -- install only what you use. - -### Async-First API - -All NAPI operations return Promises by default. Sync variants are available with a `Sync` suffix, following the Node.js convention (like `fs.readFile` vs `fs.readFileSync`). - -```javascript -// Async (default, recommended -- does not block the event loop) -const signed = await jacs.signMessage({ action: 'approve' }); - -// Sync (blocks event loop, use in scripts or CLI tools) -const signed = jacs.signMessageSync({ action: 'approve' }); -``` +Prebuilt native bindings. No Rust compilation during install. -## Quick Start +[Full documentation](https://humanassisted.github.io/JACS/) | [Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html) -Quickstart -- one call to start signing: +## Quick start ```javascript const jacs = require('@hai.ai/jacs/simple'); @@ -49,550 +21,50 @@ const result = await jacs.verify(signed.raw); console.log(`Valid: ${result.valid}, Signer: ${result.signerId}`); ``` -`quickstart(options)` creates a persistent agent with keys on disk and requires `options.name` and `options.domain` (with optional `description`). If `./jacs.config.json` already exists, it loads it; otherwise it creates a new agent. Agent, keys, and config are saved to `./jacs_data`, `./jacs_keys`, and `./jacs.config.json`. If `JACS_PRIVATE_KEY_PASSWORD` is not set, the native Rust layer generates a secure password; set `JACS_SAVE_PASSWORD_FILE=true` to persist it at `./jacs_keys/.jacs_password`. Pass `{ algorithm: 'ring-Ed25519' }` to override the default (`pq2025`). - -**Signed your first document?** Next: [Verify it standalone](#standalone-verification-no-agent-required) | [Add framework adapters](#framework-adapters) | [Multi-agent agreements](#multi-party-agreements) | [Full docs](https://humanassisted.github.io/JACS/getting-started/quick-start.html) - -### Advanced: Loading an existing agent - -If you already have an agent (e.g., created by a previous `quickstart({ name, domain })` call), load it explicitly: - -```javascript -const jacs = require('@hai.ai/jacs/simple'); - -await jacs.load('./jacs.config.json'); - -const signed = await jacs.signMessage({ action: 'approve', amount: 100 }); -const result = await jacs.verify(signed.raw); -console.log(`Valid: ${result.valid}, Signer: ${result.signerId}`); -``` +All operations are async by default. Sync variants available with a `Sync` suffix (e.g. `signMessageSync`). -### Headless / Embedded Loading Without Env Vars - -For Linux services or embedded apps that fetch secrets from a vault or secret -manager, prefer passing the password in memory to the low-level binding instead -of storing it in `JACS_PRIVATE_KEY_PASSWORD`: - -```javascript -const { JacsAgent } = require('@hai.ai/jacs'); - -const secret = getSecretFromManager(); - -const agent = new JacsAgent(); -agent.setPrivateKeyPassword(secret); -const info = JSON.parse(await agent.loadWithInfo('/srv/my-project/jacs.config.json')); -``` - -`@hai.ai/jacs/simple` and `JacsClient` remain the easiest high-level APIs, but -the low-level `JacsAgent` API is the explicit path for in-memory secret -injection. - -## Core API - -Every function that calls into NAPI has both async (default) and sync variants: - -| Function | Sync Variant | Description | -|----------|-------------|-------------| -| `quickstart(options)` | `quickstartSync(options)` | Create a persistent agent with keys on disk | -| `create(options)` | `createSync(options)` | Create a new agent programmatically | -| `load(configPath)` | `loadSync(configPath)` | Load agent from config file | -| `verifySelf()` | `verifySelfSync()` | Verify agent's own integrity | -| `updateAgent(data)` | `updateAgentSync(data)` | Update agent document | -| `updateDocument(id, data)` | `updateDocumentSync(id, data)` | Update existing document | -| `signMessage(data)` | `signMessageSync(data)` | Sign any JSON data | -| `signFile(path, embed)` | `signFileSync(path, embed)` | Sign a file | -| `verify(doc)` | `verifySync(doc)` | Verify signed document | -| `verifyById(id)` | `verifyByIdSync(id)` | Verify by storage ID | -| `reencryptKey(old, new)` | `reencryptKeySync(old, new)` | Re-encrypt private key | -| `getSetupInstructions(domain)` | `getSetupInstructionsSync(domain)` | Get DNS/well-known setup | -| `createAgreement(doc, ids, ...)` | `createAgreementSync(doc, ids, ...)` | Create multi-party agreement | -| `signAgreement(doc)` | `signAgreementSync(doc)` | Sign an agreement | -| `checkAgreement(doc)` | `checkAgreementSync(doc)` | Check agreement status | -| `audit(options?)` | `auditSync(options?)` | Run a security audit | -| `createAttestation(params)` | `createAttestationSync(params)` | Create signed attestation | -| `verifyAttestation(doc, opts?)` | `verifyAttestationSync(doc, opts?)` | Verify attestation (local or full) | -| `liftToAttestation(signedDoc, claims)` | `liftToAttestationSync(signedDoc, claims)` | Lift signed doc to attestation | -| `exportAttestationDsse(doc)` | `exportAttestationDsseSync(doc)` | Export attestation as DSSE | - -Pure sync functions (no NAPI call, no suffix needed): +## Core operations | Function | Description | |----------|-------------| -| `verifyStandalone(doc, opts?)` | Verify without loading an agent | -| `getPublicKey()` | Get public key | -| `isLoaded()` | Check if agent is loaded | -| `getDnsRecord(domain, ttl?)` | Get DNS TXT record | -| `getWellKnownJson()` | Get well-known JSON | -| `trustAgent(json)` | Add agent to trust store | -| `trustAgentWithKey(json, publicKeyPem)` | Add agent with explicit public key | -| `listTrustedAgents()` | List trusted agent IDs | -| `untrustAgent(id)` | Remove from trust store | -| `isTrusted(id)` | Check if agent is trusted | -| `getTrustedAgent(id)` | Get trusted agent's JSON | +| `quickstart(options)` | Create a persistent agent with keys — zero config | +| `load(configPath)` | Load agent from config file | +| `signMessage(data)` | Sign any JSON data | +| `signFile(path, embed)` | Sign a file | +| `verify(doc)` | Verify signed document | +| `verifyStandalone(doc, opts)` | Verify without loading an agent | +| `audit()` | Run a security audit | -## Types - -```typescript -interface SignedDocument { - raw: string; // Full JSON document - documentId: string; // UUID - agentId: string; // Signer's ID - timestamp: string; // ISO 8601 -} - -interface VerificationResult { - valid: boolean; - data?: any; - signerId: string; - timestamp: string; - attachments: Attachment[]; - errors: string[]; -} -``` - -## Programmatic Agent Creation - -```typescript -const jacs = require('@hai.ai/jacs/simple'); - -const agent = await jacs.create({ - name: 'my-agent', - password: process.env.JACS_PRIVATE_KEY_PASSWORD, // required - algorithm: 'pq2025', // default; also: "ring-Ed25519", "RSA-PSS" - dataDirectory: './jacs_data', - keyDirectory: './jacs_keys', -}); -console.log(`Created: ${agent.agentId}`); -``` - -### Standalone Verification (No Agent Required) - -Verify a signed document without loading an agent. Useful for one-off verification, CI/CD pipelines, or services that only need to verify, not sign. +## Verify without an agent ```typescript import { verifyStandalone } from '@hai.ai/jacs/simple'; -const result = verifyStandalone(signedJson, { - keyResolution: 'local', - keyDirectory: './trusted-keys/', -}); -if (result.valid) { - console.log(`Signed by: ${result.signerId}`); -} -``` - -Documents signed by Rust or Python agents verify identically in Node.js -- cross-language interop is tested on every commit with Ed25519 and pq2025 (ML-DSA-87). See the full [Verification Guide](https://humanassisted.github.io/JACS/getting-started/verification.html) for CLI, DNS, and cross-language examples. - -### Verify by Document ID - -```javascript -const result = await jacs.verifyById('550e8400-e29b-41d4-a716-446655440000:1'); -console.log(`Valid: ${result.valid}`); -``` - -### Re-encrypt Private Key - -```javascript -await jacs.reencryptKey('old-password-123!', 'new-Str0ng-P@ss!'); -``` - -### Password Requirements - -Passwords must be at least 8 characters and include uppercase, lowercase, a digit, and a special character. - -### Post-Quantum Algorithm - -Use `pq2025` (ML-DSA-87, FIPS-204) for post-quantum signing. - -## Examples - -### Sign and Verify - -```javascript -const jacs = require('@hai.ai/jacs/simple'); - -await jacs.load('./jacs.config.json'); - -// Sign data -const signed = await jacs.signMessage({ - action: 'transfer', - amount: 500, - to: 'agent-123' -}); - -// Later, verify received data -const result = await jacs.verify(receivedJson); -if (result.valid) { - console.log(`Signed by: ${result.signerId}`); - console.log(`Data: ${JSON.stringify(result.data)}`); -} -``` - -### Update Agent - -```javascript -// Get current agent, modify, and update -const agentDoc = JSON.parse(jacs.exportAgent()); -agentDoc.jacsAgentType = 'updated-service'; -const updated = await jacs.updateAgent(agentDoc); -console.log('Agent updated with new version'); -``` - -### Update Document - -```javascript -// Create a document -const signed = await jacs.signMessage({ status: 'pending', amount: 100 }); - -// Later, update it -const doc = JSON.parse(signed.raw); -doc.content.status = 'approved'; -const updated = await jacs.updateDocument(signed.documentId, doc); -console.log('Document updated with new version'); -``` - -### File Signing - -```javascript -// Reference only (stores hash) -const signed = await jacs.signFile('contract.pdf', false); - -// Embed content (portable document) -const embedded = await jacs.signFile('contract.pdf', true); -``` - -## Framework Adapters - -### Vercel AI SDK (`jacs/vercel-ai`) - -Sign AI model outputs with cryptographic provenance using the AI SDK's middleware pattern: - -```typescript -import { JacsClient } from '@hai.ai/jacs/client'; -import { withProvenance } from '@hai.ai/jacs/vercel-ai'; -import { openai } from '@ai-sdk/openai'; -import { generateText } from 'ai'; - -const client = await JacsClient.quickstart({ name: 'my-agent', domain: 'agent.example.com' }); -const model = withProvenance(openai('gpt-4o'), { client }); - -const { text, providerMetadata } = await generateText({ model, prompt: 'Hello!' }); -console.log(providerMetadata?.jacs?.text?.documentId); // signed proof -``` - -Works with `generateText`, `streamText` (signs after stream completes), and tool calls. Compose with other middleware via `jacsProvenance()`. - -**Peer deps**: `npm install ai @ai-sdk/provider` - -### Express Middleware (`jacs/express`) - -Verify incoming signed requests, optionally auto-sign responses: - -```typescript -import express from 'express'; -import { JacsClient } from '@hai.ai/jacs/client'; -import { jacsMiddleware } from '@hai.ai/jacs/express'; - -const client = await JacsClient.quickstart({ name: 'my-agent', domain: 'agent.example.com' }); -const app = express(); -app.use(express.text({ type: 'application/json' })); -app.use(jacsMiddleware({ client, verify: true })); - -app.post('/api/data', (req, res) => { - console.log(req.jacsPayload); // verified payload - // Manual signing via req.jacsClient: - req.jacsClient.signMessage({ status: 'ok' }).then(signed => { - res.type('text/plain').send(signed.raw); - }); -}); -``` - -Options: `client`, `configPath`, `sign` (auto-sign, default false), `verify` (default true), `optional` (allow unsigned, default false). Supports Express v4 + v5. - -For auth-style endpoints, enable replay protection: - -```typescript -app.use( - jacsMiddleware({ - client, - verify: true, - authReplay: { enabled: true, maxAgeSeconds: 30, clockSkewSeconds: 5 }, - }), -); -``` - -**Peer dep**: `npm install express` - -### Koa Middleware (`jacs/koa`) - -```typescript -import Koa from 'koa'; -import { jacsKoaMiddleware } from '@hai.ai/jacs/koa'; - -const app = new Koa(); -app.use(jacsKoaMiddleware({ client, verify: true, sign: true })); -app.use(async (ctx) => { - console.log(ctx.state.jacsPayload); // verified - ctx.body = { status: 'ok' }; // auto-signed when sign: true -}); -``` - -For auth-style endpoints, enable replay protection: - -```typescript -app.use( - jacsKoaMiddleware({ - client, - verify: true, - authReplay: { enabled: true, maxAgeSeconds: 30, clockSkewSeconds: 5 }, - }), -); -``` - -**Peer dep**: `npm install koa` - -### LangChain.js (`jacs/langchain`) - -Two integration patterns — full toolkit or auto-signing wrappers: - -**Full toolkit** — give your LangChain agent access to all JACS operations (sign, verify, agreements, trust, audit): - -```typescript -import { JacsClient } from '@hai.ai/jacs/client'; -import { createJacsTools } from '@hai.ai/jacs/langchain'; - -const client = await JacsClient.quickstart({ name: 'my-agent', domain: 'agent.example.com' }); -const jacsTools = createJacsTools({ client }); - -// Bind to your LLM — agent can now sign, verify, create agreements, etc. -const llm = model.bindTools([...myTools, ...jacsTools]); -``` - -Returns 14 tools: `jacs_sign`, `jacs_verify`, `jacs_create_agreement`, `jacs_sign_agreement`, `jacs_check_agreement`, `jacs_verify_self`, `jacs_trust_agent`, `jacs_trust_agent_with_key`, `jacs_list_trusted`, `jacs_is_trusted`, `jacs_share_public_key`, `jacs_share_agent`, `jacs_audit`, `jacs_agent_info`. - -**Auto-signing wrappers** — transparently sign existing tool outputs: - -```typescript -import { signedTool, jacsToolNode } from '@hai.ai/jacs/langchain'; - -// Wrap a single tool -const signed = signedTool(myTool, { client }); - -// Or wrap all tools in a ToolNode (LangGraph) -const node = jacsToolNode([tool1, tool2], { client }); -``` - -**Peer deps**: `npm install @langchain/core` (and optionally `@langchain/langgraph` for `jacsToolNode`) - -### MCP (`jacs/mcp`) - -Two integration patterns — transport proxy or partial tool compatibility registration. -The canonical full MCP server is built into the `jacs` binary, launched via `jacs mcp`. - -**Transport proxy** — wrap any MCP transport with signing/verification: - -```typescript -import { JacsClient } from '@hai.ai/jacs/client'; -import { createJACSTransportProxy } from '@hai.ai/jacs/mcp'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; - -const client = await JacsClient.quickstart({ name: 'my-agent', domain: 'agent.example.com' }); -const baseTransport = new StdioServerTransport(); -const secureTransport = createJACSTransportProxy(baseTransport, client, 'server'); -``` - -**MCP tool registration** — add the jacsnpm compatibility tool set to your MCP server: - -```typescript -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { JacsClient } from '@hai.ai/jacs/client'; -import { registerJacsTools } from '@hai.ai/jacs/mcp'; - -const server = new Server({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); -const client = await JacsClient.quickstart({ name: 'my-agent', domain: 'agent.example.com' }); -registerJacsTools(server, client); -``` - -Registers a partial compatibility layer for signing, verification, agreements, trust, A2A, audit, and selected legacy helpers. Use `getJacsMcpToolDefinitions()` and `handleJacsMcpToolCall()` for custom integration. If you need the full canonical `jacs_*` MCP surface, use the Rust server. - -**Peer dep**: `npm install @modelcontextprotocol/sdk` - -### Legacy: `jacs/http` - -The old `JACSExpressMiddleware` and `JACSKoaMiddleware` are still available from `@hai.ai/jacs/http` for backward compatibility. New code should use `@hai.ai/jacs/express` and `@hai.ai/jacs/koa`. - -## JacsClient (Instance-Based API) - -`JacsClient` is the recommended API for new code. Each instance owns its own agent, so multiple clients can coexist in the same process without shared global state. - -```typescript -import { JacsClient } from '@hai.ai/jacs/client'; - -// Zero-config: loads or creates a persistent agent -const client = await JacsClient.quickstart({ - name: 'my-agent', - domain: 'agent.example.com', - algorithm: 'ring-Ed25519' -}); - -const signed = await client.signMessage({ action: 'approve', amount: 100 }); -const result = await client.verify(signed.raw); -console.log(`Valid: ${result.valid}, Signer: ${result.signerId}`); +const result = verifyStandalone(signedJson, { keyDirectory: './keys/' }); ``` -### Ephemeral Clients - -For testing or throwaway use, create an in-memory client with no files or env vars: - -```typescript -const client = await JacsClient.ephemeral('ring-Ed25519'); -const signed = await client.signMessage({ hello: 'world' }); -const result = await client.verify(signed.raw); -``` +Cross-language interop tested on every commit — documents signed in Rust or Python verify identically in Node.js. -Sync variants are also available: +## Framework adapters -```typescript -const client = JacsClient.ephemeralSync('ring-Ed25519'); -const signed = client.signMessageSync({ hello: 'world' }); -const result = client.verifySync(signed.raw); -``` +Adapters for Vercel AI SDK, Express, Koa, LangChain.js, and MCP. All framework dependencies are optional peer deps. -### Multi-Party Agreements +## Instance-based API -Create agreements that require signatures from multiple agents, with optional constraints: - -```typescript -const agreement = await client.createAgreement( - { action: 'deploy', version: '2.0' }, - [agentA.agentId, agentB.agentId], - { - question: 'Approve deployment?', - timeout: '2026-03-01T00:00:00Z', // ISO 8601 deadline - quorum: 2, // M-of-N signatures required - requiredAlgorithms: ['ring-Ed25519'], // restrict signing algorithms - minimumStrength: 'classical', // "classical" or "post-quantum" - }, -); - -// Other agents sign the agreement -const signed = await agentB.signAgreement(agreement.raw); - -// Check agreement status -const status = await client.checkAgreement(signed.raw); -console.log(`Complete: ${status.complete}, Signatures: ${status.signedCount}/${status.totalRequired}`); -``` - -### JacsClient API - -All instance methods have async (default) and sync variants: - -| Method | Sync Variant | Description | -|--------|-------------|-------------| -| `JacsClient.quickstart(options)` | `JacsClient.quickstartSync(options)` | Load or create a persistent agent | -| `JacsClient.ephemeral(algorithm?)` | `JacsClient.ephemeralSync(algorithm?)` | Create an in-memory agent | -| `client.load(configPath?)` | `client.loadSync(configPath?)` | Load agent from config file | -| `client.create(options)` | `client.createSync(options)` | Create a new agent | -| `client.signMessage(data)` | `client.signMessageSync(data)` | Sign any JSON data | -| `client.verify(doc)` | `client.verifySync(doc)` | Verify a signed document | -| `client.verifySelf()` | `client.verifySelfSync()` | Verify agent's own integrity | -| `client.verifyById(id)` | `client.verifyByIdSync(id)` | Verify by storage ID | -| `client.signFile(path, embed?)` | `client.signFileSync(path, embed?)` | Sign a file | -| `client.createAgreement(...)` | `client.createAgreementSync(...)` | Create multi-party agreement | -| `client.signAgreement(...)` | `client.signAgreementSync(...)` | Sign an agreement | -| `client.checkAgreement(...)` | `client.checkAgreementSync(...)` | Check agreement status | -| `client.updateAgent(data)` | `client.updateAgentSync(data)` | Update agent document | -| `client.updateDocument(id, data)` | `client.updateDocumentSync(id, data)` | Update a document | -| `client.exportAgentCard(agentData?)` | — | Export A2A Agent Card | -| `client.signArtifact(artifact, type, parents?)` | — | Sign A2A artifact | -| `client.verifyArtifact(wrapped)` | — | Verify A2A artifact | -| `client.createAttestation(params)` | — | Create attestation | -| `client.verifyAttestation(doc, opts?)` | — | Verify attestation | -| `client.liftToAttestation(signedDoc, claims)` | — | Lift doc to attestation | -| `client.exportAttestationDsse(doc)` | — | Export attestation as DSSE | - -See [`examples/multi_agent_agreement.ts`](./examples/multi_agent_agreement.ts) for a complete multi-agent agreement demo. - -## A2A Protocol Support - -Every JACS agent is an A2A agent -- zero additional configuration. JACS implements the [Agent-to-Agent (A2A)](https://github.com/a2aproject/A2A) protocol with cryptographic trust built in. -For A2A security, JACS is an OAuth alternative for service-to-service agent trust (mTLS-like at the payload layer), not a replacement for OAuth/OIDC delegated user authorization. - -### Quick Start +For multiple agents in one process: ```typescript import { JacsClient } from '@hai.ai/jacs/client'; -const client = await JacsClient.quickstart({ name: 'my-agent', domain: 'agent.example.com' }); -const card = client.exportAgentCard(); -const signed = await client.signArtifact({ action: 'classify', input: 'hello' }, 'task'); +const client = await JacsClient.quickstart({ name: 'my-agent', domain: 'example.com' }); +const signed = await client.signMessage({ action: 'approve' }); ``` -### Using JACSA2AIntegration Directly - -For full A2A lifecycle control (well-known documents, chain of custody, extension descriptors): - -```typescript -import { JacsClient } from '@hai.ai/jacs/client'; - -const client = await JacsClient.quickstart({ name: 'my-agent', domain: 'agent.example.com' }); -const a2a = client.getA2A(); - -// Export an A2A Agent Card -const card = a2a.exportAgentCard(agentData); - -// Sign an artifact with provenance -const signed = await a2a.signArtifact({ taskId: 't-1', operation: 'classify' }, 'task'); - -// Verify a received artifact -const result = await a2a.verifyWrappedArtifact(signed); -console.log(result.valid); - -// Build chain of custody across agents -const step2 = await a2a.signArtifact( - { step: 2, data: 'processed' }, 'message', - [signed], // parent signatures -); -``` - -When using `a2a.listen(0)`, Node picks a free port automatically. Use `server.address().port` if you need to read it programmatically. - -### Trust Policies - -JACS trust policies control how your agent handles foreign signatures: - -| Policy | Behavior | -|--------|----------| -| `open` | Accept all signatures without key resolution | -| `verified` | Require key resolution before accepting (**default**) | -| `strict` | Require the signer to be in your local trust store | - -See the [A2A Guide](https://humanassisted.github.io/JACS/integrations/a2a.html) for well-known documents, cross-organization discovery, and chain-of-custody examples. - -## Testing - -The `@hai.ai/jacs/testing` module provides zero-setup test helpers: - -```typescript -import { createTestClient, createTestClientSync } from '@hai.ai/jacs/testing'; - -// Async (preferred) -const client = await createTestClient('ring-Ed25519'); -const signed = await client.signMessage({ hello: 'test' }); -const result = await client.verify(signed.raw); -assert(result.valid); - -// Sync -const client2 = createTestClientSync('ring-Ed25519'); -const signed2 = client2.signMessageSync({ hello: 'test' }); -const result2 = client2.verifySync(signed2.raw); -assert(result2.valid); -``` +See [DEVELOPMENT.md](https://github.com/HumanAssisted/JACS/blob/main/DEVELOPMENT.md) for the full API reference, advanced usage (agreements, A2A, attestation, headless loading), framework adapter examples, and testing utilities. -## See Also +## Links -- [JACS Book](https://humanassisted.github.io/JACS/) - Full documentation (published book) -- [Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html) -- [Verification Guide](https://humanassisted.github.io/JACS/getting-started/verification.html) - CLI, standalone, DNS verification -- [Source](https://github.com/HumanAssisted/JACS) - GitHub repository +- [JACS Documentation](https://humanassisted.github.io/JACS/) +- [Verification Guide](https://humanassisted.github.io/JACS/getting-started/verification.html) +- [Source](https://github.com/HumanAssisted/JACS) - [Examples](./examples/) diff --git a/jacsnpm/package.json b/jacsnpm/package.json index a6a7e33b..8479af7a 100644 --- a/jacsnpm/package.json +++ b/jacsnpm/package.json @@ -1,6 +1,6 @@ { "name": "@hai.ai/jacs", - "version": "0.9.12", + "version": "0.9.13", "description": "JACS (JSON Agent Communication Standard) - Data provenance and cryptographic signing for AI agents", "main": "index.js", "types": "index.d.ts", diff --git a/jacsnpm/test/adapter-inventory.test.js b/jacsnpm/test/adapter-inventory.test.js new file mode 100644 index 00000000..a8e96019 --- /dev/null +++ b/jacsnpm/test/adapter-inventory.test.js @@ -0,0 +1,121 @@ +/** + * Adapter inventory parity test for the Node.js binding. + * + * Validates that all Node adapters listed in + * binding-core/tests/fixtures/adapter_inventory.json are requireable + * and expose the documented public functions. + * + * This test complements (does not duplicate) the MCP contract drift test. + * It validates API surface existence only. + */ + +const fs = require('fs'); +const path = require('path'); +const { expect } = require('chai'); + +const FIXTURE_PATH = path.resolve( + __dirname, + '../../binding-core/tests/fixtures/adapter_inventory.json' +); + +describe('Node.js adapter inventory parity', function () { + let inventory; + let nodeAdapters; + + before(function () { + if (!fs.existsSync(FIXTURE_PATH)) { + console.log(' Skipping adapter inventory tests - fixture not found'); + this.skip(); + } + inventory = JSON.parse(fs.readFileSync(FIXTURE_PATH, 'utf8')); + nodeAdapters = inventory.adapters && inventory.adapters.node; + if (!nodeAdapters) { + console.log(' Skipping - no Node adapters in inventory'); + this.skip(); + } + }); + + it('inventory fixture is valid JSON with expected structure', function () { + expect(inventory).to.have.property('adapters'); + expect(inventory.adapters).to.have.property('node'); + }); + + it('node adapter entries have required fields', function () { + for (const [adapterName, adapter] of Object.entries(nodeAdapters)) { + if (adapterName.startsWith('_')) continue; + expect(adapter, `${adapterName} should have module`).to.have.property('module'); + expect(adapter, `${adapterName} should have public_functions`).to.have.property('public_functions'); + expect(adapter.public_functions, `${adapterName} public_functions should be non-empty`).to.be.an('array').that.is.not.empty; + } + }); + + it('MCP adapter module is requireable', function () { + const mcpAdapter = nodeAdapters.mcp; + if (!mcpAdapter) { + this.skip(); + return; + } + + let mcpModule; + try { + mcpModule = require(`../${mcpAdapter.module}.js`); + } catch (e) { + // If the MCP module isn't compiled, skip + this.skip(); + return; + } + + expect(mcpModule).to.not.be.null; + }); + + it('MCP adapter exposes all listed public functions', function () { + const mcpAdapter = nodeAdapters.mcp; + if (!mcpAdapter) { + this.skip(); + return; + } + + let mcpModule; + try { + mcpModule = require(`../${mcpAdapter.module}.js`); + } catch (e) { + this.skip(); + return; + } + + const missing = []; + for (const funcName of mcpAdapter.public_functions) { + if (typeof mcpModule[funcName] === 'undefined') { + missing.push(funcName); + } + } + + expect(missing, `MCP adapter missing public functions: ${missing.join(', ')}`).to.be.empty; + }); + + it('all Node adapter public functions exist in their modules', function () { + for (const [adapterName, adapter] of Object.entries(nodeAdapters)) { + if (adapterName.startsWith('_')) continue; + + let mod; + try { + mod = require(`../${adapter.module}.js`); + } catch (e) { + // Module not compiled/available, skip this adapter + continue; + } + + const missing = []; + for (const funcName of adapter.public_functions) { + if (typeof mod[funcName] === 'undefined') { + missing.push(funcName); + } + } + + expect( + missing, + `Node adapter '${adapterName}' (${adapter.module}) missing: ${missing.join(', ')}` + ).to.be.empty; + } + }); +}); diff --git a/jacsnpm/test/error-parity.test.js b/jacsnpm/test/error-parity.test.js new file mode 100644 index 00000000..f07a3ef6 --- /dev/null +++ b/jacsnpm/test/error-parity.test.js @@ -0,0 +1,154 @@ +/** + * Error kind parity test for the Node.js binding. + * + * Validates that all error kinds listed in the `error_kinds` array of + * binding-core/tests/fixtures/parity_inputs.json are recognized by the + * Node binding's error handling. + * + * The Rust ErrorKind enum has 13 variants. Node maps these through error + * message prefixes (all errors are JavaScript Error instances). + * + * This test complements, not duplicates, the behavioral error tests in + * test_parity.js section 7. + * + * KNOWN LIMITATION: 8 of 13 error kinds are validated structurally only + * (mapping existence in ERROR_KIND_MAP), not behaviorally. Only the + * triggerable kinds are tested with runtime assertions. Untriggerable + * kinds require states that are impractical in unit tests (e.g., mutex + * poisoning, network calls, trust store setup). + */ + +const fs = require('fs'); +const path = require('path'); +const { expect } = require('chai'); + +const FIXTURE_PATH = path.resolve( + __dirname, + '../../binding-core/tests/fixtures/parity_inputs.json' +); + +// Mapping from Rust ErrorKind variant name to Node error representation. +// Each entry documents how this error kind manifests in JavaScript. +const ERROR_KIND_MAP = { + 'LockFailed': { + messagePattern: 'lock', + triggerable: false, // Requires concurrent mutex poisoning + }, + 'AgentLoad': { + messagePattern: 'Failed to load', + triggerable: true, + }, + 'Validation': { + messagePattern: 'Validation', + triggerable: true, + }, + 'SigningFailed': { + messagePattern: 'Sign', + triggerable: true, + }, + 'VerificationFailed': { + messagePattern: 'Verification failed', + triggerable: true, + }, + 'DocumentFailed': { + messagePattern: 'Document', + triggerable: false, + }, + 'AgreementFailed': { + messagePattern: 'Agreement', + triggerable: false, + }, + 'SerializationFailed': { + messagePattern: 'Serialization', + triggerable: true, + }, + 'InvalidArgument': { + messagePattern: 'Invalid', + triggerable: true, + }, + 'TrustFailed': { + messagePattern: 'Trust', + triggerable: false, + }, + 'NetworkFailed': { + messagePattern: 'Network', + triggerable: false, + }, + 'KeyNotFound': { + messagePattern: 'key', + triggerable: false, + }, + 'Generic': { + messagePattern: null, // Catch-all, no specific pattern + triggerable: false, + }, +}; + +describe('Node.js error kind parity', function () { + let fixture; + let errorKinds; + + before(function () { + if (!fs.existsSync(FIXTURE_PATH)) { + console.log(' Skipping error parity tests - fixture not found'); + this.skip(); + return; + } + fixture = JSON.parse(fs.readFileSync(FIXTURE_PATH, 'utf8')); + errorKinds = fixture.error_kinds; + if (!errorKinds) { + console.log(' Skipping - no error_kinds in fixture'); + this.skip(); + } + }); + + it('all error kinds from fixture are mapped in ERROR_KIND_MAP', function () { + const unmapped = errorKinds.filter(kind => !(kind in ERROR_KIND_MAP)); + expect(unmapped, `Unmapped error kinds: ${unmapped.join(', ')}`).to.be.empty; + }); + + it('ERROR_KIND_MAP has no stale entries', function () { + const fixtureSet = new Set(errorKinds); + const stale = Object.keys(ERROR_KIND_MAP).filter(kind => !fixtureSet.has(kind)); + expect(stale, `Stale ERROR_KIND_MAP entries: ${stale.join(', ')}`).to.be.empty; + }); + + it('there are exactly 13 error kinds', function () { + expect(errorKinds).to.have.length(13); + expect(Object.keys(ERROR_KIND_MAP)).to.have.length(13); + }); + + it('triggerable error kinds can be triggered via the binding', function () { + let JacsSimpleAgent; + try { + const bindings = require('../index.js'); + JacsSimpleAgent = bindings.JacsSimpleAgent; + if (!JacsSimpleAgent) { + this.skip(); + return; + } + } catch (e) { + this.skip(); + return; + } + + const agent = JacsSimpleAgent.ephemeral('ed25519'); + + // InvalidArgument: bad JSON input. + // This is a KNOWN behavioral difference from Python (see Issue 013): + // - Node signMessage passes raw string to binding-core sign_message_json, which + // expects valid JSON. Invalid JSON is rejected with InvalidArgument. + // - Python sign_message takes any Python object and serializes it first, so a + // raw string becomes a valid JSON string value and succeeds. + // - Both behaviors are correct for their respective API contracts. + // See parity_inputs.json 'sign_message_invalid_json_behavior' for documentation. + expect(() => agent.signMessage('{{{bad')).to.throw(/Invalid/i); + + // VerificationFailed: malformed document + expect(() => agent.verify('not json')).to.throw(/Verification failed|Malformed/i); + + // InvalidArgument: bad base64 key + const signed = agent.signMessage('{"test": 1}'); + expect(() => agent.verifyWithKey(signed, '!!!notbase64')).to.throw(/Invalid|base64/i); + }); +}); diff --git a/jacsnpm/test/method-parity.test.js b/jacsnpm/test/method-parity.test.js new file mode 100644 index 00000000..4a8ef403 --- /dev/null +++ b/jacsnpm/test/method-parity.test.js @@ -0,0 +1,158 @@ +/** + * Method enumeration parity test for the Node.js binding. + * + * Validates that all methods listed in + * binding-core/tests/fixtures/method_parity.json are exposed on the + * JacsSimpleAgent class, with documented exclusions and camelCase name mappings. + * + * This is a *structural* test (method names), not a *behavioral* test. + * It complements, not duplicates, test_parity.js. + */ + +const fs = require('fs'); +const path = require('path'); +const { expect } = require('chai'); + +const FIXTURE_PATH = path.resolve( + __dirname, + '../../binding-core/tests/fixtures/method_parity.json' +); + +// Methods that are intentionally Rust-only and not exposed in Node. +const EXCLUDED_FROM_NODE = new Set([ + // inner_ref returns a raw Rust reference; not meaningful across FFI + 'inner_ref', + // from_agent wraps a Rust SimpleAgent; not callable from JS + 'from_agent', + // load_with_info is an internal Rust helper; Node uses load() directly + 'load_with_info', +]); + +// Rust snake_case method name -> Node camelCase method name mapping. +const NODE_NAME_MAP = { + 'create': 'create', // static + 'load': 'load', // static + 'ephemeral': 'ephemeral', // static + 'create_with_params': 'createWithParams', // static + 'get_agent_id': 'getAgentId', + 'key_id': 'keyId', + 'is_strict': 'isStrict', + 'config_path': 'configPath', + 'export_agent': 'exportAgent', + 'get_public_key_pem': 'getPublicKeyPem', + 'get_public_key_base64': 'getPublicKeyBase64', + 'diagnostics': 'diagnostics', + 'verify_self': 'verifySelf', + 'verify_json': 'verify', + 'verify_with_key_json': 'verifyWithKey', + 'verify_by_id_json': 'verifyById', + 'sign_message_json': 'signMessage', + 'sign_raw_bytes_base64': 'signRawBytes', + 'sign_file_json': 'signFile', + 'to_yaml': 'toYaml', + 'from_yaml': 'fromYaml', + 'to_html': 'toHtml', + 'from_html': 'fromHtml', +}; + +// Static methods (on the class itself, not on instances) +const STATIC_METHODS = new Set(['create', 'load', 'ephemeral', 'createWithParams']); + +describe('Node.js method enumeration parity', function () { + let fixture; + let JacsSimpleAgent; + let agent; + + before(function () { + if (!fs.existsSync(FIXTURE_PATH)) { + console.log(' Skipping method parity tests - fixture not found'); + this.skip(); + return; + } + fixture = JSON.parse(fs.readFileSync(FIXTURE_PATH, 'utf8')); + + try { + const bindings = require('../index.js'); + JacsSimpleAgent = bindings.JacsSimpleAgent; + if (!JacsSimpleAgent) { + this.skip(); + return; + } + agent = JacsSimpleAgent.ephemeral('ed25519'); + } catch (e) { + console.log(' Skipping method parity tests - native binding not available'); + this.skip(); + } + }); + + it('all non-excluded methods from fixture exist on JacsSimpleAgent', function () { + const allMethods = fixture.all_methods_flat; + const missing = []; + + for (const rustName of allMethods) { + if (EXCLUDED_FROM_NODE.has(rustName)) continue; + + const nodeName = NODE_NAME_MAP[rustName]; + if (!nodeName) { + missing.push(`${rustName} (no NODE_NAME_MAP entry)`); + continue; + } + + if (STATIC_METHODS.has(nodeName)) { + // Check on the class itself + if (typeof JacsSimpleAgent[nodeName] !== 'function') { + missing.push(`${rustName} -> static ${nodeName}`); + } + } else { + // Check on the instance + if (typeof agent[nodeName] !== 'function') { + missing.push(`${rustName} -> ${nodeName}`); + } + } + } + + expect(missing, `Missing methods:\n${missing.join('\n')}`).to.be.empty; + }); + + it('exclusions are all valid fixture methods', function () { + const allMethods = new Set(fixture.all_methods_flat); + const invalid = []; + for (const excluded of EXCLUDED_FROM_NODE) { + if (!allMethods.has(excluded)) { + invalid.push(excluded); + } + } + expect(invalid, `EXCLUDED_FROM_NODE contains methods not in fixture: ${invalid}`).to.be.empty; + }); + + it('NODE_NAME_MAP covers all non-excluded methods', function () { + const allMethods = fixture.all_methods_flat; + const unmapped = []; + for (const rustName of allMethods) { + if (EXCLUDED_FROM_NODE.has(rustName)) continue; + if (!(rustName in NODE_NAME_MAP)) { + unmapped.push(rustName); + } + } + expect(unmapped, `Methods without NODE_NAME_MAP entry: ${unmapped}`).to.be.empty; + }); + + it('NODE_NAME_MAP has no stale entries', function () { + const allMethods = new Set(fixture.all_methods_flat); + const stale = []; + for (const rustName of Object.keys(NODE_NAME_MAP)) { + if (!allMethods.has(rustName)) { + stale.push(rustName); + } + } + expect(stale, `Stale NODE_NAME_MAP entries: ${stale}`).to.be.empty; + }); + + it('method count matches fixture minus exclusions', function () { + const expected = fixture.all_methods_flat.length - EXCLUDED_FROM_NODE.size; + const nodeNameCount = Object.keys(NODE_NAME_MAP).length; + expect(nodeNameCount).to.equal(expected, + `NODE_NAME_MAP has ${nodeNameCount} entries but expected ${expected} (fixture has ${fixture.all_methods_flat.length}, ${EXCLUDED_FROM_NODE.size} excluded)` + ); + }); +}); diff --git a/jacspy/Cargo.toml b/jacspy/Cargo.toml index 736bd5ce..dc6a3f30 100644 --- a/jacspy/Cargo.toml +++ b/jacspy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacspy" -version = "0.9.12" +version = "0.9.13" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacspy/README.md b/jacspy/README.md index 9e3f62f9..71f9a793 100644 --- a/jacspy/README.md +++ b/jacspy/README.md @@ -1,511 +1,77 @@ # JACS Python Library -**Sign it. Prove it.** - -Cryptographic signatures for AI agent outputs -- so anyone can verify who said what and whether it was changed. No server. Three lines of code. - -[Which integration should I use?](https://humanassisted.github.io/JACS/getting-started/decision-tree.html) | [Full documentation](https://humanassisted.github.io/JACS/) +Cryptographic identity, signing, and verification for AI agents — from Python. ```bash -# Using uv (recommended) -uv pip install jacs - -# Or with pip pip install jacs - ``` -The Python package ships prebuilt native bindings (via maturin) and does not compile Rust during `pip install`. Packaging/build metadata is defined in `pyproject.toml`. `setup.py` is intentionally not used. - -To check dependencies for known vulnerabilities when using optional extras, run `pip audit` (or `safety check`). +Prebuilt native bindings via maturin. No Rust compilation during install. -## Quick Start +[Full documentation](https://humanassisted.github.io/JACS/) | [Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html) -Zero-config -- one call to start signing: +## Quick start ```python import jacs.simple as jacs info = jacs.quickstart(name="my-agent", domain="my-agent.example.com") -print(info.config_path, info.public_key_path, info.private_key_path) signed = jacs.sign_message({"action": "approve", "amount": 100}) result = jacs.verify(signed.raw) print(f"Valid: {result.valid}, Signer: {result.signer_id}") ``` -`quickstart(name, domain, ...)` creates a persistent agent with keys on disk. If `./jacs.config.json` already exists, it loads it; otherwise it creates a new agent. Agent, keys, and config are saved to `./jacs_data`, `./jacs_keys`, and `./jacs.config.json`. If `JACS_PRIVATE_KEY_PASSWORD` is not set, the native Rust layer auto-generates a secure password (set `JACS_SAVE_PASSWORD_FILE=true` to persist it at `./jacs_keys/.jacs_password`). Returned `AgentInfo` includes config and key file paths. Pass `algorithm="ring-Ed25519"` or `algorithm="RSA-PSS"` to override the default (`pq2025`). - -**Signed your first document?** Next: [Verify it standalone](#standalone-verification-no-agent-required) | [Add framework adapters](#framework-adapters) | [Multi-agent agreements](#agreements-with-timeout-and-quorum) | [Full docs](https://humanassisted.github.io/JACS/getting-started/quick-start.html) - -### Advanced: Loading an existing agent - -If you already have an agent (e.g., created by a previous `quickstart(name=..., domain=...)` call), load it explicitly: - -```python -import jacs.simple as jacs - -agent = jacs.load("./jacs.config.json") - -# Sign a message (accepts dict, list, str, or any JSON-serializable data) -signed = jacs.sign_message({"action": "approve", "amount": 100}) -print(f"Signed by: {signed.agent_id}") - -# Verify it -result = jacs.verify(signed.raw) -print(f"Valid: {result.valid}") - -# Sign a file -signed_file = jacs.sign_file("document.pdf", embed=True) - -# Update agent metadata -agent_doc = json.loads(jacs.export_agent()) -agent_doc["jacsAgentType"] = "updated-service" -updated = jacs.update_agent(agent_doc) - -# Update a document -doc = json.loads(signed.raw) -doc["content"]["status"] = "approved" -updated_doc = jacs.update_document(signed.document_id, doc) -``` - -### Headless / Embedded Loading Without Env Vars - -For Linux services or embedded apps that fetch secrets from a vault or secret -manager, prefer passing the password in memory to the low-level binding instead -of storing it in `JACS_PRIVATE_KEY_PASSWORD`: - -```python -import json -from jacs import JacsAgent - -secret = get_secret_from_manager() - -agent = JacsAgent() -agent.set_private_key_password(secret) -info = json.loads(agent.load_with_info("/srv/my-project/jacs.config.json")) -``` - -`jacs.simple.load()` and `JacsClient(...)` are convenient higher-level entry -points, but the low-level `JacsAgent` API is the explicit path for in-memory -secret injection. +`quickstart()` creates a persistent agent with keys on disk. If `jacs.config.json` exists, it loads it; otherwise it creates a new agent. -## Core Operations - -The simplified API provides these core operations: +## Core operations | Operation | Description | |-----------|-------------| -| `quickstart(name, domain, ...)` | Create a persistent agent with keys on disk -- zero config, no manual setup | -| `create()` | Create a new agent programmatically (non-interactive) | +| `quickstart(name, domain)` | Create a persistent agent with keys — zero config | | `load()` | Load an existing agent from config | -| `verify_self()` | Verify the loaded agent's integrity | -| `update_agent()` | Update the agent document with new data | -| `update_document()` | Update an existing document with new data | -| `sign_message()` | Sign a text message or JSON data | +| `sign_message()` | Sign any JSON-serializable data | | `sign_file()` | Sign a file with optional embedding | -| `verify()` | Verify any signed document (JSON string) | -| `verify_standalone()` | Verify without loading an agent (one-off) | -| `verify_by_id()` | Verify a document by its storage ID (`uuid:version`) | -| `get_dns_record()` | Get DNS TXT record line for the agent | -| `get_well_known_json()` | Get well-known JSON for `/.well-known/jacs-pubkey.json` | -| `export_agent()` | Export agent JSON for sharing | -| `get_public_key()` | Get the agent's public key (e.g. for DNS) | -| `reencrypt_key()` | Re-encrypt the private key with a new password | -| `trust_agent()` | Add an agent to the local trust store | -| `list_trusted_agents()` | List all trusted agent IDs | -| `untrust_agent()` | Remove an agent from the trust store | -| `is_trusted()` | Check if an agent is trusted | -| `get_trusted_agent()` | Get a trusted agent's JSON document | -| `audit()` | Run a read-only security audit (returns risks, health_checks, summary) | - -### Programmatic Agent Creation - -```python -import jacs.simple as jacs - -# Create an agent without interactive prompts -agent = jacs.create( - name="my-agent", - password="Str0ng-P@ssw0rd!", # or set JACS_PRIVATE_KEY_PASSWORD env var - algorithm="pq2025", # default; also: "ring-Ed25519", "RSA-PSS" - data_directory="./jacs_data", - key_directory="./jacs_keys", -) -print(f"Created agent: {agent.agent_id}") -``` - -### Standalone Verification (No Agent Required) - -Verify a signed document without loading an agent. Useful for one-off verification, CI/CD pipelines, or services that only need to verify, not sign. - -```python -import jacs.simple as jacs - -result = jacs.verify_standalone( - signed_json, - key_resolution="local", - key_directory="./trusted-keys/" -) -if result.valid: - print(f"Signed by: {result.signer_id}") -``` - -Documents signed by Rust or Node.js agents verify identically in Python -- cross-language interop is tested on every commit with Ed25519 and pq2025 (ML-DSA-87). See the full [Verification Guide](https://humanassisted.github.io/JACS/getting-started/verification.html) for CLI, DNS, and cross-language examples. - -### Verify by Document ID - -```python -# If you have a document ID instead of the full JSON -result = jacs.verify_by_id("550e8400-e29b-41d4-a716-446655440000:1") -print(f"Valid: {result.valid}") -``` - -### Re-encrypt Private Key - -```python -jacs.reencrypt_key("old-password-123!", "new-Str0ng-P@ss!") -``` - -### Password Requirements - -Passwords must be at least 8 characters and include uppercase, lowercase, a digit, and a special character. - -### Post-Quantum Algorithm - -Use `pq2025` (ML-DSA-87, FIPS-204) for post-quantum signing. - -## Type Definitions - -```python -from jacs import AgentInfo, SignedDocument, VerificationResult - -# All return types are dataclasses with clear fields -agent: AgentInfo = jacs.load() -signed: SignedDocument = jacs.sign_message({"data": "hello"}) -result: VerificationResult = jacs.verify(signed.raw) -``` - -## JacsClient (Instance-Based API) - -When you need multiple agents in one process, or want to avoid global state, use `JacsClient`. Each instance wraps its own `JacsAgent` with independent keys and config. - -```python -from jacs.client import JacsClient - -# Load from config -client = JacsClient("./jacs.config.json") -signed = client.sign_message({"action": "approve"}) -result = client.verify(signed.raw_json) -print(f"Valid: {result.valid}, Agent: {client.agent_id}") - -# Or zero-config quickstart (creates keys on disk) -client = JacsClient.quickstart(name="my-agent", domain="my-agent.example.com") - -# Context manager for automatic cleanup -with JacsClient.quickstart(name="my-agent", domain="my-agent.example.com") as client: - signed = client.sign_message("hello") -``` - -### Multi-Agent Example - -```python -from jacs.client import JacsClient - -alice = JacsClient.ephemeral() -bob = JacsClient.ephemeral() - -signed = alice.sign_message({"from": "alice"}) -result = bob.verify(signed.raw_json) -print(f"Alice: {alice.agent_id}") -print(f"Bob verifies Alice's message: {result.valid}") -``` - -### Agreements with Timeout and Quorum - -`create_agreement` accepts flat keyword arguments for advanced options: - -```python -from datetime import datetime, timedelta, timezone - -agreement = client.create_agreement( - document={"proposal": "Deploy model v2"}, - agent_ids=[alice.agent_id, bob.agent_id, mediator.agent_id], - question="Do you approve?", - quorum=2, # 2-of-3 signatures required - timeout=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), - required_algorithms=None, # optional: restrict signing algorithms - minimum_strength=None, # optional: "classical" or "post-quantum" -) - -signed = alice.sign_agreement(agreement) -status = alice.check_agreement(signed) -print(f"Complete: {status.complete}, Pending: {status.pending}") -``` - -See [`examples/multi_agent_agreement.py`](../examples/multi_agent_agreement.py) for a full 3-agent agreement demo with crypto proof chain. - -### JacsClient API Reference - -| Method | Description | -|--------|-------------| -| `JacsClient(config_path)` | Load from config | -| `JacsClient.quickstart(name, domain, ...)` | Zero-config persistent agent | -| `JacsClient.ephemeral()` | In-memory agent (no disk, for tests) | -| `sign_message(data)` | Sign JSON-serializable data | -| `verify(document)` | Verify a signed document | -| `verify_self()` | Verify agent integrity | -| `verify_by_id(doc_id)` | Verify by storage ID | -| `sign_file(path, embed)` | Sign a file | -| `create_agreement(...)` | Create multi-party agreement | -| `sign_agreement(doc)` | Co-sign an agreement | -| `check_agreement(doc)` | Check agreement status | -| `trust_agent(json)` | Add agent to trust store | -| `list_trusted_agents()` | List trusted agent IDs | -| `update_agent(data)` | Update and re-sign agent | -| `update_document(id, data)` | Update and re-sign document | +| `verify()` | Verify any signed document | +| `verify_standalone()` | Verify without loading an agent | | `export_agent()` | Export agent JSON for sharing | -| `audit()` | Run security audit | -| `reset()` | Clear internal state | - -## Framework Adapters - -Auto-sign AI framework outputs with zero infrastructure. Install the extra for your framework: - -```bash -pip install jacs[langchain] # LangChain / LangGraph -pip install jacs[fastapi] # FastAPI / Starlette -pip install jacs[crewai] # CrewAI -pip install jacs[anthropic] # Anthropic / Claude SDK -pip install jacs[all] # Everything -``` - -**LangChain** -- sign every tool result via middleware: -```python -from jacs.adapters.langchain import jacs_signing_middleware -agent = create_agent(model="openai:gpt-4o", tools=tools, middleware=[jacs_signing_middleware()]) -``` - -**FastAPI** -- sign all JSON responses: -```python -from jacs.adapters.fastapi import JacsMiddleware -app.add_middleware(JacsMiddleware) -``` - -For auth-style endpoints, enable replay protection: - -```python -from jacs.adapters.fastapi import JacsMiddleware - -app.add_middleware( - JacsMiddleware, - auth_replay_protection=True, - auth_max_age_seconds=30, - auth_clock_skew_seconds=5, -) -``` - -**CrewAI** -- sign task outputs via guardrail: -```python -from jacs.adapters.crewai import jacs_guardrail -task = Task(description="Analyze data", agent=my_agent, guardrail=jacs_guardrail()) -``` - -**Anthropic / Claude SDK** -- sign tool return values: -```python -from jacs.adapters.anthropic import signed_tool +| `audit()` | Run a security audit | -@signed_tool() -def get_weather(location: str) -> str: - return f"Weather in {location}: sunny" -``` - -See the [Framework Adapters guide](https://humanassisted.github.io/JACS/python/adapters.html) for full documentation, custom adapters, and strict/permissive mode details. - -## Testing - -The `jacs.testing` module provides a pytest fixture that creates an ephemeral client with no disk I/O or env vars required: - -```python -from jacs.testing import jacs_agent - -def test_sign_and_verify(jacs_agent): - signed = jacs_agent.sign_message({"test": True}) - result = jacs_agent.verify(signed.raw_json) - assert result.valid - -def test_agent_has_unique_id(jacs_agent): - assert jacs_agent.agent_id -``` - -The fixture automatically resets after each test. - -## MCP Integration - -The canonical full JACS MCP server is the Rust `jacs-mcp` binary. Python keeps FastMCP-native middleware and a partial MCP compatibility adapter for embedding JACS behavior into Python servers. - -For AI tool servers using the Model Context Protocol: +## Verify without an agent ```python -from fastmcp import FastMCP -import jacs.simple as jacs - -mcp = FastMCP("My Server") -jacs.load("./jacs.config.json") - -@mcp.tool() -def signed_hello(name: str) -> dict: - signed = jacs.sign_message({"greeting": f"Hello, {name}!"}) - return {"response": signed.raw} +result = jacs.verify_standalone(signed_json, key_directory="./keys") ``` -## JacsAgent Class (Advanced) +Cross-language interop tested on every commit — documents signed in Rust or Node.js verify identically in Python. -For more control, use the `JacsAgent` class directly: - -```python -from jacs import JacsAgent - -agent = JacsAgent() -agent.load("./jacs.config.json") - -# Sign raw strings -signature = agent.sign_string("data to sign") - -# Verify documents -is_valid = agent.verify_document(document_json) - -# Create documents with schemas -doc = agent.create_document(json_string, schema=None) -``` - -## A2A Protocol Support - -Every JACS agent is an A2A agent -- zero additional configuration. JACS implements the [Agent-to-Agent (A2A)](https://github.com/a2aproject/A2A) protocol with cryptographic trust built in. -For A2A security, JACS is an OAuth alternative for service-to-service agent trust (mTLS-like at the payload layer), not a replacement for OAuth/OIDC delegated user authorization. - -### Quick Start - -```python -from jacs.client import JacsClient - -client = JacsClient.quickstart(name="my-agent", domain="my-agent.example.com") -card = client.export_agent_card("http://localhost:8080") -signed = client.sign_artifact({"action": "classify", "input": "hello"}, "task") -``` - -### Using JACSA2AIntegration Directly - -For full A2A lifecycle control (well-known documents, chain of custody, extension descriptors): - -```python -from jacs.client import JacsClient -from jacs.a2a import JACSA2AIntegration - -client = JacsClient.quickstart(name="my-agent", domain="my-agent.example.com") -a2a = client.get_a2a(url="http://localhost:8080") - -# Export an A2A Agent Card -card = a2a.export_agent_card(agent_data) - -# Sign an artifact with provenance -signed = a2a.sign_artifact({"taskId": "t-1", "operation": "classify"}, "task") - -# Verify a received artifact -result = a2a.verify_wrapped_artifact(signed) -assert result["valid"] - -# Build chain of custody across agents -step2 = a2a.sign_artifact( - {"step": 2, "data": "processed"}, "message", - parent_signatures=[signed], -) -``` - -### One-Liner Quickstart - -```python -from jacs.a2a import JACSA2AIntegration - -a2a = JACSA2AIntegration.quickstart(url="http://localhost:8080") -a2a.serve(port=8080) # Publishes /.well-known/agent-card.json -``` - -### Trust Policies - -JACS trust policies control how your agent handles foreign signatures: - -| Policy | Behavior | -|--------|----------| -| `open` | Accept all signatures without key resolution | -| `verified` | Require key resolution before accepting (**default**) | -| `strict` | Require the signer to be in your local trust store | - -See the [A2A Guide](https://humanassisted.github.io/JACS/integrations/a2a.html) for well-known documents, cross-organization discovery, and chain-of-custody examples. - -## Installation +## Framework adapters ```bash -# Basic installation -pip install jacs - -# With framework adapters pip install jacs[langchain] # LangChain / LangGraph pip install jacs[fastapi] # FastAPI / Starlette pip install jacs[crewai] # CrewAI pip install jacs[anthropic] # Anthropic / Claude SDK -pip install jacs[all] # All adapters + MCP + A2A - -# With A2A support -pip install jacs[a2a] # Discovery helpers (native runtime, no extra Python deps) -pip install jacs[a2a-server] # A2A server with serve() (FastAPI + uvicorn) - -# With MCP support -pip install jacs[mcp] - -# Optional: jacs[langgraph] (LangGraph ToolNode), jacs[ws] (WebSockets). See pyproject.toml. - +pip install jacs[a2a] # A2A protocol +pip install jacs[all] # Everything ``` -## Examples - -See the [examples/](./examples/) directory: -- `quickstart.py` - Basic signing and verification -- `sign_file.py` - File signing with embeddings -- `mcp_server.py` - Authenticated MCP server -- `p2p_exchange.py` - Peer-to-peer trust establishment -- [`multi_agent_agreement.py`](../examples/multi_agent_agreement.py) - Three-agent agreement with quorum, timeout, and crypto proof chain - -## Development +## Instance-based API -Using uv (recommended): +For multiple agents in one process: -```bash -# Quick start with Makefile -make setup # Install all dependencies -make dev # Build for development -make test # Run all tests +```python +from jacs.client import JacsClient -# Or manually: -uv venv && source .venv/bin/activate -uv pip install maturin pytest httpx httpx-sse -uv run maturin develop -uv run python -m pytest tests/ -v +client = JacsClient.quickstart(name="my-agent", domain="example.com") +signed = client.sign_message({"action": "approve"}) ``` -### Available Make Commands - -| Command | Description | -|---------|-------------| -| `make setup` | Install dev dependencies with uv | -| `make dev` | Build Rust extension for development | -| `make test` | Run all tests | -| `make check-imports` | Verify core imports (also checks optional jacs.hai / HaiClient if installed) | +See [DEVELOPMENT.md](https://github.com/HumanAssisted/JACS/blob/main/DEVELOPMENT.md) for the full API reference, advanced usage (agreements, A2A, attestation, headless loading), framework adapter examples, and testing utilities. -## Documentation +## Links -- [JACS Book](https://humanassisted.github.io/JACS/) - Full documentation (published book) -- [Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html) -- [Verification Guide](https://humanassisted.github.io/JACS/getting-started/verification.html) - CLI, standalone, DNS verification -- [API Reference](https://humanassisted.github.io/JACS/api/python) - Python API docs -- [Migration Guide](https://humanassisted.github.io/JACS/migration) - Upgrading from v0.4.x -- [Source](https://github.com/HumanAssisted/JACS) - GitHub repository +- [JACS Documentation](https://humanassisted.github.io/JACS/) +- [Verification Guide](https://humanassisted.github.io/JACS/getting-started/verification.html) +- [Framework Adapters](https://humanassisted.github.io/JACS/python/adapters.html) +- [Source](https://github.com/HumanAssisted/JACS) +- [Examples](./examples/) diff --git a/jacspy/pyproject.toml b/jacspy/pyproject.toml index 7969a1e4..6931b5cd 100644 --- a/jacspy/pyproject.toml +++ b/jacspy/pyproject.toml @@ -3,7 +3,7 @@ requires = ["maturin>=1.0,<2.0"] build-backend = "maturin" [project] name = "jacs" -version = "0.9.12" +version = "0.9.13" description = "JACS - JSON AI Communication Standard: Cryptographic signing and verification for AI agents." readme = "README.md" requires-python = ">=3.10" diff --git a/jacspy/tests/test_adapter_inventory.py b/jacspy/tests/test_adapter_inventory.py new file mode 100644 index 00000000..89095e3c --- /dev/null +++ b/jacspy/tests/test_adapter_inventory.py @@ -0,0 +1,141 @@ +""" +Adapter inventory parity test for the Python binding. + +Validates that all Python adapters listed in +binding-core/tests/fixtures/adapter_inventory.json are importable +and expose the documented public functions. + +This test complements (does not duplicate) the MCP contract drift test +or behavioral adapter tests. It validates API surface existence only. + +NOTE: Some adapter tests are skipped when optional dependencies are not +installed (e.g., langchain, crewai). In CI, install with `pip install +jacs[all]` to test all 5 adapters. The test_skip_count_guard test below +warns if too many adapters are skipped. +""" + +from __future__ import annotations + +import importlib +import json +from pathlib import Path + +import pytest + +FIXTURE_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "binding-core" + / "tests" + / "fixtures" + / "adapter_inventory.json" +) + + +@pytest.fixture(scope="module") +def adapter_inventory() -> dict: + """Load the shared adapter inventory fixture file.""" + assert FIXTURE_PATH.exists(), ( + f"Adapter inventory fixture not found at {FIXTURE_PATH}. " + "Ensure binding-core/tests/fixtures/adapter_inventory.json exists." + ) + with open(FIXTURE_PATH) as f: + return json.load(f) + + +@pytest.fixture(scope="module") +def python_adapters(adapter_inventory: dict) -> dict: + """Get the Python adapter definitions from the inventory.""" + adapters = adapter_inventory.get("adapters", {}).get("python", {}) + assert adapters, "adapter_inventory.json should have Python adapters" + return adapters + + +def test_python_adapter_count(python_adapters: dict): + """Python should have exactly 5 adapters.""" + assert len(python_adapters) == 5, ( + f"Expected 5 Python adapters, found {len(python_adapters)}. " + f"Adapters: {list(python_adapters.keys())}" + ) + + +@pytest.mark.parametrize( + "adapter_name", + ["mcp", "langchain", "crewai", "fastapi", "anthropic"], +) +def test_python_adapter_module_importable( + python_adapters: dict, adapter_name: str +): + """Each Python adapter module should be importable.""" + adapter = python_adapters[adapter_name] + module_name = adapter["module"] + + # Some adapters have optional dependencies; we test that the module + # itself exists (importlib.util.find_spec) rather than forcing import + # of all framework dependencies. + spec = importlib.util.find_spec(module_name) + assert spec is not None, ( + f"Python adapter module '{module_name}' is not importable. " + f"Ensure the module exists at the expected path." + ) + + +@pytest.mark.parametrize( + "adapter_name", + ["mcp", "langchain", "crewai", "fastapi", "anthropic"], +) +def test_python_adapter_public_functions_exist( + python_adapters: dict, adapter_name: str +): + """Each listed public function should exist in the adapter module.""" + adapter = python_adapters[adapter_name] + module_name = adapter["module"] + expected_functions = adapter["public_functions"] + + try: + mod = importlib.import_module(module_name) + except ImportError as e: + # Framework dependency not installed (e.g., langchain, crewai). + # Skip rather than fail -- the module importability test above + # already verifies the module file exists. + pytest.skip( + f"Cannot import {module_name} (missing dependency: {e}). " + f"Install optional deps to fully test." + ) + return + + missing = [] + for func_name in expected_functions: + if not hasattr(mod, func_name): + missing.append(func_name) + + assert not missing, ( + f"Adapter '{adapter_name}' ({module_name}) is missing public functions: {missing}. " + f"Expected: {expected_functions}" + ) + + +def test_skip_count_guard(python_adapters: dict): + """Warn if too many adapters are skipped due to missing dependencies. + + If more than 1 adapter cannot be imported, it likely means optional + dependencies are not installed. In CI, ensure `pip install jacs[all]` + is used to fully test all adapters. + """ + skipped = [] + for adapter_name, adapter in python_adapters.items(): + module_name = adapter["module"] + try: + importlib.import_module(module_name) + except ImportError: + skipped.append(adapter_name) + + # Allow at most 1 skip (some environments may not have one framework) + if len(skipped) > 1: + import warnings + + warnings.warn( + f"{len(skipped)} of {len(python_adapters)} Python adapters could not " + f"be imported: {skipped}. Install optional dependencies with " + f"`pip install jacs[all]` to fully test all adapters.", + stacklevel=1, + ) diff --git a/jacspy/tests/test_error_parity.py b/jacspy/tests/test_error_parity.py new file mode 100644 index 00000000..ca56b3d6 --- /dev/null +++ b/jacspy/tests/test_error_parity.py @@ -0,0 +1,247 @@ +""" +Error kind parity test for the Python binding. + +Validates that all error kinds listed in the `error_kinds` array of +binding-core/tests/fixtures/parity_inputs.json are represented in the +Python binding's error type system. + +The Rust ErrorKind enum has 13 variants. Python maps these through: +1. Custom exception classes in jacs.types (JacsError hierarchy) +2. Error message prefixes from the PyO3 native binding (RuntimeError) + +This test ensures the Python codebase recognizes all error kinds. + +KNOWN LIMITATION: Most error kinds (8 of 13) are validated structurally +only (mapping existence in ERROR_KIND_MAP), not behaviorally (actually +triggered at runtime). Only the 5 triggerable kinds are tested with +runtime assertions. The untriggerable kinds require specific states +(concurrent mutex poisoning, network calls, trust store setup, etc.) +that are impractical to set up in unit tests. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +FIXTURE_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "binding-core" + / "tests" + / "fixtures" + / "parity_inputs.json" +) + +# Mapping from Rust ErrorKind variant name to Python error representation. +# For each kind, we document: +# - python_class: The Python exception class that maps to this kind (if any) +# - message_pattern: A substring that appears in error messages for this kind +# - triggerable: Whether this error can be reliably triggered in tests +ERROR_KIND_MAP = { + "LockFailed": { + "python_class": None, # Rare mutex poisoning; no dedicated Python class + "message_pattern": "lock", + "triggerable": False, # Would require concurrent mutex poisoning + }, + "AgentLoad": { + "python_class": "ConfigError", + "message_pattern": "Failed to load agent", + "triggerable": True, + }, + "Validation": { + "python_class": None, # Generic validation; uses RuntimeError + "message_pattern": "Validation", + "triggerable": True, + }, + "SigningFailed": { + "python_class": "SigningError", + "message_pattern": "Sign", + "triggerable": True, + }, + "VerificationFailed": { + "python_class": "VerificationError", + "message_pattern": "Verification failed", + "triggerable": True, + }, + "DocumentFailed": { + "python_class": None, # Document ops; uses RuntimeError + "message_pattern": "Document", + "triggerable": False, # Requires specific document state + }, + "AgreementFailed": { + "python_class": None, # Agreement ops; uses RuntimeError + "message_pattern": "Agreement", + "triggerable": False, # Requires agreement setup + }, + "SerializationFailed": { + "python_class": None, # JSON/YAML serialization errors + "message_pattern": "Serialization", + "triggerable": True, + }, + "InvalidArgument": { + "python_class": None, # Bad input; uses RuntimeError + "message_pattern": "Invalid", + "triggerable": True, + }, + "TrustFailed": { + "python_class": "TrustError", + "message_pattern": "Trust", + "triggerable": False, # Requires trust store setup + }, + "NetworkFailed": { + "python_class": "NetworkError", + "message_pattern": "Network", + "triggerable": False, # Requires network call + }, + "KeyNotFound": { + "python_class": "KeyNotFoundError", + "message_pattern": "key", + "triggerable": False, # Requires missing key scenario + }, + "Generic": { + "python_class": "JacsError", + "message_pattern": None, # Catch-all; no specific pattern + "triggerable": False, + }, +} + + +@pytest.fixture(scope="module") +def error_kinds_from_fixture() -> list: + """Load error_kinds from the shared parity fixture.""" + assert FIXTURE_PATH.exists(), ( + f"Parity fixture not found at {FIXTURE_PATH}. " + "Ensure binding-core/tests/fixtures/parity_inputs.json exists." + ) + with open(FIXTURE_PATH) as f: + data = json.load(f) + kinds = data.get("error_kinds") + assert kinds is not None, "parity_inputs.json should contain 'error_kinds' array" + return kinds + + +def test_all_error_kinds_are_mapped(error_kinds_from_fixture: list): + """Every error kind from the fixture must have an entry in ERROR_KIND_MAP.""" + unmapped = [] + for kind in error_kinds_from_fixture: + if kind not in ERROR_KIND_MAP: + unmapped.append(kind) + + assert not unmapped, ( + f"Error kinds from fixture not mapped in Python: {unmapped}. " + "Add entries to ERROR_KIND_MAP in test_error_parity.py." + ) + + +def test_error_kind_map_has_no_stale_entries(error_kinds_from_fixture: list): + """ERROR_KIND_MAP should not contain entries not in the fixture.""" + fixture_set = set(error_kinds_from_fixture) + stale = [k for k in ERROR_KIND_MAP if k not in fixture_set] + + assert not stale, ( + f"ERROR_KIND_MAP contains stale entries not in fixture: {stale}. " + "Remove them." + ) + + +def test_error_kinds_count(error_kinds_from_fixture: list): + """There should be exactly 13 error kinds.""" + assert len(error_kinds_from_fixture) == 13, ( + f"Expected 13 error kinds, got {len(error_kinds_from_fixture)}." + ) + assert len(ERROR_KIND_MAP) == 13, ( + f"ERROR_KIND_MAP has {len(ERROR_KIND_MAP)} entries, expected 13." + ) + + +def test_python_error_classes_exist(): + """All referenced Python error classes must be importable from jacs.types.""" + from jacs.types import ( + JacsError, + ConfigError, + AgentNotLoadedError, + SigningError, + VerificationError, + TrustError, + KeyNotFoundError, + NetworkError, + ) + + # Verify the class hierarchy + assert issubclass(ConfigError, JacsError) + assert issubclass(AgentNotLoadedError, JacsError) + assert issubclass(SigningError, JacsError) + assert issubclass(VerificationError, JacsError) + assert issubclass(TrustError, JacsError) + assert issubclass(KeyNotFoundError, JacsError) + assert issubclass(NetworkError, JacsError) + + +def test_python_error_classes_match_map(): + """Python classes referenced in ERROR_KIND_MAP must exist in jacs.types.""" + import jacs.types as types_mod + + for kind, info in ERROR_KIND_MAP.items(): + class_name = info.get("python_class") + if class_name is None: + continue + assert hasattr(types_mod, class_name), ( + f"ERROR_KIND_MAP references '{class_name}' for {kind}, " + f"but jacs.types has no such class." + ) + + +# ============================================================================= +# Runtime trigger tests for triggerable error kinds +# ============================================================================= + +try: + from jacs import SimpleAgent as _SA + + _NATIVE_AVAILABLE = True +except ImportError: + _NATIVE_AVAILABLE = False + + +@pytest.mark.skipif(not _NATIVE_AVAILABLE, reason="native jacs module not built") +class TestTriggerableErrorKinds: + """Actually trigger the error kinds marked as triggerable=True.""" + + @pytest.fixture(autouse=True) + def agent(self): + self.agent, _agent_json = _SA.ephemeral("ed25519") + + def test_sign_message_handles_raw_strings(self): + """Python sign_message wraps non-JSON raw strings -- should succeed, not throw. + + This is a KNOWN behavioral difference from Node/Go (see Issue 013): + - Python sign_message takes any Python object, converts to serde_json::Value, + then serializes. A Python string becomes a valid JSON string value. + - Node signMessage takes a JSON string directly, so invalid JSON is rejected. + - Both behaviors are correct for their respective API contracts. + See parity_inputs.json 'sign_message_invalid_json_behavior' for documentation. + """ + result = self.agent.sign_message("{{{bad json") + assert isinstance(result, dict), "sign_message should return a dict" + assert "raw" in result, "result should contain 'raw' key" + # Verify the signed raw-string document round-trips + self.agent.verify(result["raw"]) + + def test_verification_failed_bad_document(self): + """VerificationFailed: not a valid signed document.""" + with pytest.raises(Exception, match=r"(?i)verif|malform"): + self.agent.verify("not a json document") + + def test_serialization_failed_on_verify(self): + """SerializationFailed: verify with non-JSON input triggers parse error.""" + with pytest.raises(Exception, match=r"(?i)malform|json|parse|key must be"): + self.agent.verify("not json at all {{{") + + def test_invalid_argument_bad_base64_key(self): + """InvalidArgument: bad base64 key for verify_with_key.""" + signed = self.agent.sign_message('{"test": 1}') + raw = signed["raw"] + with pytest.raises(Exception, match=r"(?i)invalid.*base64|base64"): + self.agent.verify_with_key(raw, "!!!notbase64!!!") diff --git a/jacspy/tests/test_method_parity.py b/jacspy/tests/test_method_parity.py new file mode 100644 index 00000000..6fddb190 --- /dev/null +++ b/jacspy/tests/test_method_parity.py @@ -0,0 +1,168 @@ +""" +Method enumeration parity test for the Python binding. + +Validates that all methods listed in +binding-core/tests/fixtures/method_parity.json are exposed in the Python +binding (via SimpleAgent PyO3 class), with documented exclusions and +name mappings. + +This is a *structural* test (method names), not a *behavioral* test. +It complements, not duplicates, test_parity.py. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +# Skip all tests if the native jacs module is not built +jacs = pytest.importorskip("jacs") + +from jacs import SimpleAgent + +FIXTURE_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "binding-core" + / "tests" + / "fixtures" + / "method_parity.json" +) + +# Methods that are intentionally Rust-only and not exposed in Python. +# Each exclusion has a comment explaining why. +EXCLUDED_FROM_PYTHON = { + # inner_ref returns a raw Rust reference; not meaningful across FFI + "inner_ref", + # from_agent wraps a Rust SimpleAgent; not callable from Python + "from_agent", + # load_with_info is an internal Rust helper; Python uses load() directly + "load_with_info", +} + +# Rust method name -> Python attribute name mapping. +# When the Python binding uses a different name than Rust, document it here. +PYTHON_NAME_MAP = { + "create": "create", + "load": "load", + "ephemeral": "ephemeral", + "create_with_params": "create_with_params", + "get_agent_id": "get_agent_id", + "key_id": "key_id", + "is_strict": "is_strict", + "config_path": "config_path", + "export_agent": "export_agent", + "get_public_key_pem": "get_public_key_pem", + "get_public_key_base64": "get_public_key_base64", + "diagnostics": "diagnostics", + "verify_self": "verify_self", + "verify_json": "verify", + "verify_with_key_json": "verify_with_key", + "verify_by_id_json": "verify_by_id", + "sign_message_json": "sign_message", + "sign_raw_bytes_base64": "sign_string", + "sign_file_json": "sign_file", + "to_yaml": "to_yaml", + "from_yaml": "from_yaml", + "to_html": "to_html", + "from_html": "from_html", +} + + +@pytest.fixture(scope="module") +def method_parity() -> dict: + """Load the shared method parity fixture file.""" + assert FIXTURE_PATH.exists(), ( + f"Method parity fixture not found at {FIXTURE_PATH}. " + "Ensure binding-core/tests/fixtures/method_parity.json exists." + ) + with open(FIXTURE_PATH) as f: + return json.load(f) + + +def test_python_method_parity_against_fixture(method_parity: dict): + """All non-excluded methods from the fixture must exist on SimpleAgent.""" + all_methods = method_parity["all_methods_flat"] + + missing = [] + for rust_name in all_methods: + if rust_name in EXCLUDED_FROM_PYTHON: + continue + + python_name = PYTHON_NAME_MAP.get(rust_name, rust_name) + if not hasattr(SimpleAgent, python_name): + missing.append(f"{rust_name} (expected as '{python_name}')") + + assert not missing, ( + f"Python SimpleAgent is missing {len(missing)} methods from method_parity.json:\n" + + "\n".join(f" - {m}" for m in missing) + + "\n\nIf a method was intentionally excluded, add it to EXCLUDED_FROM_PYTHON. " + + "If it has a different name in Python, add it to PYTHON_NAME_MAP." + ) + + +def test_python_exclusions_are_valid(method_parity: dict): + """Every excluded method must actually exist in the fixture.""" + all_methods = set(method_parity["all_methods_flat"]) + + invalid_exclusions = EXCLUDED_FROM_PYTHON - all_methods + assert not invalid_exclusions, ( + f"EXCLUDED_FROM_PYTHON contains methods not in the fixture: {invalid_exclusions}. " + "Remove stale exclusions." + ) + + +def test_python_name_map_covers_all_non_excluded(method_parity: dict): + """Every non-excluded method should have a mapping (even if identity).""" + all_methods = method_parity["all_methods_flat"] + + unmapped = [] + for rust_name in all_methods: + if rust_name in EXCLUDED_FROM_PYTHON: + continue + if rust_name not in PYTHON_NAME_MAP: + unmapped.append(rust_name) + + assert not unmapped, ( + f"Methods without PYTHON_NAME_MAP entry: {unmapped}. " + "Add a mapping (even rust_name -> rust_name if the name is the same)." + ) + + +def test_python_name_map_has_no_stale_entries(method_parity: dict): + """PYTHON_NAME_MAP should not contain methods that don't exist in the fixture.""" + all_methods = set(method_parity["all_methods_flat"]) + + stale = set(PYTHON_NAME_MAP.keys()) - all_methods + assert not stale, ( + f"PYTHON_NAME_MAP contains methods not in the fixture: {stale}. " + "Remove stale mappings." + ) + + +def test_python_exclusions_are_still_needed(): + """Check if excluded methods now exist on SimpleAgent. + + If an excluded method becomes available at runtime (e.g., after + rebuilding the native module), this test fails to prompt removal + of the exclusion. This turns the TODO in EXCLUDED_FROM_PYTHON + into an automated check. + """ + newly_available = [] + for method_name in EXCLUDED_FROM_PYTHON: + # Skip internal-only exclusions that will never appear on the class + if method_name in ("inner_ref", "from_agent", "load_with_info"): + continue + # Check if the method is now available on SimpleAgent + python_name = PYTHON_NAME_MAP.get(method_name, method_name) + if hasattr(SimpleAgent, python_name): + newly_available.append( + f"{method_name} (as '{python_name}') is now available on SimpleAgent" + ) + + assert not newly_available, ( + "The following excluded methods are now available on SimpleAgent. " + "Remove them from EXCLUDED_FROM_PYTHON and add them to PYTHON_NAME_MAP:\n" + + "\n".join(f" - {m}" for m in newly_available) + )