From 321d8780da1cdca61af74b5e112455ef569e0565 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:02:47 +0300 Subject: [PATCH 001/282] docs: add fides v2 inspection reports --- docs/inspection/agit-report.md | 306 ++++------- docs/inspection/cross-repo-primitive-map.md | 165 +++--- docs/inspection/fides-report.md | 422 ++++++---------- docs/inspection/oaps-report.md | 529 ++++---------------- docs/inspection/osp-report.md | 311 ++++-------- docs/inspection/sardis-report.md | 387 ++++---------- 6 files changed, 603 insertions(+), 1517 deletions(-) diff --git a/docs/inspection/agit-report.md b/docs/inspection/agit-report.md index 9016c16..e57e456 100644 --- a/docs/inspection/agit-report.md +++ b/docs/inspection/agit-report.md @@ -1,227 +1,97 @@ # AGIT Repository Inspection Report -## 1. Repo Purpose - -**AgentGit (agit)** is a purpose-built version control system for AI agents. It provides Git-like semantics (commit, branch, merge, diff, revert, log) over structured JSON agent state, with a high-performance Rust core, Python (PyO3) and TypeScript (napi-rs) SDKs, and integrations for 8+ agent frameworks (Claude SDK, OpenAI Agents, LangGraph, CrewAI, Google ADK, Vercel AI, MCP, Google A2A, and FIDES trust protocol). - -**Key docs read:** -- `README.md` -- `ARCHITECTURE.md` -- `agit-technical-due-diligence.md` -- `Cargo.toml` -- `pyproject.toml` - ---- - -## 2. Main Packages / Modules - -``` -/Users/efebarandurmaz/agit/ -├── crates/ -│ ├── agit-core/ # Rust VCS engine (SHA-256 DAG, Merkle diff, merge, GC, encryption) -│ ├── agit-python/ # PyO3 bindings (cdylib) -│ └── agit-node/ # napi-rs bindings (cdylib) -├── python/agit/ # Python SDK + CLI + integrations + server -│ ├── cli/app.py # Typer CLI -│ ├── engine/executor.py # ExecutionEngine wrapper -│ ├── integrations/ # FIDES, A2A, LangGraph, CrewAI, etc. -│ ├── server/ # FastAPI routes, auth, middleware, circuit breaker -│ ├── swarm/ # Multi-agent orchestrator + consensus -│ └── ui/ # Streamlit dashboards -├── ts-sdk/src/ # TypeScript SDK -│ ├── client.ts # AgitClient + PureTsRepository fallback -│ ├── types.ts # Shared TS types -│ └── integrations/ # TS hooks for Claude, OpenAI, LangGraph, A2A, FIDES, MCP, Vercel -├── web/ # Next.js 15 dashboard -├── vscode-extension/ # VS Code extension -├── examples/ # 16 runnable demo scripts -├── tests/ # Python test suite -└── docs/ # Architecture plans + markdown docs -``` - ---- - -## 3. Existing Primitives (with Exact File Paths) - -### Core VCS Primitives (Rust) - -| Primitive | File Path | Description | -|-----------|-----------|-------------| -| `Blob` / `Commit` | `crates/agit-core/src/objects.rs` | Content-addressed blob + commit struct with parent hashes forming a DAG | -| `Hash` (SHA-256) | `crates/agit-core/src/types.rs` | 64-char hex string wrapper | -| `compute_hash` / `canonical_serialize` | `crates/agit-core/src/hash.rs` | Git-style ` \0` SHA-256 hashing with deterministic JSON key sorting | -| `AgentState` / `StateDiff` / `DiffEntry` / `MergeConflict` | `crates/agit-core/src/state.rs` | Full agent state, recursive JSON diff, three-way merge | -| `MerkleNode` / `merkle_diff` | `crates/agit-core/src/state.rs` | Merkle tree over JSON subtrees for O(log N) diffing | -| `Repository` | `crates/agit-core/src/repo.rs` | Main orchestrator: commit, branch, checkout, merge, diff, revert, log, gc, retention, squash | -| `RefStore` / `Head` | `crates/agit-core/src/refs.rs` | In-memory branch + HEAD reference management | -| `StorageBackend` trait | `crates/agit-core/src/storage/mod.rs` | Pluggable async storage interface | -| `SqliteStorage` | `crates/agit-core/src/storage/sqlite.rs` | SQLite backend (WAL mode, bundled) | -| `PostgresStorage` | `crates/agit-core/src/storage/postgres.rs` | PostgreSQL backend (deadpool, multi-tenant namespacing) | -| `S3Storage` | `crates/agit-core/src/storage/s3.rs` | S3 backend (zstd compression, SQS notifications, server-side AES-256) | -| `AgitEvent` / `InMemoryEventBus` | `crates/agit-core/src/events.rs` | Append-only event log + synchronous callbacks | -| `CausalGraph` / `CausalEdge` / `CausalNode` | `crates/agit-core/src/causal.rs` | Petgraph-backed causal dependency graph across commits | -| `GuardChain` / `CommitGuard` / `GuardContext` | `crates/agit-core/src/guard.rs` | Pre-commit guard framework with Allow/Warn/Block decisions | -| `DestructiveActionGuard` | `crates/agit-core/src/guard.rs` | Blocks commits with excessive key deletion | -| `BlastRadiusGuard` | `crates/agit-core/src/guard.rs` | Blocks commits above a blast-radius risk threshold | -| `BlastRadiusReport` / `RiskLevel` | `crates/agit-core/src/blast_radius.rs` | Diff-based risk scoring (weighted added/removed/modified) | -| `ApprovalStore` / `PendingApproval` / `ApprovalStatus` | `crates/agit-core/src/approval.rs` | In-memory approval workflow for high-risk commits | -| `StateEncryptor` (AES-256-GCM + Argon2id) | `crates/agit-core/src/encryption.rs` | Field-level encryption with per-context salt derivation | -| `GcResult` / `SquashResult` / `gc` / `squash` | `crates/agit-core/src/gc.rs` | Mark-and-sweep GC + commit range squashing | -| `BisectSession` / `BisectResult` / `BisectState` | `crates/agit-core/src/bisect.rs` | Binary search through commit history to find regressions | -| `RetentionPolicy` / `RetentionResult` | `crates/agit-core/src/retention.rs` | Configurable auto-cleanup (age, count, protected branches, log pruning) | -| `Migration` / `MigrationResult` / `migrate_data` / `apply_schema_migrations` | `crates/agit-core/src/migration.rs` | Schema versioning + cross-backend data migration | -| `AgitError` enum | `crates/agit-core/src/error.rs` | Comprehensive error types via `thiserror` | -| `ActionType` / `MergeStrategy` / `ChangeType` / `ObjectType` | `crates/agit-core/src/types.rs` | Core enums | - -### Audit / Integrity Primitives - -| Primitive | File Path | Description | -|-----------|-----------|-------------| -| Hash-chained audit log | `crates/agit-core/src/repo.rs` (lines 653-698) | `compute_audit_hash` chains log entries via SHA-256 of previous integrity hash | -| `LogEntry` / `LogFilter` | `crates/agit-core/src/storage/mod.rs` | Structured audit log entries | - -### Python SDK Primitives - -| Primitive | File Path | Description | -|-----------|-----------|-------------| -| `ExecutionEngine` | `python/agit/engine/executor.py` | High-level wrapper with auto-commit, PII masking, validation, retry | -| `AgitFidesEngine` / `FidesIdentity` | `python/agit/integrations/fides.py` | DID-signed commits (Ed25519), trust-gated merge, attestation | -| `AgitA2AExecutor` / `AgitA2AClient` | `python/agit/integrations/a2a.py` | A2A protocol wrapper with branch-per-context versioning | -| CLI (`agit`) | `python/agit/cli/app.py` | Full Typer CLI (init, commit, branch, checkout, log, diff, merge, revert, status, audit, retry, gc, bisect, causal-graph, retention, squash, doctor, monitor, identity) | - -### TypeScript SDK Primitives - -| Primitive | File Path | Description | -|-----------|-----------|-------------| -| `AgitClient` | `ts-sdk/src/client.ts` | High-level client with native binding + pure-TS fallback | -| `PureTsRepository` | `ts-sdk/src/client.ts` | In-memory fallback implementing full three-way merge + BFS merge base | -| `AgitEventStream` | `ts-sdk/src/client.ts` | SSE event stream client with exponential backoff reconnect | - ---- - -## 4. Search Results for Key Terms - -| Term | Found? | Locations / Notes | -|------|--------|-------------------| -| **version control** | Yes | README, CLI help, types, docs throughout | -| **DAG** | Yes | `Commit.parent_hashes` forms DAG; `CausalGraph` uses `petgraph::DiGraph` in `crates/agit-core/src/causal.rs` | -| **Merkle** | Yes | `MerkleNode` and `merkle_diff` in `crates/agit-core/src/state.rs` (lines 192-317) | -| **merge lineage** | Partial | Merge base via BFS in `repo.rs` (L450-488); no explicit "merge lineage" index, but causal graph captures `BranchMerge` edges | -| **evidence** | No | No standalone "evidence" primitive. Closest is hash-chained audit log and FIDES signatures | -| **versioning** | Yes | README, SDKs, examples; "state versioning" is core value prop | -| **signed state** | Yes | FIDES integration signs state hash with Ed25519 in `python/agit/integrations/fides.py` | -| **content addressing** | Yes | `Blob.hash()` and `Commit.hash()` use SHA-256 over canonical serialized content in `objects.rs` + `hash.rs` | -| **hash chain** | Yes | Audit log entries chain `integrity_hash` -> `prev_integrity_hash` in `repo.rs` (L653-698) | -| **commit** | Yes | Ubiquitous; core primitive in `objects.rs`, `repo.rs`, all SDKs, CLI | -| **event** | Yes | `AgitEvent` enum in `crates/agit-core/src/events.rs`; SSE streaming in TS SDK | - ---- - -## 5. CLI Entrypoints, SDK Exports, Examples, Tests - -### CLI Entrypoint -- **Command:** `agit` -- **Entrypoint:** `python/agit/cli/app.py` -> `main()` -- **Registered in:** `pyproject.toml` line 56: `agit = "agit.cli.app:main"` - -### SDK Exports -- **Python SDK exports:** `python/agit/__init__.py` -> `ExecutionEngine`, `RetryEngine`, `ValidatorRegistry`, `PyRepository`, `PyAgentState`, etc. -- **TypeScript SDK exports:** `ts-sdk/src/index.ts` -> `AgitClient`, types, and all framework integration hooks. - -### Examples (16 runnable demos) -All located in `examples/`: -- `fides_demo.py`, `a2a_demo.py`, `langgraph_demo.py`, `claude_demo.py`, `openai_demo.py`, `crewai_demo.py`, `google_adk_demo.py`, `vercel_ai_demo.py`, `mcp_demo.py`, `swarm_demo.py`, `multi_sdk_demo.py`, `openclaw_demo.py`, `legal_review.py`, `finance_trade.py`, `health_agent.py` - -### Tests -- **Rust tests:** `cargo test --workspace` (CI runs these). Core crate has inline `#[cfg(test)]` modules in nearly every file. -- **Python tests:** `tests/` with subdirs: `cli/`, `core/`, `engine/`, `integration/`, `benchmarks/` -- **TypeScript tests:** `cd ts-sdk && npm test` (vitest) - ---- - -## 6. Schemas / Specs / CI / Config - -### CI / Config Files -- **GitHub CI:** `.github/workflows/ci.yml` - - Security scanning (TruffleHog secrets, Trivy container scan) - - Rust (fmt, clippy, test, observability feature test) - - Python (maturin build, ruff, mypy, contract checks, pytest) - - TypeScript (napi build, tsc, vitest) -- **Release workflow:** `.github/workflows/release.yml` (cosign signing, SBOM) -- **Makefile:** `Makefile` (build, test, lint, format, dev, clean) -- **Docker:** `docker/Dockerfile` -- **Rust toolchain:** `rust-toolchain.toml` - -### Schemas -- **SQLite schema:** Defined in `SqliteStorage::initialize()` at `crates/agit-core/src/storage/sqlite.rs` (lines 49-94) — tables: `objects`, `refs`, `logs`, plus indexes. -- **PostgreSQL schema:** Defined in `PostgresStorage::initialize()` at `crates/agit-core/src/storage/postgres.rs` (lines 102-147) — same tables with `BYTEA`/`JSONB` types. -- **S3 layout:** Documented in `crates/agit-core/src/storage/s3.rs` (lines 22-28) — `objects/`, `refs/`, `logs//_.json`. -- **Schema migrations:** `crates/agit-core/src/migration.rs` — currently at version 2. - ---- - -## 7. What Is Reusable - -1. **Rust core VCS primitives** (`objects.rs`, `hash.rs`, `state.rs`, `refs.rs`) — The Blob/Commit DAG, SHA-256 content addressing, Merkle diff, and three-way merge are cleanly separated from storage and can be reused as a library. -2. **`StorageBackend` trait + implementations** (`storage/mod.rs`, `sqlite.rs`, `postgres.rs`, `s3.rs`) — Well-abstracted async trait. SQLite and Postgres backends are production-ready with migrations. -3. **Merkle diff algorithm** (`state.rs`) — Novel O(log N) JSON diffing. Highly reusable for any structured-state versioning use case. -4. **Guard framework** (`guard.rs`) — Trait-based pre-commit guards with built-in destructive-action and blast-radius guards. Easy to extend. -5. **Event bus** (`events.rs`) — Simple in-process append-only event log with callbacks. Useful for any repository mutation streaming. -6. **Causal graph** (`causal.rs`) — Petgraph-based DAG analysis with root-cause tracing and critical path finding. -7. **Python `ExecutionEngine`** (`executor.py`) — Auto-commit wrapper pattern with PII masking and validation hooks. -8. **FIDES signing logic** (`fides.py`) — Ed25519 DID-signed state commits with verification. Portable to other contexts. -9. **Pure-TypeScript fallback repository** (`client.ts` lines 109-345) — Full in-memory VCS with three-way merge, useful for browser/edge environments without native binaries. +Source repo: `/Users/efebarandurmaz/agit` ---- - -## 8. What Is Missing - -1. **Distributed consensus / sharding** — Explicitly documented as a known limitation in README. No Raft/Paxos/BFT. Single-writer architecture only. -2. **Horizontal scaling** — No multi-region replication, no read replicas for Postgres backend beyond the connection pool. -3. **Prometheus / Grafana observability stack** — Rust core has feature-gated `tracing::instrument`, but no Prometheus exporter or OTel metrics. Python has stub modules for Prometheus/OTel but no active exporters wired in. -4. **OIDC / SAML SSO** — RBAC exists (`python/agit/server/auth.py`) but no SSO integration. -5. **`Arc>` optimization** — `AgentState` is cloned on every commit. The due diligence notes this as a remaining medium bottleneck for states > 100MB. -6. **S3 log pruning** — `delete_logs_before` and `prune_logs_excess` return `Ok(0)` in S3 backend (deferred to S3 lifecycle policies). -7. **CLI `squash` is a stub** — `python/agit/cli/app.py` line 567-569: it checks out the branch and commits the current state with a squash message, rather than using the Rust `gc::squash` logic. -8. **Real A2A / FIDES SDK availability** — TS `package.json` lists `@fides/sdk` and `a2a-sdk` as optional peer dependencies. These packages do not appear to be on npm (likely placeholders or private). -9. **Python stub vs native parity gaps** — `executor.py` uses many `hasattr` checks (`audit_log`, `bisect_start`, `get_causal_graph`, `preview_retention`, etc.) indicating the pure-Python stub backend lacks features present in the Rust core. -10. **No "evidence" primitive** — There is signed state (FIDES) and hash-chained audit logs, but no standalone tamper-proof "evidence" envelope or notarization primitive separate from the commit/log flow. +Note: the AGIT worktree is dirty. This report reflects current local files and is read-only evidence for FIDES v2. ---- - -## 9. Conflicts / Inconsistencies Noticed - -1. **Repository URL mismatch:** - - `README.md` line 1 CI badge points to `EfeDurmaz16/agit`. - - `pyproject.toml` lines 32-35 point to `anthropics/agit` (Homepage, Documentation, Repository, Issues). - - `ts-sdk/package.json` lines 9-11 also point to `anthropics/agit`. - - **Conflict:** The repo appears to be a fork or personal copy under `EfeDurmaz16`, but package metadata claims `anthropics` as the owner. - -2. **Due diligence claims vs. code:** - - The due diligence file claims "zero .unwrap() in production Rust code" and "constant-time HMAC comparisons." However, I did not find HMAC usage in the core — the audit chain uses plain SHA-256 concatenation, not HMAC. Also, some non-test code uses `.unwrap()` in serialization helpers (e.g., `canonical_serialize` in `hash.rs` line 39: `serde_json::to_string(value).unwrap_or_default()` — benign but not zero). - - The due diligence claims "Fernet encryption in Python fallback layer." The actual Python stubs file (`python/agit/_stubs.py`) was not fully read, but the due diligence says XOR was replaced with Fernet. If the stub file still uses a naive fallback, that would be a conflict. - -3. **S3 `delete_logs_before` / `prune_logs_excess` silently no-op:** - - These methods return `Ok(0)` without deleting anything. This is a behavioral gap that could surprise operators expecting retention policies to work on S3. - -4. **CLI `gc` command does not actually run GC when using stubs:** - - `app.py` line 367-373 just counts reachable history and prints a message. It does not invoke `engine.gc()`. - -5. **Python `PyProject.toml` `requires-python` is `>=3.12`** while the `classifiers` only list 3.12 and 3.13. This is consistent but strict; many enterprises are still on 3.11. - -6. **TS SDK `package.json` peer dependencies include `@fides/sdk` and `a2a-sdk`** with versions `>=0.1.0`. These are not real packages on npm as of this inspection. The TS integration files import from these modules but are likely stubs or aspirational. +## 1. Repo Purpose -7. **The `agit-technical-due-diligence.md` file contains speculative valuation and funding recommendations** ($16M-$24M post-money, $4M-$6M round) that appear to be generated content rather than actual investor documents. This is not a code conflict but a metadata inconsistency — the file reads like an LLM-generated investment memo embedded in the repo. +AGIT is a Git-like version-control and audit system for AI agent state: content-addressed commits, branch/merge/revert, JSON state diffing, audit logs, SDK bindings, REST API, MCP/A2A/FIDES integrations. -8. **Rust `cargo.toml` workspace does not include `crates/agit-node` in the default members** but `Cargo.toml` at root lists it in `members`. The `agit-node` crate has a `package.json` inside it (`crates/agit-node/package.json`) suggesting it is built with `napi-rs` and npm, not pure Cargo. This is a hybrid build setup that requires both Rust and Node toolchains. +Local evidence: ---- +- Project docs and repo purpose: `/Users/efebarandurmaz/agit/README.md`, `/Users/efebarandurmaz/agit/ARCHITECTURE.md`. +- Multi-language package roots: `/Users/efebarandurmaz/agit/Cargo.toml`, `/Users/efebarandurmaz/agit/pyproject.toml`, `/Users/efebarandurmaz/agit/ts-sdk/package.json`. -## 10. Recommended Action for FIDES v2 +## 2. Main Packages / Modules -1. **Reuse AGIT hash-chain semantics** for FIDES evidence ledger — the `compute_audit_hash` pattern chaining `prev_integrity_hash` is directly portable. -2. **Reuse canonical JSON + SHA-256 content addressing** patterns from `crates/agit-core/src/hash.rs` for canonical object signing in FIDES. -3. **Reuse Guard framework concept** (Allow/Warn/Block) as inspiration for FIDES policy engine guards, especially for high-risk action handling. -4. **Reuse ApprovalStore pattern** for FIDES ApprovalRequest/ApprovalDecision primitives. -5. **Do NOT import AGIT as a runtime dependency** for FIDES v2. Instead, port the concepts into TypeScript. A future Rust adapter can bridge to `agit-core` for performance-critical workloads. -6. **AGIT's Merkle diff is too specific to VCS** for direct reuse in FIDES, but the Merkle tree construction pattern (`MerkleNode`) is reusable for evidence verification. +| Area | Path | Purpose | +|---|---|---| +| Rust core | `/Users/efebarandurmaz/agit/crates/agit-core/src/lib.rs` | Repository orchestration, objects, refs, state diff/merge, hashing, storage, guards, approvals, events, causal graph, encryption. | +| Python SDK/CLI/server | `/Users/efebarandurmaz/agit/python/agit/engine/executor.py`, `/Users/efebarandurmaz/agit/python/agit/cli/app.py`, `/Users/efebarandurmaz/agit/python/agit/server/routes.py` | Execution engine, Typer CLI, FastAPI server. | +| TypeScript SDK | `/Users/efebarandurmaz/agit/ts-sdk/src/index.ts`, `/Users/efebarandurmaz/agit/ts-sdk/src/client.ts`, `/Users/efebarandurmaz/agit/ts-sdk/src/types.ts` | TS client and integration exports. | +| Native bindings | `/Users/efebarandurmaz/agit/crates/agit-python/Cargo.toml`, `/Users/efebarandurmaz/agit/crates/agit-node/Cargo.toml` | PyO3 and napi-rs packaging. | +| CI | `/Users/efebarandurmaz/agit/.github/workflows/ci.yml` | Rust, Python, TS, and security checks. | + +## 3. Existing Primitives + +- Identity / DID / Ed25519 / signatures exist only in adapters, not AGIT core. Python FIDES integration generates `did:fides:`, signs state hashes with PyNaCl, and verifies signatures in `/Users/efebarandurmaz/agit/python/agit/integrations/fides.py`. +- TypeScript FIDES integration delegates identity/signing/verification/reputation to external `@fides/sdk`: `/Users/efebarandurmaz/agit/ts-sdk/src/integrations/fides.ts`, `/Users/efebarandurmaz/agit/ts-sdk/package.json`. +- Agent state exists as a core primitive in `/Users/efebarandurmaz/agit/crates/agit-core/src/state.rs`. +- Evidence/event/audit-like primitives exist as commits, storage logs, event bus, and server event bus: `/Users/efebarandurmaz/agit/crates/agit-core/src/objects.rs`, `/Users/efebarandurmaz/agit/crates/agit-core/src/storage/mod.rs`, `/Users/efebarandurmaz/agit/crates/agit-core/src/events.rs`, `/Users/efebarandurmaz/agit/python/agit/server/event_bus.py`. +- Hash/canonical/Merkle-adjacent primitives exist: canonical JSON serialization, SHA-256 object hashing, state hash, and Merkle tree diff in `/Users/efebarandurmaz/agit/crates/agit-core/src/hash.rs` and `/Users/efebarandurmaz/agit/crates/agit-core/src/state.rs`. +- Trust/reputation/attestation are adapter calls and committed audit payloads, not AGIT-native protocol objects: `/Users/efebarandurmaz/agit/python/agit/integrations/fides.py`, `/Users/efebarandurmaz/agit/ts-sdk/src/integrations/fides.ts`. +- Policy/approval/guardrails partially exist as commit guards, blast-radius analysis, pending approvals, validators, and safety docs: `/Users/efebarandurmaz/agit/crates/agit-core/src/guard.rs`, `/Users/efebarandurmaz/agit/crates/agit-core/src/blast_radius.rs`, `/Users/efebarandurmaz/agit/crates/agit-core/src/approval.rs`, `/Users/efebarandurmaz/agit/python/agit/engine/validator.py`, `/Users/efebarandurmaz/agit/docs/plans/2026-03-11-safety-layer-design.md`. +- Graph primitive is a commit causal graph, not a trust graph: `/Users/efebarandurmaz/agit/crates/agit-core/src/causal.rs`. +- MCP and A2A integrations exist: `/Users/efebarandurmaz/agit/python/agit/integrations/mcp_server.py`, `/Users/efebarandurmaz/agit/tests/integration/test_mcp_server.py`, `/Users/efebarandurmaz/agit/python/agit/integrations/a2a.py`, `/Users/efebarandurmaz/agit/ts-sdk/src/integrations/a2a.ts`. + +## 4. Relevant Files + +- `/Users/efebarandurmaz/agit/crates/agit-core/src/hash.rs` +- `/Users/efebarandurmaz/agit/crates/agit-core/src/objects.rs` +- `/Users/efebarandurmaz/agit/crates/agit-core/src/state.rs` +- `/Users/efebarandurmaz/agit/crates/agit-core/src/events.rs` +- `/Users/efebarandurmaz/agit/crates/agit-core/src/storage/mod.rs` +- `/Users/efebarandurmaz/agit/crates/agit-core/src/guard.rs` +- `/Users/efebarandurmaz/agit/crates/agit-core/src/blast_radius.rs` +- `/Users/efebarandurmaz/agit/crates/agit-core/src/approval.rs` +- `/Users/efebarandurmaz/agit/crates/agit-core/src/causal.rs` +- `/Users/efebarandurmaz/agit/python/agit/integrations/fides.py` +- `/Users/efebarandurmaz/agit/ts-sdk/src/integrations/fides.ts` +- `/Users/efebarandurmaz/agit/python/agit/integrations/mcp_server.py` +- `/Users/efebarandurmaz/agit/python/agit/integrations/a2a.py` + +## 5. Reusable Components + +- Deterministic canonical hashing and content-addressed object model from `/Users/efebarandurmaz/agit/crates/agit-core/src/hash.rs` and `/Users/efebarandurmaz/agit/crates/agit-core/src/objects.rs`. +- Append-style audit/event surfaces and storage abstraction from `/Users/efebarandurmaz/agit/crates/agit-core/src/storage/mod.rs` and `/Users/efebarandurmaz/agit/crates/agit-core/src/events.rs`. +- Guard chain and blast-radius scoring as prior art for pre-action policy enforcement: `/Users/efebarandurmaz/agit/crates/agit-core/src/guard.rs`, `/Users/efebarandurmaz/agit/crates/agit-core/src/blast_radius.rs`. +- Approval request model from `/Users/efebarandurmaz/agit/crates/agit-core/src/approval.rs`, with a durability caveat. +- Causal graph for future evidence/root-cause analysis: `/Users/efebarandurmaz/agit/crates/agit-core/src/causal.rs`. + +## 6. Missing Components + +I could not find core implementations for: + +- Revocation records. +- Incident records. +- TEE/runtime attestations. +- DHT, relay, federation, or well-known discovery. +- AP2, x402, TAP, mandate-chain, delegation token, session authority, grants. +- Registry/capability registry. +- Provision/rotate/deprovision lifecycle. +- Privacy-preserving evidence. +- Canonical FIDES v2 schemas. + +The closest matches are generic REST schemas in `/Users/efebarandurmaz/agit/python/agit/server/models.py`, FIDES adapters in `/Users/efebarandurmaz/agit/python/agit/integrations/fides.py` and `/Users/efebarandurmaz/agit/ts-sdk/src/integrations/fides.ts`, and safety primitives in `/Users/efebarandurmaz/agit/crates/agit-core/src/guard.rs`. + +## 7. Conflicts With FIDES v2 Architecture + +- FIDES claims in AGIT docs are stronger than AGIT core implementation: DID signing and trust are adapter-level, not Rust-core verifiable protocol primitives. +- Python and TypeScript FIDES signing models differ: Python signs a SHA-256 state hash directly; TypeScript delegates to external FIDES request signing. +- Approval storage is not durable enough as-is for FIDES v2. +- AGIT is a VCS/evidence substrate, not the canonical identity/trust/authority runtime. + +## 8. Recommended Action + +Use AGIT as a Rust adapter-ready evidence and lineage substrate, not as the FIDES v2 primitive source of truth. Port concepts, not dependencies: + +- canonical hashing, +- content-addressed objects, +- commit/state lineage, +- Merkle/state diff concepts, +- event/audit surfaces, +- guard/blast-radius ideas, +- causal graph. + +Do not import AGIT's current FIDES adapters as protocol canon. FIDES must own the identity, signing envelope, revocation, delegation/session, evidence, and trust graph types. diff --git a/docs/inspection/cross-repo-primitive-map.md b/docs/inspection/cross-repo-primitive-map.md index b222990..a8ea2df 100644 --- a/docs/inspection/cross-repo-primitive-map.md +++ b/docs/inspection/cross-repo-primitive-map.md @@ -1,98 +1,93 @@ # Cross-Repo Primitive Map -This document maps key primitives across the five inspected repositories and determines the best source and action for each primitive in FIDES v2. +This map reflects the current local inspection of: -## Architecture Decisions (Context) +- FIDES: `/Users/efebarandurmaz/fides` +- AGIT: `/Users/efebarandurmaz/agit` +- OSP: `/Users/efebarandurmaz/osp` +- OAPS: `/Users/efebarandurmaz/OAPS` +- Sardis: `/Users/efebarandurmaz/sardis` -Before reading this map, the following decisions have been made: +## Hard Architecture Decisions -1. **TS-first, Rust adapter-ready.** FIDES v2 is implemented in TypeScript/Node. AGIT's Rust core may be used later through adapters for evidence chain, hashing, canonicalization, or other performance-critical primitives. -2. **OAPS concepts are ported into FIDES, not imported as a runtime dependency.** OAPS remains the spec/source of semantic compatibility. FIDES owns the runtime types. -3. **Sardis contributes generic patterns only:** policy-before-execution, guardrails, evidence ledger, approvals, kill switch, high-risk action handling. Sardis payment-specific domain models stay in Sardis: stablecoins, MPC wallets, payment rails, merchants, compliance, spending limits. -4. **FIDES owns the generic authority/trust/evidence layer.** Sardis owns the payment-specific authority model. -5. **Effect may be used for internal runtime orchestration** (service layers, typed errors, workflows, discovery/provider orchestration, daemon, CLI workflows, DHT/relay/registry orchestration). -6. **Protocol objects, crypto, canonical JSON, signing primitives, and public schemas must remain framework-agnostic.** -7. **Public SDK should expose Promise-based APIs, with optional Effect-native APIs later.** - ---- +- FIDES v2 is TS-first and Rust adapter-ready. +- OAPS concepts are ported into FIDES. FIDES must not depend on `@oaps/core` at runtime. +- Sardis contributes generic authority patterns only; payment-specific models stay in Sardis. +- Effect may be used internally later, but protocol objects, signing, schemas, AgentCards, evidence events, DHT records, session grants, attestations, revocations, and incidents stay framework-agnostic. +- Public SDK remains Promise-based. ## Primitive Map | Primitive | FIDES | AGIT | OSP | OAPS | Sardis | Best source | Action | -|-----------|-------|------|-----|------|--------|-------------|--------| -| **Agent identity** | `did:fides:` (`packages/sdk/src/identity/did.ts`) | FIDES integration | None | `ActorRef` (`packages/core/src/index.ts:36`) | `KYA` (`packages/sardis-compliance/src/sardis_compliance/kya.py`) | FIDES | Extend with publisher + principal identity | -| **Publisher identity** | Not found | Not found | Not found | `ActorCard.publisher` (conceptual) | Not found | OAPS concept | Create new in FIDES | -| **Principal identity** | Not found | Not found | `principal_id` (spec only) | `ActorRef` | Embedded in mandates | OAPS concept | Create new in FIDES | -| **DID / key format** | `did:fides:` | `FidesIdentity` | None | `actor_id` string | None | FIDES | Reuse, document W3C non-compliance | -| **Signing** | RFC 9421 + Ed25519 (`packages/sdk/src/signing/`) | Ed25519 DID-signed commits | Ed25519 (`osp-crypto`) | `Proof`, canonical JSON | Ed25519 policy attestation | FIDES | Reuse, extend for new object types | -| **HTTP message signatures** | Full implementation (`packages/sdk/src/signing/`) | Not found | Not found | Not found | Not found | FIDES | Reuse | -| **Trust attestation** | `createAttestation` (`packages/sdk/src/trust/attestation.ts`) | Trust-gated merge | Not found | `trust-attestation.json` schema | Not found | FIDES | Extend with capability-scoped attestations | -| **Trust graph** | BFS + scoring (`services/trust-graph/src/services/`) | Not found | Not found | Not found | Not found | FIDES | Extend with context-specific trust | -| **Reputation** | Direct + transitive (`services/trust-graph/src/services/scoring.ts`) | Not found | Not found | Not found | Not found | FIDES | Extend with capability-specific reputation | -| **Capability descriptor** | Basic in discovery (`packages/sdk/src/discovery/agent-client.ts`) | Not found | `ServiceOffering` (schema) | `CapabilityCard` (`packages/core/src/index.ts:81`) | Not found | OAPS | Port into FIDES | -| **Agent card** | `AgentCard` (`packages/shared/src/types.ts`) | Not found | `ServiceManifest` | `ActorCard` (`packages/core/src/index.ts:68`) | Agent cards in `sardis-a2a` | OAPS + FIDES | Merge concepts, port into FIDES | -| **Discovery** | Discovery service + well-known (`services/discovery/`) | Not found | `discover()` methods | `.well-known/aicp.json` | Not found | FIDES + OAPS | Extend FIDES with OAPS discovery patterns | -| **Well-known discovery** | `/.well-known/fides.json` | Not found | Not found | `/.well-known/aicp.json` | Not found | FIDES + OAPS | Merge both paths into FIDES | -| **Hosted registry** | Not found | Not found | `osp-registry` (Axum, SQLite) | Not found | Not found | OSP concept | Port concept into FIDES (TypeScript/Hono) | -| **Public registry API** | Not found | Not found | Registry routes (`osp-registry/src/routes.rs`) | Not found | Not found | OSP concept | Create new in FIDES | -| **Private registry mode** | Not found | Not found | Not found | Not found | Not found | None | Create new in FIDES | -| **Relay-based discovery** | Not found | Not found | Not found | Not found | Not found | None | Create new in FIDES | -| **DHT-based discovery** | Not found | Not found | Not found | Not found | Not found | None | Create new in FIDES | -| **Federation-ready registry peering** | Not found | Not found | Not found | Not found | Not found | None | Create new in FIDES | -| **Delegation token** | `DelegationToken` (`packages/core/src/delegation.ts`) | Not found | Not found | `DelegationToken` (`packages/core/src/index.ts:117`) | Embedded in `mandate_tree.py` | FIDES + OAPS | Harden delegation propagation and conformance | -| **Session grant** | `SessionGrant` plus stores (`packages/core/src/delegation.ts`, `packages/core/src/session-store.ts`) | Not found | Not found | Not found | Not found | FIDES | Harden durable storage and auth boundaries | -| **Policy bundle** | `PolicyBundle` (`packages/policy/src/index.ts`) plus standalone service (`services/policy-engine/src/index.ts`) | `GuardChain` (`guard.rs`) | Not found | `PolicyBundle` (`packages/policy/src/index.ts:24`) | `policy_dsl.py` | FIDES + OAPS | Harden FIDES policy persistence, approvals, and conformance | -| **Policy engine** | Implemented evaluator and HTTP route (`packages/policy/src/index.ts`, `services/policy-engine/src/index.ts`) | `GuardChain`, `ApprovalStore` | Not found | `evaluatePolicy()` (`packages/policy/src/index.ts:162`) | `pre_execution_pipeline.py` | FIDES + Sardis patterns | Add production adapters and Sardis-style pre-execution pipeline integration | -| **Intent** | Not found | Not found | Not found | `Intent` (`packages/core/src/index.ts:93`) | `mandates.py` | OAPS | Port into FIDES | -| **Evidence event** | `EvidenceEvent` and hash-chain verifier (`packages/evidence/src/index.ts`) | Hash-chained audit log (`repo.rs:653-698`) | Not found | `EvidenceEvent` (`packages/core/src/index.ts:272`) + `EvidenceChain` | `policy_evidence.py`, ledger | FIDES + OAPS + AGIT | Harden persistence, anchoring, and cross-service use | -| **Hash chain** | SHA-256 for Content-Digest | `compute_audit_hash` (`repo.rs:653-698`) | Not found | `EvidenceChain` (`packages/evidence/src/index.ts`) | Merkle ledger | AGIT + OAPS | Merge AGIT chaining + OAPS schema | -| **Merkle proof** | Not found | `MerkleNode` (`state.rs:192-317`) | Not found | Not found | `merkle_tree.py` | AGIT + Sardis | Port concept for evidence verification | -| **Revocation** | `createRevocation` (`rotation.ts`) — data only | Not found | Not found | Not found | Not found | FIDES | Extend into active revocation system | -| **Incident** | Not found | Not found | Not found | Not found | Not found | None | Create new in FIDES | -| **Runtime attestation** | Not found | Not found | Not found | Not found | Not found | None | Create new in FIDES | -| **TEE** | Not found | Not found | Not found | Not found | Not found | None | Create new in FIDES | -| **Approval workflow** | Not found | `ApprovalStore` (`approval.rs`) | Not found | `ApprovalRequest`/`ApprovalDecision` (`packages/core/src/index.ts:141-154`) | `approval_service.py` | OAPS + AGIT | Port OAPS primitives + AGIT guard pattern | -| **Kill switch** | Not found | Not found | Not found | Not found | `kill_switch.py` | Sardis | Port concept, genericize in FIDES | -| **Service lifecycle** | Not found | Not found | `ProvisionRequest`/`ProvisionResponse` | Not found | Not found | OSP concept | Port concept for agent lifecycle | -| **Provisioning** | Not found | Not found | `provision()` (SDKs) | Not found | Not found | OSP concept | Port concept for agent registration | -| **Rotation** | `rotateKey` (DID-changing) | Not found | `rotateCredentials()` | Not found | Not found | FIDES + OSP | Extend FIDES key rotation + credential rotation | -| **Deprovisioning** | Not found | Not found | `deprovision()` (SDKs) | Not found | Not found | OSP concept | Port concept for agent deregistration | -| **CLI** | `fides` CLI (`packages/cli/`) | `agit` CLI (`python/agit/cli/app.py`) | `osp` CLI (mostly stubs) | Python conformance CLI | `sardis` CLI (Python) | FIDES | Extend FIDES CLI | -| **SDK** | `@fides/sdk` (`packages/sdk/`) | Python + TS SDKs | `@osp/client`, Go SDK | `@oaps/core` etc. | `@sardis/sdk`, `sardis-sdk-python` | FIDES | Extend FIDES SDK | -| **Examples** | Limited | 16 demos | YAML examples | 100+ JSON payloads | Many Python/TS demos | FIDES + all | Create new FIDES examples | -| **Tests** | Good coverage (`packages/sdk/test/`, `services/*/test/`) | Rust + Python + TS tests | Conformance tests (Python) | Node built-in test runner | 208 test files | FIDES | Extend FIDES test suite | -| **Docs** | `docs/` (architecture, protocol spec) | `ARCHITECTURE.md`, `docs/` | `spec/`, `docs/` | `spec/`, `SPEC.md` | `docs-site/`, business docs | FIDES + OAPS spec | Extend FIDES docs | -| **Canonical object signing** | Partial (HTTP signatures) | `canonical_serialize` (`hash.rs`) | `canonicalJson` (`crypto.ts`) | `canonicalJson` (`core/src/index.ts`) | Not found | AGIT + OAPS | Create unified canonical signing model | -| **Version negotiation** | Not found | Not found | Not found | `negotiateVersion` (`core/src/index.ts:512`) | Not found | OAPS | Port into FIDES | -| **Typed error vocabulary** | `FidesError` hierarchy (`packages/shared/src/errors.ts`) | `AgitError` (`error.rs`) | `ErrorResponse` schema | `ErrorObject` (`core/src/index.ts:253`) | Exception hierarchy | FIDES + OAPS | Extend FIDES errors with OAPS categories | -| **Privacy model for evidence** | Not found | Not found | Not found | Not found | Not found | None | Create new in FIDES | -| **Trust explainability** | Not found | Not found | Not found | Not found | Not found | None | Create new in FIDES | -| **Adversarial simulation** | Not found | Not found | Not found | Not found | Not found | None | Create new in FIDES | -| **Capability ontology** | Not found | Not found | `ServiceManifest` taxonomy | `CapabilityCard` | Not found | OAPS | Port into FIDES | -| **Risk taxonomy** | Not found | `BlastRadiusReport` / `RiskLevel` | Not found | `compareRiskClass` | Anomaly engine | AGIT + Sardis | Port concepts into FIDES | -| **Adapter interfaces** | A2A converter (`packages/sdk/src/discovery/a2a.ts`) | FIDES, A2A, MCP, LangGraph integrations | MCP server | MCP, A2A, x402, auth-web adapters | A2A, MCP, protocol verifiers | All | Create unified adapter framework in FIDES | - ---- - -## Action Legend +|----------|-------|------|-----|------|--------|-------------|--------| +| Agent identity | `packages/core/src/identity.ts`, `packages/shared/src/types.ts` | Adapter only: `python/agit/integrations/fides.py` | `osp-manifest/src/types.rs` | `ActorRef`/`ActorCard` | `fides_did.py`, `identity.py` | FIDES | extend existing | +| Publisher identity | `packages/core/src/identity.ts` | not found | provider identity adjacent | ActorCard publisher semantics | publisher mixed with payment/API context | FIDES + OAPS | extend existing | +| Principal identity | `packages/core/src/identity.ts` | not found | service principal adjacent | `ActorRef`, mandate principal | payment mandates/principals | FIDES + OAPS | extend existing | +| DID/key format | `did:fides`, canonical signer | adapter-level FIDES DID | Ed25519 DID methods | DID as actor profile, no verifier | `fides_did.py` | FIDES | reuse existing | +| Signing | `canonical-signer.ts`, SDK signing | canonical hashing, adapter signatures | `osp-crypto` | generic proof/HMAC | attestation envelope | FIDES | extend existing | +| HTTP message signatures | SDK signing package | not core | not primary | documented gap | not generic | FIDES | reuse existing | +| Canonical object signing | `packages/core/src/canonical-signer.ts` | `crates/agit-core/src/hash.rs` | `osp-crypto/src/canonical.rs` | core canonical JSON/hash | attestation envelope | FIDES + AGIT/OAPS prior art | extend existing | +| Trust attestation | trust graph/service | adapter-only | trust tier/reputation metadata | profile draft | TrustFramework | FIDES | extend existing | +| Trust graph | `services/trust-graph` | causal graph only | not found | not found | FIDES adapter/trust infra | FIDES | extend existing | +| Reputation | capability scoring partial | not found | registry reputation metadata | not found | KYA/payment reputation | FIDES | extend existing | +| Capability descriptor | `packages/core/src/capability.ts` | not found | service/capability manifest | CapabilityCard | agent auth/A2A/payment capabilities | OAPS + FIDES | extend existing | +| Capability ontology | heuristic only | blast radius/risk | service taxonomy | capability schemas/constants | risk/action patterns | OAPS | create new | +| AgentCard / ActorCard | `packages/core/src/agent-card.ts`, shared AgentCard | not found | service manifest | ActorCard | A2A AgentCard | FIDES + OAPS | extend existing | +| Discovery | provider package/services | adapter only | service discovery | actor discovery | A2A/agent auth | FIDES | extend existing | +| Well-known discovery | discovery service/provider | not found | manifest fetch | `.well-known/oaps.json` | agent auth/A2A well-known | FIDES | extend existing | +| Registry | `services/registry` | not found | `osp-registry` | not broad runtime | not generic | FIDES + OSP | extend existing | +| Relay | `services/relay`, relay provider | not found | not found | not found | not found | FIDES | extend existing | +| DHT | in-memory direct-card provider | not found | not found | not found | not found | FIDES | extend existing | +| Federation | partial/spec only | not found | registry concepts | profile notes only | not found | FIDES + OSP | create new | +| DelegationToken | `packages/core/src/delegation.ts` | not found | delegation chain structs | `DelegationToken` | mandates | FIDES + OAPS | extend existing | +| SessionGrant | `packages/core/src/delegation.ts`, session store | not found | not found | auth-web session adjacent | grant/session-like agent auth | FIDES | extend existing | +| PolicyBundle | `packages/policy` | guard chain | not core | policy package | policy DSL/pipeline | FIDES + OAPS | extend existing | +| Policy engine | `packages/policy`, `services/policy-engine`, guard | guards/blast radius | not core | fail-closed evaluator | pre-execution pipeline | FIDES + Sardis | extend existing | +| Intent | not first-class | not found | not found | foundation intent | AP2/payment intents | OAPS | create new | +| ApprovalRequest | missing first-class core | `approval.rs` | HITL spec | core approvals | approval flow | OAPS + Sardis | create new | +| ApprovalDecision | missing first-class core | `approval.rs` | HITL spec | core approvals | approval flow | OAPS + Sardis | create new | +| EvidenceEvent | `packages/evidence` | commits/events/audit | webhook events | hash-linked evidence | evidence export/hash-chain | FIDES + OAPS + AGIT | extend existing | +| Hash chain | `packages/evidence` | strong lineage/hash prior art | not generic | evidence package | policy hash-chain | FIDES + AGIT | extend existing | +| Merkle proof | Merkle root only | Merkle/state diff concepts | not found | not found | ledger anchor | AGIT + Sardis | create new | +| Revocation | `packages/core/src/revocation.ts`, services | not core | docs/spec | revoke flow | identity/payment revocation | FIDES | extend existing | +| Incident | `packages/core/src/revocation.ts`, services | not core | not found | not found | payment/trust context | FIDES | extend existing | +| Runtime attestation | `packages/runtime` | not found | not found | not found | not generic | FIDES | extend existing | +| TEE | Mock/HTTP adapter boundary | not found | not found | not found | not found | FIDES | adapter-ready | +| Privacy/redaction | evidence privacy modes | not found | credential encryption | profile/spec only | payment privacy primitives | FIDES + Sardis prior art | extend existing | +| Error vocabulary | broad error classes | `AgitError` | error responses | error taxonomy | exception/reason codes | FIDES + OAPS | extend existing | +| Version negotiation | missing/partial | not found | version fields | negotiateVersion | version fields | OAPS | create new | +| Kill switch | `packages/runtime`, CLI/agentd | not found | not found | revoke/fail-closed only | payment kill switch | FIDES + Sardis | extend existing | +| Guardrails | guard/policy | guard chain/blast radius | not core | policy/approval | pre-execution pipeline | FIDES + Sardis + AGIT | extend existing | +| Service lifecycle | agentd/services | not found | discover/provision/rotate/deprovision | not core | project provisioning payment-adjacent | OSP | adapter-ready | +| Provisioning | not generic | not found | core OSP primitive | not core | payment/project-specific | OSP | adapter-ready | +| Rotation | identity/session/revocation partial | not core | rotate credentials | not core | identity/payment rotation | FIDES + OSP | extend existing | +| Deprovisioning | deregister/revoke partial | not found | core OSP primitive | revoke flows | payment/project-specific | OSP | adapter-ready | +| CLI | `packages/cli`, binary `fides` | Python CLI | Rust/TS tools | CLI refs | several CLIs | FIDES | extend existing | +| SDK | `packages/sdk` | TS/Python SDKs | TS/Python/Go/Rust | TS reference packages | TS/Python SDKs | FIDES | extend existing | +| Examples | `examples/` | demos | examples | many fixtures/examples | many demos | FIDES | extend existing | +| Tests | package/service/e2e/adversarial | Rust/Python/TS tests | conformance | reference tests | large suite | FIDES | extend existing | +| Docs | docs present but stale | docs | spec/docs | spec/schemas | docs/site | FIDES | extend existing | +| MCP adapter | not first-class FIDES adapter | exists | MCP server | MCP adapter | MCP server | OAPS + Sardis/OSP | adapter-ready | +| A2A adapter | shared/SDK legacy shapes | exists | A2A adjacent | A2A adapter | A2A resources/routes | FIDES + OAPS/Sardis | adapter-ready | +| OAPS adapter | not found | not found | not found | source spec | not found | FIDES | create new | +| OSP adapter | not found | not found | source spec | not found | not found | FIDES + OSP | adapter-ready | +| AP2 adapter | not found | not found | not core | payment profile | AP2 verifier/mandates | Sardis | adapter-ready | +| x402 adapter | not found | not found | not core | x402 adapter | x402 facilitator | Sardis/OAPS | adapter-ready | +| Sardis adapter | not found | FIDES adapter to AGIT | Sardis integration | profile relation | source consumer | FIDES + Sardis | create new | -| Action | Meaning | -|--------|---------| -| **Reuse existing** | Use FIDES implementation as-is or with minor tweaks | -| **Extend existing** | Build on top of FIDES implementation, add new features | -| **Port from another repo** | Take concept/type/algorithm from another repo, reimplement in FIDES namespace | -| **Create new** | No suitable source found; build from scratch | -| **Leave as adapter-ready** | Define interface, expect external implementation | -| **Leave as spec-complete** | Document the interface/protocol, no implementation yet | +## Key Findings ---- +- FIDES already contains the most complete local runtime for this pivot, but it is not yet coherent enough to call FIDES v2 complete. +- OAPS is the best semantic source for actor/delegation/mandate/approval/evidence/version/error concepts, but it is not a high-assurance trust runtime. +- AGIT is useful for evidence lineage, hashing, state history, Merkle/diff concepts, and causal graph ideas. +- OSP is useful for service lifecycle, registry, provisioning, rotation, deprovisioning, and provider/MCP integration semantics. +- Sardis is useful for generic authority patterns but must remain payment-specific for actual payment execution. -## Recommended Priority Order +## Recommended Implementation Bias -1. **Reuse FIDES identity + signing** — solid foundation -2. **Port OAPS core primitives** — DelegationToken, PolicyBundle, EvidenceEvent, ActorCard, CapabilityCard, ApprovalRequest/Decision -3. **Port AGIT hash-chain semantics** — for evidence ledger integrity -4. **Port Sardis patterns** — pre-execution pipeline, kill switch, approval flow -5. **Port OSP concepts** — registry pattern, service lifecycle, credential rotation -6. **Create new** — session grants, runtime attestation, TEE, DHT/relay/federation, incidents, adversarial simulation -7. **Leave as adapter-ready** — MCP, A2A, x402, AP2, OSP, Sardis runtime adapters +1. Reuse and harden FIDES packages first. +2. Port OAPS semantics into FIDES-owned types. +3. Use AGIT as Rust adapter-ready prior art for evidence hashing/lineage. +4. Use OSP for adapter semantics and lifecycle mapping only. +5. Use Sardis for policy-before-execution, approvals, kill switch, evidence, high-risk action handling, and mandate-chain abstractions only. +6. Keep DHT/relay/registry as discovery signals, never authority. diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index aff5341..f255c97 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -1,289 +1,153 @@ # FIDES Repository Inspection Report -> Historical baseline note: this report started as an early repository inspection. The sections below are preserved as audit context, but the implementation has moved since the original snapshot. Current evidence-based status is maintained in `docs/architecture/gap-analysis.md`; stale stub claims in this report have been corrected where they conflicted with landed code. +Status: current local inspection on branch `fides-v2-agent-trust-fabric`, fast-forwarded to local `main` at `7e52774`. ## 1. Repo Purpose -**FIDES** (Latin: trust, faith, confidence) is a decentralized trust and authentication protocol for autonomous AI agents. It provides: +FIDES is a TypeScript/Node monorepo for verifiable identity, authority, pre-execution trust controls, evidence, discovery, runtime attestation, and daemon/service APIs for AI agents. -- Cryptographic identity management (Ed25519 keypairs + custom DIDs) -- RFC 9421 HTTP Message Signatures for request authentication -- Distributed trust attestations and reputation scoring -- Agent discovery with A2A (Agent-to-Agent) protocol compatibility +Local evidence: -**Tech stack:** TypeScript/Node.js monorepo (pnpm + Turbo), Hono web framework, Drizzle ORM, PostgreSQL, @noble/ed25519 for pure-JS cryptography. - ---- +- Root package metadata describes FIDES as a decentralized trust and authentication protocol for AI agents: `package.json`. +- README frames FIDES as an agent trust fabric with signed identity, delegation, policy guards, tamper-evident evidence, runtime attestation, and kill switches: `README.md`. +- Workspace layout uses pnpm and Turbo: `pnpm-workspace.yaml`, `turbo.json`. ## 2. Main Packages / Modules -### Packages (`/Users/efebarandurmaz/fides/packages/`) - -| Package | Path | Purpose | -|---------|------|---------| -| `@fides/sdk` | `packages/sdk/` | Core protocol implementation (identity, signing, trust, discovery) | -| `@fides/shared` | `packages/shared/` | Shared types, constants, errors | -| `@fides/cli` | `packages/cli/` | Command-line interface (`fides init`, `sign`, `verify`, `trust`, `discover`, `status`) | -| `rust-sdk` | `packages/rust-sdk/` | Stub — only contains README.md | - -### Services (`/Users/efebarandurmaz/fides/services/`) - -| Service | Path | Purpose | -|---------|------|---------| -| `discovery` | `services/discovery/` | Identity registration & resolution service (Hono + PostgreSQL) | -| `trust-graph` | `services/trust-graph/` | Trust relationship management & reputation scoring service | -| `platform-api` | `services/platform-api/` | Platform metadata API with health, version, and topology routes | -| `policy-engine` | `services/policy-engine/` | Standalone policy evaluation service backed by `@fides/policy` | - -### Apps (`/Users/efebarandurmaz/fides/apps/`) - -| App | Path | Purpose | -|-----|------|---------| -| `web` | `apps/web/` | Stub — only README.md | - ---- - -## 3. Existing Primitives with Exact File Paths - -### Identity Layer - -| Primitive | File Path | -|-----------|-----------| -| Ed25519 keypair generation | `packages/sdk/src/identity/keypair.ts` | -| DID creation / parsing / validation (`did:fides:`) | `packages/sdk/src/identity/did.ts` | -| In-memory keystore | `packages/sdk/src/identity/keystore.ts` (class `MemoryKeyStore`) | -| File-based encrypted keystore (AES-256-GCM + PBKDF2) | `packages/sdk/src/identity/keystore.ts` (class `FileKeyStore`) | -| Key rotation | `packages/sdk/src/identity/rotation.ts` | -| Revocation record | `packages/sdk/src/identity/rotation.ts` (`createRevocation`) | - -### Signing Layer (RFC 9421 HTTP Message Signatures) - -| Primitive | File Path | -|-----------|-----------| -| Request canonicalization & signature base creation | `packages/sdk/src/signing/canonicalize.ts` | -| HTTP request signing | `packages/sdk/src/signing/http-signature.ts` | -| HTTP request verification (with nonce replay protection, Content-Digest check) | `packages/sdk/src/signing/verify.ts` | -| In-memory nonce store | `packages/sdk/src/signing/nonce-store.ts` | - -### Trust Layer - -| Primitive | File Path | -|-----------|-----------| -| Trust attestation creation & verification | `packages/sdk/src/trust/attestation.ts` | -| Trust levels enum | `packages/sdk/src/trust/types.ts` (`TrustLevel.NONE/LOW/MEDIUM/HIGH/ABSOLUTE`) | -| Trust client (HTTP client to trust-graph service) | `packages/sdk/src/trust/client.ts` | - -### Discovery Layer - -| Primitive | File Path | -|-----------|-----------| -| Discovery client (register/resolve identities) | `packages/sdk/src/discovery/client.ts` | -| Identity resolver (with caching, well-known fallback) | `packages/sdk/src/discovery/resolver.ts` | -| Agent discovery client (register agents, capabilities, heartbeat) | `packages/sdk/src/discovery/agent-client.ts` | -| A2A protocol compatibility converters | `packages/sdk/src/discovery/a2a.ts` | - -### Security & Observability - -| Primitive | File Path | -|-----------|-----------| -| Sliding-window rate limiter | `packages/sdk/src/security/rate-limiter.ts` | -| Rate-limit middleware | `packages/sdk/src/security/rate-limit-middleware.ts` | -| Content validator | `packages/sdk/src/security/content-validator.ts` | -| Content-validation middleware | `packages/sdk/src/security/content-validation-middleware.ts` | -| Prometheus metrics collector | `packages/sdk/src/observability/metrics.ts` | -| Metrics middleware | `packages/sdk/src/observability/metrics-middleware.ts` | - -### Integrations - -| Primitive | File Path | -|-----------|-----------| -| agit (AgentGit) commit signer | `packages/sdk/src/integrations/agit.ts` (`AgitCommitSigner`) | -| agit trust-gated access controller | `packages/sdk/src/integrations/agit.ts` (`TrustGatedAccess`) | - -### Services (Server-side) - -| Primitive | File Path | -|-----------|-----------| -| Discovery service entrypoint | `services/discovery/src/index.ts` | -| Discovery DB schema (identities, agents) | `services/discovery/src/db/schema.ts` | -| Identity routes (`POST /identities`, `GET /identities/:did`) | `services/discovery/src/routes/identities.ts` | -| Agent routes (`/agents`, heartbeat, deregister) | `services/discovery/src/routes/agents.ts` | -| Well-known routes (`/.well-known/fides.json`, `/.well-known/agent.json`) | `services/discovery/src/routes/well-known.ts` | -| Trust-graph service entrypoint | `services/trust-graph/src/index.ts` | -| Trust-graph DB schema (identities, trust_edges, key_history, reputation_scores) | `services/trust-graph/src/db/schema.ts` | -| Trust service (create trust, get score, get path) | `services/trust-graph/src/services/trust-service.ts` | -| BFS trust graph traversal | `services/trust-graph/src/services/graph.ts` | -| Reputation scoring algorithm | `services/trust-graph/src/services/scoring.ts` | -| Graph edge utilities (filter, forward/reverse index) | `services/trust-graph/src/services/edge-utils.ts` | -| Trust routes (`POST /v1/trust`, `GET /v1/trust/:did/score`, `GET /v1/trust/:from/:to`) | `services/trust-graph/src/routes/trust.ts` | - -### Shared Types & Constants - -| Primitive | File Path | -|-----------|-----------| -| All shared types (AgentIdentity, TrustAttestation, TrustScore, AgentCard, etc.) | `packages/shared/src/types.ts` | -| Protocol constants (ALGORITHM, DEFAULT_TRUST_DECAY, MAX_TRUST_DEPTH, etc.) | `packages/shared/src/constants.ts` | -| Error hierarchy (FidesError, SignatureError, DiscoveryError, TrustError, KeyError) | `packages/shared/src/errors.ts` | - -### CLI - -| Command | File Path | -|---------|-----------| -| CLI entrypoint | `packages/cli/src/index.ts` | -| `fides init` | `packages/cli/src/commands/init.ts` | -| `fides sign` | `packages/cli/src/commands/sign.ts` | -| `fides verify` | `packages/cli/src/commands/verify.ts` | -| `fides trust` | `packages/cli/src/commands/trust.ts` | -| `fides discover` | `packages/cli/src/commands/discover.ts` | -| `fides status` | `packages/cli/src/commands/status.ts` | - ---- - -## 4. Key Term Search Results - -| Term | Status | Notes | -|------|--------|-------| -| **identity** | FOUND | Extensively implemented across SDK and services | -| **agent** | FOUND | Core concept; AgentCard, AgentDiscoveryClient, etc. | -| **did** | FOUND | Custom `did:fides:` format | -| **signature** | FOUND | RFC 9421 HTTP Message Signatures + attestation signatures | -| **ed25519** | FOUND | Exclusive algorithm via `@noble/ed25519` | -| **attestation** | FOUND | Signed trust attestations with payload verification | -| **trust** | FOUND | Central primitive; trust edges, levels, graph, scoring | -| **reputation** | FOUND | Reputation scoring with direct + transitive trust | -| **graph** | FOUND | BFS traversal on trust edges with decay | -| **discovery** | FOUND | Discovery service + well-known fallback | -| **registry** | FOUND | Hosted AgentCard registry service in `services/registry`; federation/peering remains missing | -| **capability** | FOUND | Agent skills/capabilities in discovery (A2A-compatible) | -| **policy** | FOUND | `packages/policy` evaluator, `services/policy-engine` HTTP routes, and `agentd` `/v1/policy/evaluate` | -| **delegation** | FOUND | `packages/core/src/delegation.ts` plus tests | -| **session** | FOUND | Session grants and stores in `packages/core/src/session-store.ts`; `agentd` route coverage | -| **grant** | FOUND | Session grants in `packages/core/src/delegation.ts` and `packages/core/src/session-store.ts` | -| **evidence** | FOUND | `packages/evidence/src/index.ts` plus `agentd` evidence routes | -| **event** | FOUND | Evidence events exist; no generic event bus | -| **ledger** | FOUND | Hash-chained evidence ledger primitives in `packages/evidence` | -| **hash** | FOUND | SHA-256 for Content-Digest and agit state hashing | -| **merkle** | NOT FOUND | Only mentioned in `.omc/plans/` as future consideration | -| **revocation** | FOUND | Core revocation records plus `agentd` revocation routes | -| **incident** | FOUND | Core incident records plus `agentd` incident routes feeding authorization context | -| **runtime** | FOUND | Runtime attestation package and `agentd` runtime endpoints | -| **tee** | PARTIAL | `MockTEEProvider` and TEE adapter boundary; no production vendor adapters | -| **dht** | PARTIAL | Mock/in-memory DHT discovery provider; no production libp2p network | -| **relay** | FOUND | Relay service and relay discovery provider exist; service remains prototype/in-memory | -| **federation** | PARTIAL | Architecture/spec level only; registry peering runtime remains missing | -| **well-known** | FOUND | `/.well-known/fides.json` and `/.well-known/agent.json` supported | - ---- - -## 5. What Is Reusable - -### Highly Reusable Components - -1. **Identity Primitives** (`packages/sdk/src/identity/`) - - `generateKeyPair`, `generateDID`, `parseDID`, `isValidDID` — pure functions, zero service dependency - - `MemoryKeyStore` / `FileKeyStore` — pluggable interface for any key storage need - -2. **RFC 9421 Signing Stack** (`packages/sdk/src/signing/`) - - `signRequest`, `verifyRequest`, `createSignatureBase`, `parseSignatureInput` — can be reused for any HTTP signing use case - - Includes Content-Digest (body integrity) and nonce replay protection - -3. **Trust Attestation Primitives** (`packages/sdk/src/trust/attestation.ts`) - - `createAttestation`, `verifyAttestation` — self-contained signing/verification of JSON payloads - -4. **Rate Limiter** (`packages/sdk/src/security/rate-limiter.ts`) - - Pure in-memory sliding-window rate limiter; framework-agnostic - -5. **Metrics Collector** (`packages/sdk/src/observability/metrics.ts`) - - Zero-dependency Prometheus exposition format collector - -6. **Graph Algorithms** (`services/trust-graph/src/services/graph.ts`, `scoring.ts`, `edge-utils.ts`) - - Pure functions for BFS trust path finding and reputation scoring; no DB dependency at the algorithm layer - -7. **A2A Converters** (`packages/sdk/src/discovery/a2a.ts`) - - Bidirectional conversion between FIDES AgentCard and Google A2A Agent Card format - -8. **Shared Types & Constants** (`packages/shared/`) - - TypeScript interfaces and error classes used across all packages - ---- - -## 6. What Is Missing - -### Protocol / Cryptographic Gaps - -- **Registry**: No decentralized registry implementation. Discovery is centralized PostgreSQL. -- **Delegation**: No delegation primitives (no delegated signing, no proxy attestations). -- **Session Management**: No session tokens, no grant mechanism, no OAuth/OIDC bridge. -- **Evidence / Proofs**: No verifiable credentials, no zero-knowledge proofs, no structured evidence collection. -- **Ledger / Merkle Trees**: No immutable log or Merkle anchoring of attestations. -- **Revocation**: `createRevocation` exists as a data structure but there is no active revocation service, CRL, or on-chain registry. -- **Incident Response**: No incident logging, reporting, or automated response system. -- **TEE Support**: No trusted execution environment integration. -- **DHT / P2P Discovery**: Discovery relies on centralized HTTP service; no distributed resolution. -- **Relay / Federation**: No cross-domain federation protocol; services are standalone. - -### Service Gaps - -- **Policy Engine**: Implemented as a deterministic HTTP evaluation service over `@fides/policy`; production persistence, approval workflows, and external pipeline adapters remain future hardening work. -- **Platform API**: Implemented as a metadata/topology service; it does not yet host identity issuance, tenant management, production auth, or metrics. -- **Web Dashboard**: Stubbed only (`apps/web/README.md` describes future UI). -- **Rust SDK**: Only README exists. - -### Operational / Security Gaps (Acknowledged in Docs) - -- No nonce tracking on the server side (client SDK has `NonceStore`, but services do not enforce it). -- No key recovery mechanism. -- No HSM support. -- No rate limiting on trust attestations at the protocol level. -- No negative trust attestations. -- No time-based reputation decay (trust edges do not age). -- Sybil attack vulnerability acknowledged in scoring algorithm. - ---- - -## 7. Conflicts Noticed - -1. **Repository URL Mismatch** - - Root `package.json` and `@fides/sdk/package.json` list `"url": "https://github.com/anthropic-ai/fides"` - - README badge and contributing section list `https://github.com/EfeDurmaz16/fides` - - **Conflict**: Two different GitHub organizations claimed. - -2. **DID Method vs. W3C Compliance** - - The README and architecture docs state DID format is `did:fides:` and explicitly note it is **not W3C DID Core compliant**. - - The protocol spec says it is "inspired by but not compliant with" W3C DID Core. - - **Conflict**: If interoperability with broader DID ecosystems is a goal, this simplified format will break compatibility. - -3. **Trust Decay Formula Discrepancy** - - `docs/protocol-spec.md` describes reputation aggregation as: `sum(direct) * 1.0 + sum(transitive_depth_2) * 0.5 + sum(depth_3_to_6) * 0.25` divided by total paths. - - The actual implementation in `services/trust-graph/src/services/scoring.ts` uses: `combinedScore = (directScore * 0.7) + (min(transitiveScore, 1.0) * 0.3)` with BFS capped at 3 hops for transitive scoring. - - **Conflict**: Spec and implementation do not match in algorithm or max depth. - -4. **Nonce Replay Protection Claim** - - `docs/architecture.md` and `docs/protocol-spec.md` state "No nonce tracking in MVP (deferred to v2)". - - However, `packages/sdk/src/signing/http-signature.ts` generates a nonce, and `packages/sdk/src/signing/verify.ts` checks it against a `NonceStore`. - - **Conflict**: Docs say it's missing, but client SDK partially implements it. Server services do not appear to enforce nonce checking. - -5. **Package Name Consistency** - - Root `package.json`: `"name": "fides"` - - SDK `package.json`: `"name": "@fides/sdk"` - - CLI `package.json`: `"name": "@fides/cli"` - - Discovery service `package.json`: `"name": "@fides/discovery-service"` - - Trust-graph service `package.json`: `"name": "@fides/trust-graph"` - - Registry/relay/platform/policy services are also namespaced under `@fides/*`. - - **Current status**: The earlier unscoped service-name conflict has been corrected. - -6. **Key Rotation DID Change** - - `rotateKey` in `packages/sdk/src/identity/rotation.ts` generates a **new keypair and therefore a new DID**. - - This is technically key replacement rather than rotation, because the DID changes. The protocol spec and architecture docs mention "key rotation and revocation" as a future v2 feature. - - **Conflict**: The current `rotateKey` breaks all existing trust edges since the DID changes, which is not how DID key rotation typically works. - ---- - -## 8. Recommended Action for FIDES v2 - -1. **Keep FIDES as the main repo** — it has the most complete runtime (services, SDK, CLI, tests, CI). -2. **Preserve identity and signing primitives** — they are solid foundations. -3. **Fix spec/implementation discrepancies** before building on top (trust decay formula, nonce protection, DID rotation). -4. **Harden landed layers**: delegation, session, evidence, policy, runtime attestation, DHT/relay, and federation now have a mix of implemented/prototype/spec surfaces; the next work is production persistence, auth, adapter conformance, and propagation semantics. -5. **Keep service naming consistent** under `@fides/*` and avoid reintroducing unscoped workspace package names. -6. **Normalize DID rotation** to use the same DID with a new key, or document why FIDES uses DID-changing replacement. +| Area | Path | Current role | +|---|---|---| +| Core protocol primitives | `packages/core/src/` | Identity v2 shapes, trust anchors, domain/passkey verification, canonical object signing, AgentCard, capabilities, delegation, sessions, revocation, incidents. | +| Policy | `packages/policy/src/index.ts` | Deterministic rules and Sardis-style pre-execution guard pipeline. | +| Guard | `packages/guard/src/index.ts` | Unified authorization decision engine over policy, trust, evidence, attestation, revocation, approval, and kill switch state. | +| Evidence | `packages/evidence/src/index.ts` | Hash-chained evidence events, Merkle root computation, privacy export modes. | +| Runtime | `packages/runtime/src/index.ts` | Runtime attestation interfaces, MockTEE, HTTP TEE adapters, build/container/package/GitHub attestation adapters, kill switch. | +| Discovery | `packages/discovery/src/` | Local, well-known, registry, relay, and in-memory DHT discovery provider architecture. | +| SDK | `packages/sdk/src/` | Promise-based clients for signing, discovery, registry, relay, trust, platform, agentd, and FIDES facade. | +| CLI | `packages/cli/src/` | `fides` CLI with commands for init, sign, verify, trust, discover, card, policy, runtime, killswitch, daemon, delegate, session, revoke, incident, propagation, authorize, relay, identity. | +| Shared | `packages/shared/src/` | Shared types, constants, errors, service auth, metrics, security helpers. | +| Local daemon | `services/agentd/src/` | Local HTTP API and authority store for sessions, policy, evidence, revocations, incidents, propagation, and authorization checks. | +| Trust graph service | `services/trust-graph/src/` | Identity/trust-edge storage, trust paths, capability-scoped scores, revocations, incidents. | +| Discovery service | `services/discovery/src/` | Identity and AgentCard discovery, well-known routes, domain/org verification migrations. | +| Registry service | `services/registry/src/` | Hosted registry with public/private modes and durable storage. | +| Relay service | `services/relay/src/` | Relay registration/discovery service with storage. | +| Policy service | `services/policy-engine/src/` | HTTP policy evaluation service. | +| Platform API | `services/platform-api/src/` | Platform metadata/topology API. | +| Examples | `examples/` | Calendar, invoice, payment, requester, and demo scripts. | +| Tests | `packages/*/test`, `services/*/test`, `tests/e2e`, `tests/adversarial` | Package, service, e2e, and adversarial coverage. | + +## 3. Existing Primitives + +| Primitive | Status | Local evidence | +|---|---|---| +| Agent identity | Present, shallow v2 shape | `packages/core/src/identity.ts`, `packages/shared/src/types.ts`. | +| Publisher identity | Present, limited verification methods | `packages/core/src/identity.ts`, `packages/core/src/domain-verifier.ts`. | +| Principal identity | Present, limited | `packages/core/src/identity.ts`. | +| Domainless identity | Partial | `packages/core/src/identity.ts` supports DIDs without domain, but identity creation currently uses random bytes as public key rather than keypair issuance. | +| Platform-hosted identity | Partial | Shared and docs mention platform identity, but no first-class hosted identity lifecycle was found. | +| Domain/org verified identity | Present for DNS TXT verification | `packages/core/src/domain-verifier.ts`, `services/discovery/src/db/migrations/003_identity_domain_verification.sql`, `services/discovery/src/db/migrations/004_organization_domain_verification.sql`. | +| Trust anchors | Present | `packages/core/src/trust-anchor.ts`. | +| Canonical object signing | Present | `packages/core/src/canonical-signer.ts`. | +| HTTP message signatures | Present in SDK | `packages/sdk/src/signing/`. | +| Signed AgentCards | Partial | `packages/core/src/agent-card.ts` defines `SignedAgentCard`, discovery providers accept signed cards, but AgentCard lacks all requested v2 fields. | +| Capability descriptors | Partial | `packages/core/src/capability.ts` has id/name/schema/risk/approval/attestation; missing namespace/action/resource/control metadata. | +| Capability ontology | Missing | I could not find ontology entries beyond heuristic risk classification in `packages/core/src/capability.ts`. | +| Local discovery | Present | `packages/discovery/src/local-provider.ts`. | +| Well-known discovery | Present | `packages/discovery/src/well-known-provider.ts`, `services/discovery/src/routes/well-known.ts`. | +| Registry discovery | Present | `packages/discovery/src/registry-provider.ts`, `services/registry/src/`. | +| Relay discovery | Present, prototype | `packages/discovery/src/relay-provider.ts`, `services/relay/src/`. | +| DHT discovery | Present, too weak | `packages/discovery/src/dht-provider.ts` stores AgentCards by DID in an in-memory peer graph; it does not yet implement signed DHT pointer records. | +| Federation | Partial/spec only | Registry service exists, but I could not find federation peering records/runtime. | +| Trust graph | Present | `services/trust-graph/src/services/graph.ts`, `services/trust-graph/src/services/trust-service.ts`. | +| Capability-specific reputation | Partial | `services/trust-graph/src/db/migrations/003_capability_scoring.sql`, `services/trust-graph/src/services/capability-scoring.ts`. | +| Context-specific trust scoring | Partial | Trust edges include optional capability/context, but no full v2 scoring component model. | +| Policy engine | Present, simple | `packages/policy/src/index.ts`, `services/policy-engine/src/index.ts`. | +| Delegation tokens | Present | `packages/core/src/delegation.ts`. | +| Session grants | Present, not v2-complete | `packages/core/src/delegation.ts`, `packages/core/src/session-store.ts`, `services/agentd/src/index.ts`. | +| Capability invocation | Partial | Guard/agentd authorization exists; no generic signed InvocationRequest/InvocationResult protocol object found. | +| Runtime attestation | Present | `packages/runtime/src/index.ts`. | +| TEE-ready attestation | Present as adapter boundary | `packages/runtime/src/index.ts`. | +| MockTEE | Present | `packages/runtime/src/index.ts`. | +| Evidence ledger | Present, package-level | `packages/evidence/src/index.ts`; persisted locally by agentd authority store. | +| Revocation records | Present | `packages/core/src/revocation.ts`, `services/agentd/src/index.ts`, `services/trust-graph/src/db/migrations/002_revocations.sql`. | +| Incident records | Present | `packages/core/src/revocation.ts`, `services/agentd/src/index.ts`, `services/trust-graph/src/db/migrations/002_revocations.sql`. | +| Approval primitives | Partial | Guard and policy can require approval; I could not find first-class ApprovalRequest/ApprovalDecision protocol objects in core. | +| Kill switch | Present | `packages/runtime/src/index.ts`, `packages/cli/src/commands/killswitch.ts`, `services/agentd/src/index.ts`. | +| Evidence privacy | Present, basic | `packages/evidence/src/index.ts` supports public/private/redacted/hash-only export modes. | +| Version negotiation | Missing/partial | Shared errors include `VersioningError`, but no full VersionNegotiationRecord or discovery downgrade flow was found. | +| Typed errors | Partial | `packages/shared/src/errors.ts` has broad classes, but not stable code/category/severity/retryable envelopes. | +| Explainability | Partial | Guard and policy return factors/explanations in `packages/guard/src/index.ts` and `packages/policy/src/index.ts`. | +| Adversarial simulation | Present as test, incomplete harness | `tests/adversarial/adversarial.test.ts`; no `agentd simulate adversarial` command found. | +| Interop adapters | Partial | SDK and CLI have A2A/FIDES-era surfaces; explicit MCP/A2A/OAPS/OSP/AP2/x402/Sardis adapter package not found. | +| CLI | Present, command name is `fides` | `packages/cli/src/index.ts`. Requested `agentd` CLI naming is not present. | +| Local HTTP API | Present at `/v1/*`, not requested exact endpoint set | `services/agentd/src/index.ts`, `docs/api/agentd.yaml`. | +| SDK | Present | `packages/sdk/src/index.ts`, `packages/sdk/src/fides.ts`. | +| Examples/demo | Present, not full requested v2 demo | `examples/`. | +| Tests | Present | Package/service/e2e tests exist. | + +## 4. Relevant Files + +- Monorepo/workspace: `package.json`, `pnpm-workspace.yaml`, `turbo.json`, `tsconfig.base.json`. +- Public repo docs: `README.md`, `docs/getting-started.md`, `docs/deployment.md`, `docs/threat-model.md`. +- Current inspection/architecture docs: `docs/inspection/*.md`, `docs/architecture/*.md`. +- Protocol docs: `docs/protocol-spec.md`, `docs/protocol/fides-v2-spec.md`. +- OpenAPI specs: `docs/api/agentd.yaml`, `docs/api/discovery.yaml`, `docs/api/registry.yaml`, `docs/api/relay.yaml`, `docs/api/trust-graph.yaml`, `docs/api/platform-api.yaml`. +- Core primitives: `packages/core/src/index.ts`, `packages/core/src/canonical-signer.ts`, `packages/core/src/identity.ts`, `packages/core/src/agent-card.ts`, `packages/core/src/capability.ts`, `packages/core/src/delegation.ts`, `packages/core/src/revocation.ts`, `packages/core/src/trust-anchor.ts`, `packages/core/src/domain-verifier.ts`, `packages/core/src/passkey.ts`. +- Runtime/evidence/policy/guard: `packages/runtime/src/index.ts`, `packages/evidence/src/index.ts`, `packages/policy/src/index.ts`, `packages/guard/src/index.ts`. +- Discovery providers: `packages/discovery/src/provider.ts`, `packages/discovery/src/orchestrator.ts`, `packages/discovery/src/local-provider.ts`, `packages/discovery/src/well-known-provider.ts`, `packages/discovery/src/registry-provider.ts`, `packages/discovery/src/relay-provider.ts`, `packages/discovery/src/dht-provider.ts`. +- Local authority daemon: `services/agentd/src/index.ts`, `services/agentd/src/storage.ts`, `services/agentd/src/db/migrations/001_authority_store.sql`. +- Trust graph: `services/trust-graph/src/services/trust-service.ts`, `services/trust-graph/src/services/graph.ts`, `services/trust-graph/src/services/capability-scoring.ts`, `services/trust-graph/src/db/migrations/001_initial.sql`, `services/trust-graph/src/db/migrations/002_revocations.sql`, `services/trust-graph/src/db/migrations/003_capability_scoring.sql`. +- CLI: `packages/cli/src/index.ts`, `packages/cli/src/commands/*.ts`. +- SDK: `packages/sdk/src/agentd/client.ts`, `packages/sdk/src/discovery/client.ts`, `packages/sdk/src/registry/client.ts`, `packages/sdk/src/relay/client.ts`, `packages/sdk/src/trust/client.ts`, `packages/sdk/src/platform/client.ts`. +- Tests: `packages/core/test/`, `packages/evidence/test/`, `packages/runtime/test/`, `packages/discovery/test/`, `packages/guard/test/`, `packages/policy/test/`, `packages/sdk/test/`, `services/*/test/`, `tests/e2e/`, `tests/adversarial/`. + +## 5. Reusable Components + +- Keep the TypeScript monorepo as the v2 home. It already matches the TS-first constraint. +- Reuse `packages/core/src/canonical-signer.ts`, but harden it into the single envelope for all signed protocol objects with required shared fields. +- Reuse `packages/evidence/src/index.ts`, but evolve EvidenceEvent into the requested privacy-aware signed protocol object with input/output/policy hashes and event taxonomy. +- Reuse `packages/runtime/src/index.ts` for MockTEE and attestation adapter boundaries. +- Reuse `services/agentd/src/storage.ts` and `services/agentd/src/db/migrations/001_authority_store.sql` as the starting local authority store, but move toward SQLite/local-first storage for the daemon target if required. +- Reuse `services/trust-graph` algorithms and storage for v2 trust/reputation, but replace the score model with the requested explainable component model. +- Reuse `packages/discovery` provider architecture, but replace DID-only resolution with capability-query discovery candidates. +- Reuse CLI/service/SDK scaffolding, but align naming and endpoints to the requested `agentd` v2 surface. + +## 6. Missing Components + +I could not find these in the repo as complete v2 implementations: + +- `PublisherIdentity` types for anonymous/self-signed/verified_individual/platform_hosted/domain_verified/organization_verified. +- Full trust anchor set for GitHub/email/npm/PyPI/wallet/passkey/org invitation/runtime/build/peer attestation. +- Unified protocol object family with `schema_version`, `id`, `issuer`, `subject`, `payload_hash`, and `signature`. +- Capability ontology entries with namespace/action/resource/control semantics. +- Signed DHT pointer records and tests for tampering, expiry, card hash mismatch, and revocation. +- Federation peering records/runtime. +- First-class ApprovalRequest, ApprovalDecision, ApprovalPolicy protocol objects. +- InvocationRequest and InvocationResult protocol objects and generic invocation executor. +- Full version negotiation. +- Stable ErrorEnvelope vocabulary with code/category/severity/retryable/details. +- MCP/A2A/OAPS/OSP/AP2/x402/Sardis adapter package. +- `agentd demo run` and `agentd simulate adversarial` CLI commands. +- Requested local SQLite daemon storage layout. + +## 7. Conflicts With FIDES v2 Architecture + +- `createIdentity` in `packages/core/src/identity.ts` accepts a DID and fills `publicKey` with random bytes, but does not create or bind an Ed25519 keypair. That is unsafe for v2 identity issuance. +- There are two AgentCard shapes: `packages/core/src/agent-card.ts` and `packages/shared/src/types.ts`. They need consolidation. +- DHT provider currently stores AgentCards directly; v2 requires DHT to provide signed pointers only and never act as a trust source. +- Policy actions use `approve-required` and `dry-run`; the user-facing spec uses `require_approval`, `dry_run_only`, `scope_limit`, and `risk_limit`. This needs vocabulary normalization or compatibility mapping. +- CLI binary is `fides`, while requested commands are under `agentd`. Decide whether `agentd` becomes an alias/binary or a subcommand. +- Service APIs use `/v1/*` and differ from the requested local HTTP API paths. Add compatibility routes or document versioned API mapping. +- FIDES currently contains payment examples such as `payments.execute`; generic FIDES must keep execution payment-specific behavior in Sardis and support only generic dry-run/payment-prep patterns. + +## 8. Recommended Action + +Treat the current repo as an advanced prototype, not a blank MVP. The v2 work should be a hardening and consolidation pass: + +1. Freeze the current implemented surface as baseline. +2. Consolidate core protocol objects under `packages/core`. +3. Add missing v2 protocol objects and stable error/version vocabularies. +4. Convert discovery from DID resolution to capability + constraints resolution. +5. Replace DHT direct-card storage with signed pointer records. +6. Promote approvals, invocation, revocation, incidents, and evidence into first-class signed objects. +7. Align CLI/API/SDK/demo surfaces to `agentd` v2. +8. Keep AGIT/OAPS/OSP/Sardis as semantic or adapter inputs only; no runtime dependency on OAPS. diff --git a/docs/inspection/oaps-report.md b/docs/inspection/oaps-report.md index 638eded..ed49da7 100644 --- a/docs/inspection/oaps-report.md +++ b/docs/inspection/oaps-report.md @@ -1,431 +1,116 @@ # OAPS Repository Inspection Report -## 1. Repo Purpose - -**Protocol name:** AICP — Agent Interaction Control Protocol -**Repository:** OAPS — Open Agentic Primitive Standard (historical slug preserved) - -This repository hosts the AICP protocol specification, JSON Schemas, conformance suite, reference implementations, and profile drafts. AICP is designed as a **composing semantic super-protocol** that sits above existing agent ecosystems (MCP, A2A, x402, MPP, AP2, OSP, etc.) and standardizes the horizontal primitive layer they do not define consistently: identity references, delegation, mandates, intents, tasks, approvals, execution outcomes, evidence, and payment coordination. - -Public-facing brand is **AICP**; the repo slug remains `OAPS` to preserve history. - ---- - -## 2. Main Packages / Modules - -### Reference Implementations - -| Line | Package / Module | Path | Language | Status | -|------|------------------|------|----------|--------| -| TypeScript monorepo | `reference/oaps-monorepo` | `reference/oaps-monorepo` | TypeScript (pnpm workspace) | **Stable** — primary runtime-backed slice | -| Python interoperability | `reference/oaps-python` | `reference/oaps-python` | Python | **Stable** — conformance manifest consumer | -| AOSL monorepo | `reference/aosl-monorepo` | `reference/aosl-monorepo` | TypeScript (pnpm workspace) | **Draft** — parallel runtime effort | - -### OAPS Monorepo Packages (`reference/oaps-monorepo/packages/*`) - -| Package | Path | Role | Runtime Backing | -|---------|------|------|-----------------| -| `@oaps/core` | `packages/core/src/index.ts` | Shared contracts, IDs, version negotiation, hashing, auth checks, state machines, handshake protocol | Yes | -| `@oaps/evidence` | `packages/evidence/src/index.ts` | SHA256 hashing, hash-linked chain builder, chain verifier | Yes | -| `@oaps/policy` | `packages/policy/src/index.ts` | `oaps-policy-v1` deterministic policy evaluator, context hashing | Yes | -| `@oaps/mcp-adapter` | `packages/mcp-adapter/src/index.ts` | MCP capability mapping, policy enforcement, approval gating, evidence emission | Yes | -| `@oaps/http` | `packages/http/src/index.ts` | Reference HTTP server, well-known discovery, interaction/approval/evidence endpoints, idempotency, file-backed state | Yes | -| `@oaps/discovery` | `packages/discovery/src/index.ts` | Actor card discovery via `.well-known/aicp.json` and DNS TXT fallback | Yes | -| `@oaps/context` | `packages/context/src/index.ts` | Interaction context management, message/transition/delegation tracking, replay windows | Yes | -| `@oaps/websocket-binding` | `packages/websocket-binding/src/index.ts` | WebSocket binding with handshake, version negotiation, frame validation, evidence streaming | Yes | -| `@oaps/webhook-binding` | `packages/webhook-binding/src/index.ts` | Webhook binding stub | Partial | -| `@oaps/a2a-adapter` | `packages/a2a-adapter/src/index.ts` | A2A task/status mapping to AICP semantics, delegation, evidence | Yes | -| `@oaps/x402-adapter` | `packages/x402-adapter/src/index.ts` | x402 payment challenge/authorization/settlement/refund/void mapping | Yes | -| `@oaps/auth-web-adapter` | `packages/auth-web-adapter/src/index.ts` | Web auth (session/bearer) subject binding, delegation verification | Yes | -| `@oaps/hono` | `packages/hono/src/index.ts` | Hono-compatible HTTP wrapper (simplified reference stack) | Yes | -| `@oaps/hono-node-server` | `packages/hono-node-server/src/index.ts` | Node server wrapper | Yes | - -### AOSL Monorepo Packages (`reference/aosl-monorepo/packages/*`) - -| Package | Path | Role | -|---------|------|------| -| `@aosl/core` | `packages/core/src/types.ts` | Core types (Actor, Intent, Task, Delegation, EvidenceEvent, etc.) | -| `@aosl/runtime` | `packages/runtime/src/index.ts` | Runtime exports (policy, evidence, plan) | -| `@aosl/cli` | `packages/cli/src/index.ts` | CLI scaffold (`aosl CLI booting...`) | -| `@aosl/ir` | `packages/ir/src/index.ts` | IR types and validation | -| `@aosl/linter` | `packages/linter/src/index.ts` | Linter | -| `@aosl/sdk-ts` | `packages/sdk-ts/src/index.ts` | SDK effects and task utilities | - ---- - -## 3. Existing Primitives with Exact File Paths - -### Core Types (OAPS Monorepo) - -Defined in `reference/oaps-monorepo/packages/core/src/index.ts`: - -- `ActorRef` — line 36 -- `Endpoint` — line 42 -- `Proof` — line 48 -- `Money` — line 55 -- `Action` — line 60 -- `ActorCard` — line 68 -- `CapabilityCard` — line 81 -- `Intent` — line 93 -- `Task` — line 104 -- `DelegationToken` — line 117 -- `Mandate` — line 130 -- `ApprovalRequest` — line 141 -- `ApprovalDecision` — line 154 -- `Challenge` — line 165 -- `ExecutionRequest` — line 179 -- `ExecutionResult` — line 187 -- `InteractionCreated` — line 195 -- `InteractionUpdated` — line 203 -- `InteractionTransition` — line 211 -- `TaskTransition` — line 224 -- `ErrorObject` — line 253 (with `ErrorCategory` union at line 239) -- `ExtensionDescriptor` — line 261 -- `EvidenceEvent` — line 272 -- `EnvelopeRefs` / `Envelope` — line 285-308 -- `VersionSupport` / `VersionNegotiationResult` — line 310-320 - -### Handshake Protocol (OAPS Monorepo) - -In `reference/oaps-monorepo/packages/core/src/index.ts` lines 904-1103: - -- `HandshakeBinding` — line 907 -- `HandshakeStep` — line 909 -- `HandshakeProposal` — line 911 -- `HandshakeAcceptance` — line 925 -- `HandshakeRejection` — line 935 -- `validateHandshakeProposal()` — line 941 -- `evaluateHandshakeProposal()` — line 1001 -- `buildHandshakeEvidence()` — line 1085 - -### Policy Types (OAPS Monorepo) - -In `reference/oaps-monorepo/packages/policy/src/index.ts`: - -- `PolicyExpression` — line 6 -- `PolicyRule` — line 18 -- `PolicyBundle` — line 24 -- `PolicyContext` — line 31 -- `PolicyResult` — line 45 -- `evaluatePolicy()` — line 162 -- `hashPolicyContext()` — line 158 - -### Evidence Types (OAPS Monorepo) - -In `reference/oaps-monorepo/packages/evidence/src/index.ts`: - -- `EvidenceChain` — line 3 -- `createEvidenceChain()` — line 7 -- `appendEvidenceEvent()` — line 23 -- `verifyEvidenceChain()` — line 44 -- `hashEvidenceValue()` — line 19 - -### Discovery Types (OAPS Monorepo) - -In `reference/oaps-monorepo/packages/discovery/src/index.ts`: - -- `ActorCardDiscovery` — line 10 -- `fetchActorCard()` — line 174 -- `resolveActorCard()` — line 213 - -### AOSL Core Types (Separate Namespace) - -In `reference/aosl-monorepo/packages/core/src/types.ts`: - -- `Actor`, `ActorRef`, `Capability`, `Intent`, `Interaction`, `Task`, `Delegation`, `Mandate`, `ApprovalRequest`, `ApprovalDecision`, `Challenge`, `EvidenceEvent`, `ErrorObject`, `ExecutionResult`, `PaymentCoordination`, `EffectDescriptor` - ---- - -## 4. Key Term Search Results - -| Term | Status | Exact Locations | -|------|--------|-----------------| -| **DelegationToken** | Found | `reference/oaps-monorepo/packages/core/src/index.ts:117`; `packages/context/src/index.ts`; `packages/mcp-adapter/src/index.ts`; `packages/a2a-adapter/src/index.ts`; `packages/auth-web-adapter/src/index.ts`; `spec/core/HANDSHAKE-DRAFT.md`; `SPEC.md:45`; `examples/integrations/multi-agent-demo/src/demo.ts` | -| **PolicyBundle** | Found | `reference/oaps-monorepo/packages/policy/src/index.ts:24`; `packages/mcp-adapter/src/index.ts`; `packages/http/src/index.ts`; `SPEC.md:45`; `examples/integrations/mcp-governance-demo/src/demo.ts` | -| **Intent** | Found | `reference/oaps-monorepo/packages/core/src/index.ts:93`; `packages/mcp-adapter/src/index.ts`; `packages/a2a-adapter/src/index.ts`; `spec/core/FOUNDATION-DRAFT.md`; `examples/integrations/multi-agent-demo/src/demo.ts` | -| **Evidence** | Found | `reference/oaps-monorepo/packages/evidence/src/index.ts`; `packages/core/src/index.ts:272`; `packages/websocket-binding/src/index.ts`; `packages/context/src/index.ts`; pervasive across repo | -| **A2A** | Found | `reference/oaps-monorepo/packages/a2a-adapter/src/index.ts`; `profiles/a2a-draft.md`; `conformance/fixtures/profiles/a2a/index.v1.json`; `examples/a2a/`; `docs/REVIEW-PACKET-A2A.md` | -| **MCP** | Found | `reference/oaps-monorepo/packages/mcp-adapter/src/index.ts`; `profiles/mcp.md`; `conformance/fixtures/profiles/mcp/index.v1.json`; `examples/mcp/`; `docs/REVIEW-PACKET-MCP.md` | -| **AP2** | Found | `profiles/ap2-draft.md`; `conformance/fixtures/profiles/ap2/index.v1.json`; `examples/ap2/`; `docs/REVIEW-PACKET-PAYMENT.md` | -| **x402** | Found | `reference/oaps-monorepo/packages/x402-adapter/src/index.ts`; `profiles/x402-draft.md`; `conformance/fixtures/profiles/x402/index.v1.json`; `examples/x402/` | -| **MPP** | Found | `profiles/mpp-draft.md`; `conformance/fixtures/profiles/mpp/index.v1.json`; `examples/mpp/` | -| **discovery** | Found | `reference/oaps-monorepo/packages/discovery/src/index.ts`; `spec/core/DISCOVERY-DRAFT.md`; `schemas/foundation/actor-card-discovery.json` | -| **well-known** | Found | `reference/oaps-monorepo/packages/discovery/src/index.ts:218` (`/.well-known/aicp.json`); `packages/http/src/index.ts:286` (`/.well-known/oaps.json`); `spec/core/DISCOVERY-DRAFT.md`; `examples/well-known-oaps.json` | -| **version negotiation** | Found | `reference/oaps-monorepo/packages/core/src/index.ts:512` (`negotiateVersion`); `packages/http/src/index.ts:328`; `packages/websocket-binding/src/index.ts:323,497`; `schemas/foundation/version-negotiation.json`; `spec/core/FOUNDATION-DRAFT.md:79` (CC6) | -| **JSON Schema** | Found | `schemas/` directory contains ~40 schemas; `README.md` mentions JSON Schemas; validation scripts exist | -| **error vocabulary** | **Not found as explicit phrase** | However, `schemas/foundation/error-object.json` defines canonical `ErrorCategory` enum: `authentication`, `authorization`, `validation`, `capability`, `discovery`, `transport`, `execution`, `economic`, `settlement`, `timeout`, `versioning`, `internal` | - ---- - -## 5. CLI Entrypoints, SDK Exports, Examples, Tests - -### CLI Entrypoints - -1. **TypeScript reference server** (direct execution): - - `reference/oaps-monorepo/packages/http/src/index.ts` lines 790-819 — `startReferenceServer()` runs when executed directly via `node` - -2. **Python CLI** (`oaps-python`): - - Entrypoint: `reference/oaps-python/src/oaps_python/cli.py` - - Commands: `validate`, `inventory`, `check` (fixture-check), `validate-result`, `validate-declaration`, `compatibility`, `compare-results`, `compare-declarations` - - Module: `python -m oaps_python` - -3. **AOSL CLI** (scaffold only): - - `reference/aosl-monorepo/packages/cli/src/index.ts` — minimal boot message, not a full CLI - -4. **Monorepo scripts**: - - `reference/oaps-monorepo/scripts/validate-spec-pack.mjs` — validates examples against JSON Schemas - - `reference/oaps-monorepo/scripts/validate-conformance-pack.mjs` — validates TCK manifest and fixture indexes - - `reference/oaps-monorepo/scripts/generate-core-schema-constants.mjs` — derives core constants from schema pack - -### SDK Exports - -- `@oaps/core` — main export from `packages/core/src/index.ts` -- `@oaps/evidence` — main export from `packages/evidence/src/index.ts` -- `@oaps/policy` — main export from `packages/policy/src/index.ts` -- `@oaps/mcp-adapter` — main export from `packages/mcp-adapter/src/index.ts` -- `@oaps/http` — main export from `packages/http/src/index.ts` -- `@oaps/discovery` — main export from `packages/discovery/src/index.ts` -- `@oaps/context` — main export from `packages/context/src/index.ts` -- `@oaps/websocket-binding` — main export from `packages/websocket-binding/src/index.ts` -- `@oaps/a2a-adapter` — main export from `packages/a2a-adapter/src/index.ts` -- `@oaps/x402-adapter` — main export from `packages/x402-adapter/src/index.ts` -- `@oaps/auth-web-adapter` — main export from `packages/auth-web-adapter/src/index.ts` - -### Examples - -- `examples/` — 100+ example JSON payloads -- `examples/integrations/multi-agent-demo/` — A2A-style delegation chain demo -- `examples/integrations/mcp-governance-demo/` — MCP governance demo -- `examples/integrations/stripe-aicp-demo/` — Stripe payment intent demo - -### Tests - -Each TypeScript package uses Node.js built-in test runner (`node --test`): -- `packages/core/src/index.test.ts` -- `packages/evidence/src/index.test.ts` -- `packages/policy/src/index.test.ts` -- `packages/mcp-adapter/src/index.test.ts` -- `packages/http/src/index.test.ts` -- `packages/discovery/src/index.test.ts` -- `packages/context/src/index.test.ts` -- `packages/websocket-binding/src/index.test.ts` -- `packages/a2a-adapter/src/index.test.ts` -- `packages/x402-adapter/src/index.test.ts` -- `packages/auth-web-adapter/src/index.test.ts` - -Python tests: -- `reference/oaps-python/tests/test_types.py` -- `reference/oaps-python/tests/test_evidence.py` -- `reference/oaps-python/tests/test_validation.py` -- `reference/oaps-python/tests/test_manifest.py` - ---- - -## 6. Schemas / Spec Files - -### JSON Schemas (`schemas/`) - -**Foundation schemas** (`schemas/foundation/`): -- `actor.json`, `capability.json`, `intent.json`, `task.json`, `delegation.json`, `mandate.json` -- `approval-request.json`, `approval-decision.json`, `challenge.json`, `execution-result.json` -- `evidence-event.json`, `error-object.json`, `extension-descriptor.json` -- `interaction.json`, `interaction-transition.json`, `task-transition.json` -- `interaction-context.json`, `handshake.json`, `version-negotiation.json` -- `actor-card-discovery.json`, `message.json`, `common.json` - -**Legacy schemas** (`schemas/` root): -- `actor-card.json`, `capability-card.json`, `intent.json`, `delegation-token.json` -- `approval-request.json`, `approval-decision.json`, `envelope.json`, `error.json` -- `evidence-event.json`, `execution-request.json`, `execution-result.json` -- `interaction-created.json`, `interaction-updated.json` +Source repo: `/Users/efebarandurmaz/OAPS` -**Profile schemas** (`schemas/profiles/`): -- `profile-support-declaration.json`, `payment-challenge.json`, `trust-attestation.json` -- `provisioning-operation.json`, `subject-binding-assertion.json` +Note: the repo is branded in places as AICP while still using OAPS names in code and paths. This report reflects current local files and is read-only evidence for FIDES v2. -**Payment schemas** (`schemas/payment/`): -- `payment-authorization.json`, `mandate-chain.json`, `payment-session.json` - -**Domain schemas** (`schemas/domain/`): -- `order-intent.json`, `commercial-evidence.json`, `fulfillment-commitment.json`, `merchant-authorization.json` - -**Binding schemas** (`schemas/bindings/`): -- `webhook-registration.json`, `webhook-envelope.json`, `websocket-message.json` - -### Spec Documents - -- `spec/core/FOUNDATION-DRAFT.md` — hard-normative semantic core -- `spec/core/STATE-MACHINE-DRAFT.md` — interaction/task lifecycles -- `spec/core/HANDSHAKE-DRAFT.md` — handshake protocol -- `spec/core/DISCOVERY-DRAFT.md` — discovery semantics -- `spec/core/SHARED-CONTEXT-DRAFT.md` — shared context semantics -- `spec/bindings/http-binding-draft.md` -- `spec/bindings/websocket-binding-draft.md` -- `spec/bindings/jsonrpc-binding-draft.md` -- `spec/bindings/grpc-binding-draft.md` -- `spec/bindings/events-binding-draft.md` -- `spec/bindings/webhook-binding-draft.md` -- `spec/domain/commerce-draft.md` -- `spec/profiles/aosl-runtime-draft.md` -- `spec/profiles/agent-client-draft.md` -- `SPEC.md` — consolidated legacy draft spec pack - -### Conformance Suite - -- `conformance/manifest/oaps-tck.manifest.v1.json` — TCK manifest -- `conformance/taxonomy/scenario-taxonomy.v1.json` — scenario taxonomy -- `conformance/fixtures/index.v1.json` — fixture index -- `conformance/fixtures/core/index.v1.json` — core fixtures -- `conformance/fixtures/bindings/*/index.v1.json` — binding fixtures -- `conformance/fixtures/profiles/*/index.v1.json` — profile fixtures -- `conformance/results/result-schema.v1.json` — result schema -- `conformance/results/compatibility-declaration-schema.v1.json` — declaration schema - ---- - -## 7. CI / Config Files - -### GitHub Configuration -- `.github/CODEOWNERS` -- `.github/ISSUE_TEMPLATE/cosigner.yml` -- `.github/ISSUE_TEMPLATE/config.yml` -- `.github/ISSUE_TEMPLATE/review-feedback.md` - -**No GitHub Actions workflows found.** No `.github/workflows/` directory exists. - -### Project Config -- `reference/oaps-monorepo/pnpm-workspace.yaml` — pnpm workspace definition -- `reference/oaps-monorepo/package.json` — root package.json (private, `type: "module"`, `packageManager: "pnpm@10.32.1"`) -- `reference/aosl-monorepo/package.json` — root package.json (private, `type: "module"`, `packageManager: "pnpm@10.32.1"`) -- `reference/oaps-python/pyproject.toml` — Python project config -- `.gitignore` -- `.codex/config.toml` — Codex harness config - ---- - -## 8. What Is Reusable - -### Highly Reusable (Stable, Tested, Runtime-Backed) - -1. **`@oaps/core`** — The entire core primitive layer is reusable: - - `generateId`, `canonicalJson`, `sha256Prefixed` - - `negotiateVersion` (version negotiation logic) - - `assertInvokeIntent`, `assertAuthenticatedActor`, `promoteIntentToTask` - - `assertInteractionTransition`, `assertTaskTransition` - - `buildEnvelope`, `buildHandshakeEvidence`, `evaluateHandshakeProposal` - - `assertMandateAuthorizes`, `mandateCoversAction`, `isMandateExpired` - - `assertApprovalDecisionTargets` - - `parseBearerToken`, `compareRiskClass`, `riskClassRequiresApproval` - - All type definitions (`ActorCard`, `CapabilityCard`, `Intent`, `Task`, `DelegationToken`, `Mandate`, `ApprovalRequest`, `ApprovalDecision`, `Challenge`, `EvidenceEvent`, `ErrorObject`, `Envelope`, etc.) - -2. **`@oaps/evidence`** — Evidence chain builder and verifier are fully reusable. - -3. **`@oaps/policy`** — The `oaps-policy-v1` evaluator with `eq`, `neq`, `lt`, `lte`, `gt`, `gte`, `in`, `all`, `any` expressions is reusable. - -4. **`@oaps/discovery`** — Actor card discovery via `.well-known/aicp.json` with DNS TXT fallback. - -5. **`@oaps/context`** — Interaction context manager with message/transition/delegation tracking and replay. - -6. **`@oaps/auth-web-adapter`** — Bearer/session authentication, subject binding, delegation verification. - -7. **HTTP Reference Server** (`@oaps/http`) — Full working server with: - - Bearer token auth - - Idempotency with file-backed state - - Interaction lifecycle (create, message append, approve, reject, revoke) - - Evidence replay with `after`/`limit` pagination - - MCP adapter integration - -8. **WebSocket Binding** (`@oaps/websocket-binding`) — Full server/client with handshake, version negotiation, frame types, evidence emission, replay. - -9. **Profile Adapters** (mapping layers): - - `@oaps/mcp-adapter` — Maps MCP tools to `CapabilityCard`, enforces policy, approval gates, evidence - - `@oaps/a2a-adapter` — Maps A2A tasks/statuses to AICP interaction/task states - - `@oaps/x402-adapter` — Maps x402 challenge/authorization/settlement to AICP primitives - -10. **Python Interoperability Layer**: - - `reference/oaps-python/aicp/types.py` — Dataclass primitives - - `reference/oaps-python/aicp/evidence.py` — Evidence chain in Python - - `reference/oaps-python/aicp/validation.py` — Validation utilities - - `reference/oaps-python/src/oaps_python/cli.py` — Full conformance CLI (validate, inventory, fixture-check, compatibility declaration) - -11. **JSON Schemas** — 40+ schemas under `schemas/` covering foundation, profiles, payments, domain, and bindings. - -12. **Conformance Manifest and Fixtures** — Machine-readable TCK manifest, taxonomy, and fixture packs for 17 scopes (core, 5 bindings, 11 profiles/domains). - ---- - -## 9. What Is Missing - -1. **No Cargo.toml** — This is not a Rust repository; it is TypeScript + Python. - -2. **No GitHub Actions CI** — No automated CI/CD pipelines exist. Tests must be run manually via `pnpm test` or `node --test`. - -3. **No dedicated "error vocabulary" document** — While `ErrorObject` schema exists with 12 canonical categories, there is no standalone prose document cataloging all error codes used across the suite. - -4. **gRPC and Events/Webhooks runtime support** — The spec drafts exist (`spec/bindings/grpc-binding-draft.md`, `spec/bindings/events-binding-draft.md`, `spec/bindings/webhook-binding-draft.md`), but there is no runtime-backed implementation beyond fixture stubs. - -5. **A2A end-to-end runtime** — The A2A adapter exists as a mapping layer with tests, but there is no live A2A client/server integration in the reference slice. - -6. **Payment profile runtimes** — x402, MPP, and AP2 adapters are mapping layers only. No live payment rail integration exists. - -7. **Commerce and Jobs domain families** — These are conceptual/long-term. Only `schemas/domain/` and `examples/commerce/` have draft fixtures. - -8. **Registry infrastructure** — No registry or lookup service implementation exists. - -9. **External governance / cosigner structure** — Still draft/concept per `CHARTER.md` and `governance/RF_PATENT_PLEDGE.md`. - -10. **Full AOSL monorepo integration** — The AOSL monorepo (`reference/aosl-monorepo`) is a parallel effort with its own type system. It is not yet wired into the OAPS reference slice. - ---- - -## 10. Conflicts and Issues Noticed - -### Critical: Two Competing Type Systems - -**OAPS monorepo** (`reference/oaps-monorepo/packages/core/src/index.ts`) and **AOSL monorepo** (`reference/aosl-monorepo/packages/core/src/types.ts`) define **overlapping but incompatible** primitives: - -| Primitive | OAPS Style | AOSL Style | -|-----------|------------|------------| -| ActorRef field names | `actor_id` (snake_case) | `actorId` (camelCase) | -| Intent field names | `intent_id`, `actor_ref`, `capability_ref` | `id`, `actorRef`, `capabilityRef` | -| Delegation field names | `delegation_id`, `delegator`, `delegatee` | `id`, `fromActorRef`, `toActorRef` | -| EvidenceEvent field names | `event_id`, `interaction_id`, `event_type`, `prev_event_hash`, `event_hash` | `id`, `interactionId`, `eventType`, `hash`, `prevHash`, `payloadHash` | -| Error categories | 12 categories including `economic`, `settlement`, `versioning` | 9 categories: `validation`, `permission`, `policy`, `timeout`, `network`, `execution`, `payment`, `auth`, `evidence` | -| ApprovalDecision values | `'approve'`, `'reject'`, `'modify'` | `"approved"`, `"rejected"`, `"modified"` | - -**Impact:** The AOSL monorepo is **not interchangeable** with the OAPS monorepo. Any attempt to merge them without a type bridge will cause breakage. - -### Schema Inconsistency (Documented in Repo) - -- `AUDIT-FIX-LIST.md` line 67 notes: schemas use `"$ref": "foundation/common.json#/$defs/..."` with relative path prefix, but `$id` uses `https://oaps.dev/schemas/foundation/...`. This inconsistency may confuse JSON Schema tools. - -### Maturity Downgrades - -- `docs/MATURITY-MATRIX.md` and `AUDIT-MATURITY-MATRIX.md` show that ACP, AP2, MPP, and UCP profiles were **downgraded from "Draft" to "Concept"** on 2026-04-11 because they are 2.4-3.2 KB stubs with minimal substance and no runtime backing. - -### Naming Drift - -- The repository is called `OAPS`, the protocol is branded `AICP`, and earlier drafts used the name `Pact` (retired to avoid confusion with `pact.io`). This creates potential confusion in imports, URLs, and documentation. - -### AOSL vs AICP Boundary Ambiguity - -- `docs/AICP-AOSL-BOUNDARY.md` exists to clarify the boundary, but the presence of two monorepos with overlapping concerns (`runtime`, `evidence`, `policy`, `core`) suggests the separation is still being negotiated. +## 1. Repo Purpose -### Well-Known Path Inconsistency +OAPS/AICP is a protocol suite for cross-protocol agentic control-plane semantics: identity references, delegation, mandates, intents, tasks, approvals, execution outcomes, evidence, and payment coordination. It contains specs, schemas, examples, conformance fixtures, and TypeScript reference implementations. -- Discovery draft (`spec/core/DISCOVERY-DRAFT.md`) specifies `/.well-known/aicp.json` -- HTTP binding and legacy examples use `/.well-known/oaps.json` -- Both paths are implemented in different packages (`discovery` vs `http`) +Local evidence: ---- +- `/Users/efebarandurmaz/OAPS/README.md` +- `/Users/efebarandurmaz/OAPS/spec/core/FOUNDATION-DRAFT.md` +- `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/package.json` -## 11. Recommended Action for FIDES v2 +## 2. Main Packages / Modules -1. **Port OAPS core primitives into FIDES namespace** — Do not depend on `@oaps/core` as a package. Instead, adapt the concepts (DelegationToken, PolicyBundle, EvidenceEvent, ActorCard, CapabilityCard, ApprovalRequest, ApprovalDecision) into `@fides/core` with FIDES-compatible naming and signing. -2. **Use OAPS schemas as compatibility reference** — Maintain a mapping document showing how FIDES types correspond to OAPS/AICP schemas. -3. **Adopt OAPS version negotiation logic** (`negotiateVersion`) for FIDES protocol handshake. -4. **Adopt OAPS error taxonomy** (`ErrorObject` with 12 categories) as the basis for FIDES typed errors, extended with FIDES-specific categories. -5. **Reuse OAPS evidence chain pattern** (`@oaps/evidence`) — port the hash-linked builder/verifier into `@fides/evidence`. -6. **Reuse OAPS policy evaluator pattern** (`@oaps/policy`) — port the deterministic expression evaluator into `@fides/policy`. -7. **Avoid the AOSL monorepo types** — they are incompatible with OAPS types. Use OAPS monorepo as the canonical reference. -8. **Use OAPS handshake protocol** as the basis for FIDES session establishment, with FIDES-specific extensions for trust score and runtime attestation. +| Area | Path | Purpose | +|---|---|---| +| Reference monorepo | `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/` | Primary TS reference workspace. | +| Core | `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/core/src/index.ts` | Types, IDs, version negotiation, canonical JSON hashing, auth binding checks, state transitions, mandate and approval guards. | +| Evidence | `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/evidence/src/index.ts` | Append-only hash-linked evidence chain builder/verifier. | +| Policy | `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/policy/src/index.ts` | Deterministic JSONLogic-style policy evaluator with fail-closed errors and context hashing. | +| MCP adapter | `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/mcp-adapter/src/index.ts` | MCP tool discovery, capability mapping, policy enforcement, approval gating, invocation, evidence emission. | +| HTTP reference | `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/http/src/index.ts` | Well-known discovery, actor cards, capabilities, interactions, approval/reject/revoke, evidence, events, idempotency. | +| Discovery | `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/discovery/src/index.ts` | Actor-card discovery and capability matching. | +| Profile adapters | `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/a2a-adapter`, `auth-web-adapter`, `x402-adapter`, `webhook-binding`, `websocket-binding` | Profile/transport adapters. | +| AOSL draft | `/Users/efebarandurmaz/OAPS/reference/aosl-monorepo/` | Separate draft runtime/IR/CLI line. | +| Python starter | `/Users/efebarandurmaz/OAPS/reference/oaps-python/pyproject.toml` | Minimal Python interop starter. | + +## 3. Existing Primitives + +- Actor/identity references exist through `ActorRef`, `ActorCard`, `identity_profile`, and `trust_credentials`: `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/core/src/index.ts`, `/Users/efebarandurmaz/OAPS/schemas/actor-card.json`, `/Users/efebarandurmaz/OAPS/spec/core/FOUNDATION-DRAFT.md`. +- DID is permitted as method-agnostic actor ID/profile, but I could not find DID resolution or DID signature verification runtime. +- Generic `Proof` exists on envelopes; webhook HMAC signing/verification exists. HTTP Signature support is documented as missing: `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/core/src/index.ts`, `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/webhook-binding/src/index.ts`, `/Users/efebarandurmaz/OAPS/PROTOCOL-GAP-ANALYSIS.md`. +- `auth-fides-tap` profile draft defines trust tiers, attestation semantics, stronger actor binding, signed/attested delegation chains, and explicitly notes no dedicated FIDES/TAP verifier exists: `/Users/efebarandurmaz/OAPS/profiles/auth-fides-tap-draft.md`. +- Capability discovery and matching exist: `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/discovery/src/index.ts`; `.well-known/oaps.json` exists in HTTP reference: `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/http/src/index.ts`. +- `DelegationToken`, mandate, approval request/decision, and revoke flows exist: `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/core/src/index.ts`, `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/http/src/index.ts`. +- Policy context covers intent, actor, capability, delegation, approval, environment, economic, merchant, risk, and evidence namespaces: `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/policy/src/index.ts`. +- Evidence events are hash-linked with previous and current event hashes; HTTP exposes evidence/events replay: `/Users/efebarandurmaz/OAPS/schemas/foundation/evidence-event.json`, `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/evidence/src/index.ts`, `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/http/src/index.ts`. +- MCP runtime is the stable implementation-backed slice; A2A/x402/auth-web/webhook packages exist but are partial/profile-level in places. + +## 4. Relevant Files + +- `/Users/efebarandurmaz/OAPS/README.md` +- `/Users/efebarandurmaz/OAPS/PROTOCOL-GAP-ANALYSIS.md` +- `/Users/efebarandurmaz/OAPS/VERSIONING.md` +- `/Users/efebarandurmaz/OAPS/profiles/auth-fides-tap-draft.md` +- `/Users/efebarandurmaz/OAPS/spec/core/FOUNDATION-DRAFT.md` +- `/Users/efebarandurmaz/OAPS/schemas/actor-card.json` +- `/Users/efebarandurmaz/OAPS/schemas/capability-card.json` +- `/Users/efebarandurmaz/OAPS/schemas/foundation/actor.json` +- `/Users/efebarandurmaz/OAPS/schemas/foundation/capability.json` +- `/Users/efebarandurmaz/OAPS/schemas/foundation/mandate.json` +- `/Users/efebarandurmaz/OAPS/schemas/foundation/evidence-event.json` +- `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/core/src/index.ts` +- `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/evidence/src/index.ts` +- `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/policy/src/index.ts` +- `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/discovery/src/index.ts` +- `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/http/src/index.ts` +- `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/mcp-adapter/src/index.ts` +- `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/auth-web-adapter/src/index.ts` +- `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/x402-adapter/src/index.ts` +- `/Users/efebarandurmaz/OAPS/reference/oaps-monorepo/packages/webhook-binding/src/index.ts` + +## 5. Reusable Components + +- Use OAPS as semantic source for actor refs, delegation, mandate, approval, envelopes, versioning, canonical JSON, hashes, state transitions, policy context, and error taxonomy. +- Reuse OAPS evidence shape as a minimal hash-linked event model, but FIDES must add signed events, privacy modes, Merkle/export, append-only persistence, and verification. +- Reuse OAPS policy evaluator ideas, but FIDES must add trust/reputation/runtime/revocation/kill-switch-aware authority evaluation. +- Reuse MCP adapter flow as FIDES interop adapter inspiration. +- Reuse auth-web subject binding shape as an adapter, not as cryptographic identity. + +## 6. Missing Components + +I could not find: + +- Ed25519 implementation. +- DID resolver or DID document validation. +- `did:key` support. +- Signed delegation/mandate envelopes with FIDES-level verification. +- Cryptographic attestation verification runtime. +- Trust graph or reputation runtime. +- Merkle tree. +- Append-only ledger backend. +- Incident model. +- TEE runtime attestation. +- DHT, relay, federation runtime. +- Generic privacy runtime. +- Dedicated kill-switch primitive beyond revoke/fail-closed semantics. +- GitHub Actions CI workflows; `.github` appears to contain ownership/issue template files only. + +## 7. Conflicts With FIDES v2 Architecture + +- Naming/version split: public README says AICP, repo/code still says OAPS, and version constants differ across docs and generated constants. +- Schema drift exists between foundation schemas and generated legacy constants for actor and capability kinds. +- Mandate spec/type/schema fields differ (`authorized_actor`/`scope`/`expiry` vs `principal`/`delegatee`/`action`/`expires_at`). +- Reference auth relies heavily on bearer/session subject binding and webhook HMAC, so FIDES must not treat OAPS auth as high-assurance identity. +- OAPS includes payment coordination profiles; FIDES should port only generic control semantics and leave payment execution to Sardis. + +## 8. Recommended Action + +Port OAPS concepts into FIDES, but do not depend on `@oaps/core` at runtime. + +Concrete mapping direction: + +- `ActorRef` -> FIDES `PrincipalIdentity` / `AgentIdentity` references. +- `ActorCard` -> FIDES `AgentCard`. +- `CapabilityCard` -> FIDES `CapabilityDescriptor` / ontology entry. +- `DelegationToken` and `Mandate` -> FIDES signed `DelegationToken` and `SessionGrant`. +- `ApprovalRequest` / `ApprovalDecision` -> FIDES approval primitives. +- `EvidenceEvent` -> FIDES signed privacy-aware evidence event. +- OAPS version negotiation and error taxonomy -> FIDES-owned version/error vocabularies. + +FIDES must add the missing high-assurance layer: DID resolution, Ed25519 signing/verification, trust graph/reputation semantics, revocation registry, runtime attestation, incident handling, DHT/relay/federation, and evidence hardening. diff --git a/docs/inspection/osp-report.md b/docs/inspection/osp-report.md index 7150370..0748f61 100644 --- a/docs/inspection/osp-report.md +++ b/docs/inspection/osp-report.md @@ -1,232 +1,99 @@ # OSP Repository Inspection Report -## 1. Repo Purpose - -**Open Service Protocol (OSP)** is an open standard (Apache 2.0) that enables AI agents to discover, provision, and manage developer services (databases, hosting, auth, analytics, etc.) programmatically without browser-based signup flows. It is positioned as "what MCP is to tool access, OSP is to service provisioning." - -**Key claim:** Payment-rail agnostic, provider-neutral, machine-first protocol with encrypted credential delivery (Ed25519 / x25519-xsalsa20-poly1305). - ---- - -## 2. Main Packages / Modules - -| Package | Path | Language | Description | -|---|---|---|---| -| **Spec** | `spec/osp-v1.0.md` | Markdown | Core protocol specification (~9,600 lines, v1.1 draft) | -| **Schemas** | `schemas/` | JSON Schema | Draft 2020-12 schemas + 14 example manifests | -| **osp-core** | `osp-core/crates/` | Rust (8 crates) | Rust workspace: crypto, manifest, vault, CLI, provider adapters, registry, conformance, SDK | -| **TypeScript SDK** | `reference-implementation/typescript/` | TypeScript | `@osp/client` v0.2.0 — client, types, crypto, resolver, MCP server, plugins | -| **Python SDK** | `reference-implementation/python/` | Python | `osp-client` v0.2.0 — async client, Pydantic types, FastAPI/Django integrations | -| **Go SDK** | `osp-sdk-go/` | Go | `osp-sdk-go` — full client + provider + crypto (142 tests) | -| **Provider Framework (TS)** | `packages/provider-framework/typescript/` | TypeScript | Express middleware for building OSP providers | -| **Provider Framework (Py)** | `packages/provider-framework/python/` | Python | FastAPI router for building OSP providers | -| **MCP Server** | `packages/mcp-server/` | TypeScript | `@osp/mcp-server` — MCP tools exposing OSP operations | -| **Sardis Integration** | `sardis-integration/` | TypeScript | Payment rail, MCP extension, CLI bridge for Sardis | -| **Conformance Tests** | `conformance-tests/python/` | Python | pytest conformance suite (crypto, identity, sandbox, etc.) | -| **Skills** | `skills/` | Markdown | 10 provider skill files (Supabase, Neon, Vercel, Clerk, etc.) | -| **Examples** | `examples/` | YAML | `osp.yaml` project examples (Next.js + Supabase + Clerk, Python + Neon + Resend) | -| **Website** | `website/` | Next.js | Marketing site | -| **Docs** | `docs/` | Markdown | Getting started, provider/agent guides, security model, IETF draft | - ---- - -## 3. Existing Primitives (with Exact File Paths) - -### A. Core Types / Schema Primitives - -| Primitive | File Path | -|---|---| -| `ServiceManifest` | `schemas/service-manifest.schema.json` | -| `ServiceOffering` | Embedded in schema above + SDK types | -| `ServiceTier` | Embedded in schema above + SDK types | -| `ProvisionRequest` | `schemas/provision-request.schema.json` | -| `ProvisionResponse` | `schemas/provision-response.schema.json` | -| `CredentialBundle` | `schemas/credential-bundle.schema.json` | -| `UsageReport` | `schemas/usage-report.schema.json` | -| `WebhookEvent` | `schemas/webhook-event.schema.json` | -| `HealthResponse` | `schemas/health-response.schema.json` | -| `CostSummary` | `schemas/cost-summary.schema.json` | -| `ErrorResponse` | `schemas/error-response.schema.json` | - -### B. SDK Type Definitions - -| Language | File Path | Notes | -|---|---|---| -| TypeScript | `reference-implementation/typescript/src/types.ts` | 861 lines, exhaustive v1.1 + v1.2 types | -| Python | `reference-implementation/python/src/osp/types.py` | 863 lines, Pydantic v2 models | -| Go | `osp-sdk-go/types.go` | 765 lines, structs + enums | - -### C. Client SDKs - -| Language | File Path | Methods Implemented | -|---|---|---| -| TypeScript | `reference-implementation/typescript/src/client.ts` | `discover`, `discoverFromRegistry`, `provision`, `getCredentials`, `rotateCredentials`, `getStatus`, `deprovision`, `getUsage`, `checkHealth`, `getHealth`, `getCostSummary`, `estimate`, `dispute`, `getEvents`, `registerWebhook`, `deleteWebhook`, `exportResource`, `clearCache` | -| Python | `reference-implementation/python/src/osp/client.py` | Same set as TypeScript (async) | -| Go | `osp-sdk-go/client.go` | `Discover`, `DiscoverAndVerify`, `Provision`, `Deprovision`, `Rotate`, `Status`, `Usage`, `Health`, `Credentials`, `GetEvents`, `RegisterWebhook`, `DeleteWebhook`, `Estimate`, `Dispute`, `ExportResource` | -| Rust | `osp-core/crates/osp-sdk/src/client.rs` | `discover`, `provision`, `deprovision`, `health`, `decrypt_credentials`, `verify_manifest`, `list_providers` (minimal) | - -### D. Crypto Primitives - -| Language | File Path | Primitives | -|---|---|---| -| Rust | `osp-core/crates/osp-crypto/src/` | Ed25519 signing, x25519 key agreement, xsalsa20-poly1305 encryption, canonical JSON, base64url encoding | -| Go | `osp-sdk-go/crypto.go` | KeyPair generation, Ed25519 signing/verification, x25519 ECDH, NaCl box encryption/decryption | -| TypeScript | `reference-implementation/typescript/src/crypto.ts` | `verifyEd25519`, `canonicalJson`, `decryptCredentials`, `generateAgentKeyPair`, `base64urlEncode`/`Decode` | +Source repo: `/Users/efebarandurmaz/osp` -### E. CLI Entrypoints +Note: the OSP worktree is dirty and behind origin. This report reflects current local files and is read-only evidence for FIDES v2. -| CLI | File Path | Status | -|---|---|---| -| Rust CLI | `osp-core/crates/osp-cli/src/main.rs` | Entrypoint | -| Rust CLI commands | `osp-core/crates/osp-cli/src/commands/mod.rs` | **Mostly stubs** — `init`, `discover`, `provision` have some impl; `status`, `deprovision`, `rotate`, `estimate`, `setup`, `apply`, `drift`, `join`, `import`, `share`, `onboard` print placeholder text | -| Rust CLI parser | `osp-core/crates/osp-cli/src/cli.rs` | 18 commands defined with `clap` | -| MCP Server CLI | `packages/mcp-server/package.json` | `"bin": { "osp-mcp-server": "dist/cli.js" }` | -| Sardis CLI | `sardis-integration/package.json` | Exports `./cli` | - -### F. Provider Adapters / Frameworks - -| Framework | File Path | Description | -|---|---|---| -| Rust adapters | `osp-core/crates/osp-provider/src/adapters/` | 8 adapters: neon, posthog, railway, resend, supabase, turso, upstash, vercel (+ rest_client) | -| Rust provider port | `osp-core/crates/osp-provider/src/port.rs` | Trait definition (`OSPProviderAdapter`) | -| TS provider framework | `packages/provider-framework/typescript/src/middleware.ts` | Express middleware (`createOSPProvider`) | -| Py provider framework | `packages/provider-framework/python/src/osp_provider/router.py` | FastAPI router (`create_osp_router`) | - -### G. Registry +## 1. Repo Purpose -| Component | File Path | Description | -|---|---|---| -| Registry server | `osp-core/crates/osp-registry/src/server.rs` | Axum server | -| Registry routes | `osp-core/crates/osp-registry/src/routes.rs` | REST routes | -| Registry models | `osp-core/crates/osp-registry/src/models.rs` | SQLite-backed models | -| Registry DB | `osp-core/crates/osp-registry/src/db.rs` | SQLite connection | +OSP is an open protocol for agent-driven service discovery, provisioning, credential delivery, rotation, deprovisioning, registry lookup, conformance, SDKs, MCP tooling, and provider integrations. -### H. Vault / Credential Storage +Local evidence: -| Component | File Path | Description | -|---|---|---| -| Rust vault | `osp-core/crates/osp-vault/src/store.rs` | AES-256-GCM encrypted credential store with keyring integration | -| Rust vault env | `osp-core/crates/osp-vault/src/env.rs` | Env var generation from credentials | -| Rust vault resolver | `osp-core/crates/osp-vault/src/resolver.rs` | OSP URI resolver (`osp://provider/offering/credential_key`) | +- Core framing: `/Users/efebarandurmaz/osp/README.md`, `/Users/efebarandurmaz/osp/spec/osp-v1.0.md`. +- Main Rust workspace: `/Users/efebarandurmaz/osp/osp-core/Cargo.toml`. -### I. Resolver / URI Scheme +## 2. Main Packages / Modules -| Component | File Path | Description | +| Area | Path | Purpose | |---|---|---| -| TypeScript | `reference-implementation/typescript/src/resolver.ts` | `OSPResolver`, `parseOSPUri`, `buildOSPUri`, `isOSPUri` | -| Rust | `osp-core/crates/osp-vault/src/resolver.rs` | URI resolution | - ---- - -## 4. What Is Reusable - -### Highly Reusable -- **JSON Schemas** (`schemas/`) — These are the canonical definitions. Any new implementation should import these directly. -- **TypeScript Types** (`reference-implementation/typescript/src/types.ts`) — Exhaustive, well-commented, covers v1.1 and v1.2 extensions. -- **Python Types** (`reference-implementation/python/src/osp/types.py`) — Pydantic v2 models with validation; reusable for any Python service. -- **Go Types + Client** (`osp-sdk-go/types.go`, `client.go`) — Production-quality HTTP client with retries, typed errors, and full lifecycle methods. -- **Crypto implementations** — Go (`crypto.go`) and Rust (`osp-crypto`) have complete Ed25519/x25519/xsalsa20-poly1305 stacks. -- **Provider Framework types** — Both TS and Python frameworks export reusable `ServiceManifest`, `ProvisionRequest`, `ProvisionResponse`, etc. - -### Partially Reusable -- **Rust `osp-sdk`** (`osp-core/crates/osp-sdk/src/client.rs`) — Very minimal; only basic discover/provision/deprovision/health. Missing most v1.1 features. -- **Rust `osp-cli`** — Command structure is well-designed (18 commands), but implementations are stubs. The `clap` definitions in `osp-core/crates/osp-cli/src/cli.rs` are reusable as a CLI spec. -- **MCP Server** (`packages/mcp-server/src/`) — Exposes OSP tools for Claude/GPT agents; reusable if you need MCP integration. -- **Provider Adapters** (Rust) — The adapter trait in `osp-core/crates/osp-provider/src/port.rs` is a good reference, but the 8 concrete adapters are mostly mock/stub implementations returning hardcoded JSON. - ---- - -## 5. What Is Missing - -### Critical Gaps in Implementation - -1. **No actual ownership primitive in code** - - The spec mentions `principal_id` and "resource ownership" in terminology (`spec/osp-v1.0.md` line 282), but **no SDK or framework models an `Owner` or `Ownership` type**. Ownership is only a conceptual spec term. - -2. **Rust CLI is largely unimplemented** - - `osp-core/crates/osp-cli/src/commands/mod.rs` contains extensive stubs. Commands like `status`, `deprovision`, `rotate`, `upgrade`, `estimate`, `setup`, `apply`, `drift`, `join`, `import`, `share`, `onboard` do nothing except `println!`. - -3. **Rust SDK lacks most v1.1 features** - - No A2A delegation, NHI, FinOps, cost summary, events, webhooks, disputes, export, or estimate support in `osp-core/crates/osp-sdk/src/client.rs`. - -4. **No real registry deployment / runtime** - - The registry crate (`osp-registry`) has server code but there is no evidence of a running instance or seed data. The Dockerfile and fly.toml exist but no deployed artifact. - -5. **Provider adapters are stubs** - - Adapters in `osp-core/crates/osp-provider/src/adapters/` return hardcoded credential JSON and do not make actual API calls to provider services (e.g., Vercel adapter returns `{"vercel_token": "fake_token"}`). - -6. **No actual `osp.yaml` parser / runtime** - - Examples exist (`examples/*/osp.yaml`) but no CLI or library actually parses and provisions from these files. The `Apply` command in the Rust CLI is a stub. - -7. **No TypeScript Config (`osp.config.ts`) implementation** - - The README advertises "TypeScript Config — `osp.config.ts` with Pulumi-style programmatic configuration." **Not found anywhere in the repository.** - -8. **Missing schema files** - - No dedicated `cost-summary.schema.json` at root level (only referenced in code). - - No `a2a-delegation.schema.json`, `nhi-token.schema.json`, or `finops-config.schema.json` as standalone files. - -9. **No actual payment integration** - - The `sardis-integration` package exists but is mostly scaffolding. No real payment flow, escrow contract interaction, or settlement logic. - -10. **No Python/TypeScript vault implementation** - - Only Rust (`osp-vault`) has credential vault logic. Python and TypeScript SDKs have no encrypted local credential store. - -11. **No `lifecycle` module as a first-class abstraction** - - While "lifecycle" is used as a term (status, provisioning, deprovisioning), there is no unified `LifecycleManager` or state machine in any SDK. - -12. **No `ownership` transfer endpoint implemented** - - Spec mentions cross-project resource sharing and `principal_id`, but no `POST /osp/v1/share/{resource_id}` or ownership transfer is actually implemented in any SDK. - ---- - -## 6. Conflicts & Issues Noticed - -### A. Version Inconsistencies -- **README** says "Spec v1.1 Features" and badge says `v1.1--draft`. -- **Spec file** is named `osp-v1.0.md` but contains v1.1 sections. -- **TypeScript types** mention `v1.2` in comments (`// v1.2: agent identity, sandbox mode, idempotency`) but there is no v1.2 spec document. -- **Go SDK** has `userAgent = "osp-sdk-go/1.0"` while the README claims it supports v1.1. - -### B. Schema vs. Code Drift -- The JSON Schema (`service-manifest.schema.json`) uses `mf_[a-z0-9_]+$` pattern for `manifest_id`, but the spec text example uses a UUID v4 (`a1b2c3d4-e5f6-7890-abcd-ef1234567890`). These are incompatible. -- The spec's `ProvisionRequest` has a `metadata` object with structured tag conventions, but the JSON Schema (`provision-request.schema.json`) does not include a `metadata` field at all. -- Go `types.go` defines `CredentialType` but is missing `"short_lived_token"` (the schema has it, TypeScript has it, Go does not). - -### C. Checked-in Dependencies -- **`node_modules`** checked into git: - - `sardis-integration/node_modules` - - `website/node_modules` - - `reference-implementation/typescript/node_modules` -- **`.venv`** checked into git: - - `reference-implementation/python/.venv` -- These should be `.gitignore`d but are present. The `.gitignore` at root only ignores `.DS_Store` and `tmp/`. - -### D. Duplicate / Orphan Content -- **`.claude/worktrees/`** directory contains what appears to be multiple agent worktree copies of the same files (spec, docs, LICENSE). These are not part of the main repo structure and appear to be artifacts from Claude Code sessions. They duplicate content and bloat the repo. - -### E. Package Naming Conflicts -- The TypeScript SDK is named `@osp/client` in `reference-implementation/typescript/package.json`, but the Rust CLI binary is named `osp`. No actual conflict, but the namespace is shared. -- The MCP server is `@osp/mcp-server` but depends on `@modelcontextprotocol/sdk` as a peer dependency. The Sardis integration also peer-depends on `@osp/client >=0.1.0` but the actual client is `0.2.0`. - -### F. Missing Tests for Rust Core -- The README claims 46 tests for Rust, but many crates (`osp-cli`, `osp-registry`, `osp-sdk`, `osp-provider`) contain minimal or stub test implementations. The `osp-cli` commands have no real tests. - -### G. Endpoint Path Inconsistencies -- The **getting-started docs** (`docs/getting-started.md`) list endpoints like `/osp/v1/resources/{id}/credentials` and `/osp/v1/resources/{id}/upgrade`. -- The **spec** lists `/osp/v1/credentials/{resource_id}` and has no `upgrade` endpoint (only tier change within `ProvisionRequest`). -- The **TypeScript client** uses whatever path is in the manifest endpoints (e.g., `:resource_id`), while the **Go SDK** uses the manifest's endpoint paths directly. No single canonical path is enforced across docs, spec, and code. - -### H. Cargo.toml Claims vs. Reality -- `Cargo.toml` lists `osp-crypto`, `osp-manifest`, `osp-vault`, `osp-cli`, `osp-provider`, `osp-registry`, `osp-conformance`, `osp-sdk`. -- Several of these crates have incomplete implementations (CLI stubs, provider adapters returning fake data, registry not deployed). - ---- - -## 7. Recommended Action for FIDES v2 - -1. **Reuse OSP registry concept** as inspiration for FIDES Agent Registry — the Axum-based registry server pattern (`osp-registry/src/server.rs`) is a good reference. -2. **Reuse OSP service lifecycle semantics** (provision, rotate, deprovision, status) for FIDES agent/service lifecycle management. -3. **Reuse JSON Schema discipline** — OSP has well-structured schemas. FIDES v2 should adopt the same rigor for all protocol objects. -4. **Reuse credential rotation pattern** from OSP for FIDES key rotation and delegation token rotation. -5. **Do NOT depend on OSP Rust crates** — they are largely stubs. Port concepts into TypeScript. -6. **Consider OSP's `ServiceManifest` / `ServiceOffering` pattern** as a model for FIDES `CapabilityDescriptor` and `AgentCard` versioning. +| Rust workspace | `/Users/efebarandurmaz/osp/osp-core/Cargo.toml` | `osp-crypto`, `osp-manifest`, `osp-vault`, `osp-cli`, `osp-provider`, `osp-registry`, `osp-conformance`, `osp-sdk`. | +| TypeScript reference SDK | `/Users/efebarandurmaz/osp/reference-implementation/typescript/src/index.ts` | Client, types, crypto, resolver, MCP server exports. | +| Python SDK | `/Users/efebarandurmaz/osp/reference-implementation/python/src/osp/__init__.py` | Types, client, manifest, resolver, provider. | +| Go SDK | `/Users/efebarandurmaz/osp/osp-sdk-go/types.go` | Discovery/provisioning/credential/usage types. | +| MCP server | `/Users/efebarandurmaz/osp/packages/mcp-server/src/server.ts` | Discover, provision, status, deprovision, rotate tools. | +| Provider frameworks | `/Users/efebarandurmaz/osp/packages/provider-framework/typescript/src/middleware.ts`, `/Users/efebarandurmaz/osp/packages/provider-framework/python/src/osp_provider/router.py` | Express/FastAPI provider scaffolding. | +| Sardis integration | `/Users/efebarandurmaz/osp/sardis-integration/src/payment/types.ts` | Wallet, escrow, mandate, ledger models. | + +## 3. Existing Primitives + +- Identity methods are specified for Ed25519 DID, OAuth2 client, and API key identities in `/Users/efebarandurmaz/osp/spec/osp-v1.0.md`; Rust has `AgentIdentity` and `AgentIdentityMethod` in `/Users/efebarandurmaz/osp/osp-core/crates/osp-manifest/src/types.rs`. +- Signature/canonical/Ed25519 primitives exist in Rust crypto: `/Users/efebarandurmaz/osp/osp-core/crates/osp-crypto/src/lib.rs`, `/Users/efebarandurmaz/osp/osp-core/crates/osp-crypto/src/signing.rs`, `/Users/efebarandurmaz/osp/osp-core/crates/osp-crypto/src/canonical.rs`. +- Credential encryption exists via Ed25519-to-X25519 and XSalsa20-Poly1305 in `/Users/efebarandurmaz/osp/osp-core/crates/osp-crypto/src/encryption.rs`; JSON schema exists at `/Users/efebarandurmaz/osp/schemas/credential-bundle.schema.json`. +- Discovery/registry primitives include well-known manifest fetching and registry models: `/Users/efebarandurmaz/osp/osp-core/crates/osp-manifest/src/fetch.rs`, `/Users/efebarandurmaz/osp/osp-core/crates/osp-registry/src/models.rs`. +- Delegation/capability shapes exist as A2A capabilities and delegation chain structs: `/Users/efebarandurmaz/osp/osp-core/crates/osp-manifest/src/types.rs`. +- Trust/reputation exists as trust tier and registry reputation metadata: `/Users/efebarandurmaz/osp/osp-core/crates/osp-manifest/src/types.rs`, `/Users/efebarandurmaz/osp/osp-core/crates/osp-registry/src/models.rs`. +- Event/evidence concepts are schema/spec-level lifecycle events and dispute/compliance evidence, not a FIDES evidence ledger: `/Users/efebarandurmaz/osp/schemas/webhook-event.schema.json`, `/Users/efebarandurmaz/osp/spec/osp-v1.0.md`. +- Sardis mandate/policy/approval examples exist in `/Users/efebarandurmaz/osp/sardis-integration/src/payment/types.ts`. +- Revocation is documented in `/Users/efebarandurmaz/osp/docs/security-model.md` and `/Users/efebarandurmaz/osp/spec/osp-v1.0.md`, but no standalone revocation registry/service was found. + +## 4. Relevant Files + +- `/Users/efebarandurmaz/osp/README.md` +- `/Users/efebarandurmaz/osp/spec/osp-v1.0.md` +- `/Users/efebarandurmaz/osp/osp-core/Cargo.toml` +- `/Users/efebarandurmaz/osp/osp-core/crates/osp-crypto/src/canonical.rs` +- `/Users/efebarandurmaz/osp/osp-core/crates/osp-crypto/src/signing.rs` +- `/Users/efebarandurmaz/osp/osp-core/crates/osp-crypto/src/encryption.rs` +- `/Users/efebarandurmaz/osp/osp-core/crates/osp-manifest/src/types.rs` +- `/Users/efebarandurmaz/osp/osp-core/crates/osp-manifest/src/fetch.rs` +- `/Users/efebarandurmaz/osp/osp-core/crates/osp-manifest/src/verify.rs` +- `/Users/efebarandurmaz/osp/osp-core/crates/osp-registry/src/models.rs` +- `/Users/efebarandurmaz/osp/packages/mcp-server/src/server.ts` +- `/Users/efebarandurmaz/osp/schemas/service-manifest.schema.json` +- `/Users/efebarandurmaz/osp/schemas/provision-request.schema.json` +- `/Users/efebarandurmaz/osp/schemas/credential-bundle.schema.json` +- `/Users/efebarandurmaz/osp/schemas/webhook-event.schema.json` +- `/Users/efebarandurmaz/osp/sardis-integration/src/payment/types.ts` +- `/Users/efebarandurmaz/osp/sardis-integration/src/payment/ledger.ts` + +## 5. Reusable Components + +- Canonical JSON and Ed25519 signing/verification as comparison material for FIDES signed protocol objects. +- Manifest verification flow for signed registry/discovery objects. +- Identity/delegation/NHI schemas as draft input, not final FIDES authority schemas. +- Registry models and service lifecycle vocabulary for FIDES registry/federation and OSP adapter mapping. +- Sardis `SpendingMandate` and `SpendingPolicy` as payment-specific examples of grants/mandates, not generic FIDES core. + +## 6. Missing Components + +I could not find: + +- Merkle tree or transparency log. +- Tamper-evident FIDES-style evidence ledger. +- TEE/runtime attestation implementation. +- DHT or relay discovery. +- Federation runtime. +- Kill-switch primitives. +- Generic signed action/envelope model separate from OSP manifests, usage reports, credential bundles, and webhooks. +- Implemented DID resolution or nonce-signature verification beyond structural/offline conformance assertions. + +## 7. Conflicts With FIDES v2 Architecture + +- OSP spec says agent identity is a non-goal in one place while later formalizing OSP agent identity methods. FIDES must own generic identity/authority, while OSP owns service lifecycle. +- JSON Schema and Rust manifest shapes drift, especially around provider fields. +- `AgentIdentity` exists under `$defs` in a provision-request schema but is not exposed as a top-level accepted property. +- Crypto algorithm descriptions differ between Rust/spec and TypeScript helper paths. +- OSP service provisioning, credential delivery, pricing, provider manifest, and deprovisioning are adjacent but not FIDES core. + +## 8. Recommended Action + +Use OSP as the source for service lifecycle and registry/provisioning adapter semantics: + +- discovery/provision/rotate/deprovision vocabulary, +- signed service manifest verification ideas, +- provider registry/search concepts, +- credential rotation/deprovisioning boundaries, +- MCP server integration shape. + +Do not directly import OSP schemas into FIDES v2 authority objects. FIDES should define its own `AgentCard`, `DelegationToken`, `SessionGrant`, `EvidenceEvent`, `RevocationRecord`, and `TrustGraphEdge`, then provide an OSP adapter that maps FIDES authority to OSP service lifecycle operations. diff --git a/docs/inspection/sardis-report.md b/docs/inspection/sardis-report.md index 3e2ef1b..ba2d2ae 100644 --- a/docs/inspection/sardis-report.md +++ b/docs/inspection/sardis-report.md @@ -1,300 +1,105 @@ # Sardis Repository Inspection Report -## 1. Repo Purpose - -**Sardis** is a Payment OS for the Agent Economy. It provides non-custodial MPC wallets with natural language spending policies for AI agents. The stack prevents financial hallucinations via a real-time policy firewall, executes stablecoin payments (primarily USDC) on **Base** and **Tempo**, supports multi-chain funding via CCTP v2, virtual cards via Stripe Issuing, and protocol gateways for AP2, TAP, x402, A2A, and UCP. - -**Build systems used:** -- **Python**: `uv` + `hatchling` (root `pyproject.toml` and per-package `pyproject.toml` files) -- **JS/TS**: `pnpm` monorepo (`pnpm-workspace.yaml`, root `package.json`) -- **Contracts**: Foundry (`contracts/foundry.toml`) - ---- - -## 2. Main Packages / Modules - -### Core Infrastructure (Python) - -| Package | Path | Purpose | -|---------|------|---------| -| `sardis-core` | `packages/sardis-core` | Domain models, policy engine, orchestrator, state machine, exceptions | -| `sardis-api` | `packages/sardis-api` | FastAPI REST API (v2), routers, middleware, repositories | -| `sardis-chain` | `packages/sardis-chain` | Blockchain execution, chain routing, Tempo integration, CCTP | -| `sardis-protocol` | `packages/sardis-protocol` | AP2/TAP/x402 mandate verification, reason codes | -| `sardis-ledger` | `packages/sardis-ledger` | Append-only audit ledger, Merkle anchoring, reconciliation | -| `sardis-compliance` | `packages/sardis-compliance` | KYC/AML/SAR (Didit, Elliptic, Chainalysis, OFAC, etc.) | -| `sardis-wallet` | `packages/sardis-wallet` | Wallet management, MPC via Turnkey, spending limits | -| `sardis-cards` | `packages/sardis-cards` | Virtual cards (Stripe Issuing) | -| `sardis-checkout` | `packages/sardis-checkout` | Merchant checkout flows | -| `sardis-guardrails` | `packages/sardis-guardrails` | Kill switch, anomaly engine, fraud detection, transaction caps | -| `sardis-mpp` | `packages/sardis-mpp` | Multi-party payments (Stripe LASO virtual cards) | -| `sardis-a2a` | `packages/sardis-a2a` | Agent-to-agent protocol (agent cards, discovery, messages) | -| `sardis-ucp` | `packages/sardis-ucp` | Unified Commerce Protocol adapters | -| `sardis-ramp` | `packages/sardis-ramp` | On/off ramp | - -### SDKs & Clients - -| Package | Path | Purpose | -|---------|------|---------| -| `sardis-sdk-python` | `packages/sardis-sdk-python` | Production Python SDK (sync + async) | -| `sardis-sdk-js` | `packages/sardis-sdk-js` | TypeScript SDK (CJS + ESM + browser UMD) | -| `sardis` (top-level) | `sardis/` | Simplified public Python SDK (simulation mode + delegates to `sardis-sdk`) | -| `sardis-cli` | `packages/sardis-cli` | Python CLI (`sardis` command) | -| `sardis-mcp-server` | `packages/sardis-mcp-server` | MCP server for Claude/Cursor/ChatGPT | - -### Framework Integrations (Python) - -`sardis-langchain`, `sardis-crewai`, `sardis-openai-agents`, `sardis-adk`, `sardis-autogpt`, `sardis-browser-use`, `sardis-composio`, `sardis-guardrails`, `sardis-coinbase`, `sardis-striga`, `sardis-lightspark`, `sardis-activepieces`, `sardis-agentkit`, `sardis-e2b`, `sardis-gpt`, `sardis-openclaw`, `sardis-stagehand`, `sardis-telegram-bot`, `n8n-nodes-sardis` - -### Frontend / Apps - -| Package | Path | -|---------|------| -| `app-landing` | `apps/landing` | -| `app-dashboard` | `apps/dashboard` | -| `canvas-site` | `apps/canvas-site` | -| `docs-site` | `docs-site` | -| `sardis-checkout-ui` | `packages/sardis-checkout-ui` | - -### Smart Contracts - -Located at `contracts/` (Foundry). Key contracts: -- `SardisPolicyModule.sol` -- `SardisLedgerAnchor.sol` -- `RefundProtocol.sol` -- `SardisJobManager.sol` -- `SardisIdentityRegistry.sol` -- `SardisVerifyingPaymaster.sol` - ---- - -## 3. Existing Primitives (with Exact File Paths) - -### Policy & Guardrails - -- **Spending Policy Engine**: `packages/sardis-core/src/sardis_v2_core/spending_policy.py` -- **Natural Language Policy Parser**: `packages/sardis-core/src/sardis_v2_core/nl_policy_parser.py` -- **Policy DSL**: `packages/sardis-core/src/sardis_v2_core/policy_dsl.py` -- **Policy Evidence / Audit Trail**: `packages/sardis-core/src/sardis_v2_core/policy_evidence.py` -- **Policy Attestation (Ed25519 envelopes)**: `packages/sardis-core/src/sardis_v2_core/policy_attestation.py` -- **PreExecutionPipeline**: `packages/sardis-core/src/sardis_v2_core/pre_execution_pipeline.py` -- **Drift Policy Integrator**: `packages/sardis-core/src/sardis_v2_core/drift_policy_integrator.py` -- **Kill Switch**: `packages/sardis-guardrails/src/sardis_guardrails/kill_switch.py` -- **Transaction Caps (auto-triggers kill switch)**: `packages/sardis-guardrails/src/sardis_guardrails/transaction_caps.py` -- **Anomaly Engine**: `packages/sardis-guardrails/src/sardis_guardrails/anomaly_engine.py` - -### Payment & Orchestration - -- **Payment Orchestrator (single chokepoint)**: `packages/sardis-core/src/sardis_v2_core/orchestrator.py` -- **Payment Object (one-time payment primitive)**: `packages/sardis-core/src/sardis_v2_core/payment_object.py` -- **Payment State Machine (22-state lifecycle)**: `packages/sardis-core/src/sardis_v2_core/state_machine.py` -- **Unified Payment**: `packages/sardis-core/src/sardis_v2_core/unified_payment.py` -- **Settlement Lock**: `packages/sardis-core/src/sardis_v2_core/settlement_lock.py` - -### Mandate & Approval - -- **Mandates (Intent/Cart/Payment)**: `packages/sardis-core/src/sardis_v2_core/mandates.py` -- **Spending Mandate**: `packages/sardis-core/src/sardis_v2_core/spending_mandate.py` -- **Mandate Tree / Delegation**: `packages/sardis-core/src/sardis_v2_core/mandate_tree.py` -- **Approval Service**: `packages/sardis-core/src/sardis_v2_core/approval_service.py` -- **Approval Context**: `packages/sardis-core/src/sardis_v2_core/approval_context.py` - -### Ledger & Audit - -- **Ledger Engine**: `packages/sardis-ledger/src/sardis_ledger/engine.py` -- **Merkle Tree**: `packages/sardis-ledger/src/sardis_ledger/merkle_tree.py` -- **On-chain Anchor**: `packages/sardis-ledger/src/sardis_ledger/anchor.py` -- **Immutable Records**: `packages/sardis-ledger/src/sardis_ledger/immutable.py` -- **Reconciliation**: `packages/sardis-ledger/src/sardis_ledger/reconciliation.py` - -### Protocol Verifiers - -- **AP2 Verifier / Schemas**: `packages/sardis-protocol/src/sardis_protocol/verifier.py`, `packages/sardis-protocol/src/sardis_protocol/schemas.py` -- **TAP Validation**: `packages/sardis-protocol/src/sardis_protocol/tap.py` -- **TAP Keys / JWKS**: `packages/sardis-protocol/src/sardis_protocol/tap_keys.py` -- **x402 Protocol**: `packages/sardis-protocol/src/sardis_protocol/x402.py` -- **x402 Settlement**: `packages/sardis-protocol/src/sardis_protocol/x402_settlement.py` -- **Reason Codes (AP2/TAP/x402/UCP)**: `packages/sardis-protocol/src/sardis_protocol/reason_codes.py` - -### Chain Execution - -- **Chain Executor (EVM + ERC-4337)**: `packages/sardis-chain/src/sardis_chain/executor.py` -- **Tempo Executor (type 0x76)**: `packages/sardis-chain/src/sardis_chain/tempo/executor.py` -- **Tempo Fee Payer**: `packages/sardis-chain/src/sardis_chain/tempo/fee_payer.py` -- **CCTP Forwarding**: `packages/sardis-chain/src/sardis_chain/cctp_forwarding.py` -- **Bridge (intent-based, Base<->Tempo)**: `packages/sardis-chain/src/sardis_chain/bridge.py` -- **Gas Optimizer**: `packages/sardis-chain/src/sardis_chain/gas_optimizer.py` - -### Compliance - -- **Compliance Engine**: `packages/sardis-compliance/src/sardis_compliance/checks.py` -- **KYC**: `packages/sardis-compliance/src/sardis_compliance/kyc.py` -- **KYA (Know Your Agent)**: `packages/sardis-compliance/src/sardis_compliance/kya.py` -- **Sanctions**: `packages/sardis-compliance/src/sardis_compliance/sanctions.py` -- **Travel Rule**: `packages/sardis-compliance/src/sardis_compliance/travel_rule.py` - -### Wallet & MPC - -- **Wallet Manager**: `packages/sardis-wallet/src/sardis_wallet/manager.py` -- **Turnkey Client**: `packages/sardis-wallet/src/sardis_wallet/turnkey_client.py` -- **Spending Limits**: `packages/sardis-wallet/src/sardis_wallet/spending_limits.py` +Source repo: `/Users/efebarandurmaz/sardis` ---- +Note: this report is read-only evidence for FIDES v2. Sardis is a consumer/integration source for generic patterns only; payment-specific authority remains in Sardis. -## 4. CLI Entrypoints, SDK Exports, Examples, Tests - -### CLI Entrypoints - -- **Primary CLI (Python)**: `packages/sardis-cli/src/sardis_cli/main.py` - - Commands: `agents`, `wallets`, `payments`, `holds`, `chains`, `policies`, `cards`, `spending`, `mandates`, `approvals`, `ledger`, `fiat`, `groups`, `demo` - - Entry script: `sardis` (installed via pyproject.toml scripts) -- **MCP Server CLI (Node)**: `packages/sardis-mcp-server/src/cli.ts` - - Binary: `sardis-mcp-server` (npm) -- **Placeholder/Minimal CLIs**: - - `sardis-cli-go/` — effectively empty (only `dist/` exists) - - `sardis-cli-js/` — minimal (only `dist/` and `node_modules`) - -### SDK Exports - -- **Python SDK (`sardis-sdk`)**: `packages/sardis-sdk-python/src/sardis_sdk/__init__.py` - - Exports: `SardisClient`, `AsyncSardisClient`, models, errors, pagination, bulk ops -- **TypeScript SDK (`@sardis/sdk`)**: `packages/sardis-sdk-js/src/index.ts` - - Exports: `SardisClient`, errors, types, integrations (LangChain, OpenAI, Vercel AI) -- **Top-level `sardis` package**: `sardis/__init__.py` - - Simulation-first wrapper; re-exports `AsyncSardisClient` when `sardis-sdk` is installed - -### Examples - -Located at `examples/`: -- `quickstart_5min.py` -- `simple_payment.py` -- `agent_to_agent.py` -- `budget_allocation_demo.py` -- `crewai_finance_team.py` -- `langchain_sardis_agent.py` -- `openai_agents_payment.py` -- `google_adk_agent.py` -- `vercel_ai_payment.ts` -- And several subdirectories (`crewai-procurement-team/`, `langchain-payment-agent/`, etc.) - -### Tests - -- **Root integration tests**: `tests/` — **208 test files** - - Covers: audit, compliance, chain, protocol, ledger, wallet, guardrails, ZK, fraud, migrations, load, e2e -- **Package-level tests**: - - `packages/sardis-protocol/tests/` - - `packages/sardis-ledger/tests/` - - `packages/sardis-wallet/tests/` - - `packages/sardis-sdk-python/tests/` - - `packages/sardis-mcp-server/src/__tests__/` - - `packages/sardis-sdk-js/__tests__/` - ---- - -## 5. Schemas / Spec Files & CI / Config - -### Schemas / Specs - -- **OpenAPI Schema Generator**: `packages/sardis-api/src/sardis_api/openapi_schema.py` - - Generated dynamically by FastAPI; no committed static `openapi.json` -- **OpenAPI Actions (ChatGPT)**: `packages/sardis-api/openapi/chatgpt-actions.yaml` -- **Composio OpenAPI**: `packages/sardis-composio/openapi.yaml` -- **Protocol Schemas (Pydantic)**: `packages/sardis-protocol/src/sardis_protocol/schemas.py` -- **MCC Codes JSON**: `packages/sardis-core/src/sardis_v2_core/data/mcc_codes.json` - -### CI / Config - -- **Main CI**: `.github/workflows/ci.yml` - - Python lint (ruff), root tests (pytest with 40% cov minimum), package tests, TS build/test, contract build, security scan -- **Release Workflows**: - - `release-python-sdk.yml` - - `release-python-integrations.yml` - - `release-npm.yml` - - `publish.yml` -- **Deploy Workflows**: - - `deploy-api-cloudrun.yml` - - `deploy-dashboard.yml` - - `deploy-landing.yml` -- **Other**: `codeql.yml`, `secret-scan.yml`, `security-scan.yml`, `fuzz.yml`, `nightly-sandbox-e2e.yml` -- **Dependabot**: `.github/dependabot.yml` - ---- - -## 6. What is Reusable - -1. **Policy Engine** (`spending_policy.py`, `nl_policy_parser.py`, `policy_dsl.py`) — deterministic NL-to-policy parsing is domain-agnostic for spending controls. -2. **PreExecutionPipeline** — composable hook chain pattern with fail-closed defaults can be reused for any transactional approval flow. -3. **Protocol Verifiers** (`verifier.py`, `tap.py`, `x402.py`) — mandate chain verification and signature validation are standalone. -4. **Kill Switch** (`kill_switch.py`) — global/per-agent/per-rail/per-chain emergency stop primitive. -5. **Ledger Engine + Merkle Tree** — append-only audit with cryptographic anchoring. -6. **Exception Hierarchy** (`exceptions.py`) — well-structured error taxonomy with HTTP mapping. -7. **Circuit Breaker / Retry / Logging** — generic resilience primitives in `sardis-core`. -8. **SDK Client Patterns** — both Python and TS SDKs have clean resource-based architectures. -9. **MCP Tool Definitions** — 40+ tools in `sardis-mcp-server` that wrap the API. -10. **Smart Contract Patterns** — `SardisPolicyModule.sol` (Safe module) and `SardisLedgerAnchor.sol` (Merkle root anchor). - ---- - -## 7. What is Missing - -The following are explicitly **not found** or are **gaps**: - -- **No static OpenAPI spec**: There is no committed `openapi.json`; it is generated at runtime by FastAPI. -- **No Rust/Cargo core**: Despite `sardis-cli-go` and `sardis-solana-program` directories, there is no significant Rust codebase; `sardis-cli-go` is effectively empty. -- **No dedicated `authority` primitive module**: Authority/delegation logic is embedded inside `mandates.py`, `approval_service.py`, and `spending_mandate.py` rather than being a standalone reusable package. -- **No standalone `evidence` export service**: Evidence exists as `policy_evidence.py` and within the ledger, but there is no top-level `evidence` package or router dedicated to evidence export. -- **No `spending` standalone package**: Spending tracking is split between `sardis-core` (`spending_tracker.py`) and `sardis-wallet` (`spending_limits.py`). -- **No comprehensive API documentation source files**: `docs/` contains business plans, deployment guides, and marketing content, but no structured technical API reference markdown. -- **No architecture decision records (ADRs)** in the docs directory. - ---- - -## 8. Conflicts Noticed - -1. **Dual API directories**: - - Main API code is in `packages/sardis-api/` - - A legacy/separate `api/` directory exists at `api/` containing subfolders like `api/`, `checkout/`, `dashboard/`, `landing/` — these appear to be older deployment artifacts or proxy configs. This is confusing. - -2. **Internal package naming inconsistency**: - - The installable PyPI package is `sardis-core`, but the Python import path is `sardis_v2_core` (`packages/sardis-core/src/sardis_v2_core/`). - - Similarly, `packages/sardis-api/src/sardis_api/` is correct, but there is also a stray file at `packages/sardis-api/src/sardis_v2_api/routes/budgets.py` — an orphaned module. - -3. **Multiple CLI packages**: - - Real CLI: `packages/sardis-cli/` (Python, fully featured) - - Placeholders: `packages/sardis-cli-go/` (empty) and `packages/sardis-cli-js/` (minimal dist only). These create ambiguity about the official CLI. - -4. **Overlapping wallet/spending domains**: - - `sardis-core` exports `Wallet`, `TokenLimit`, `SpendingPolicy`, and `SpendingTracker`. - - `sardis-wallet` also contains `manager.py` and `spending_limits.py`. - - The boundary between core domain models and wallet operational logic is blurry. - -5. **Top-level `sardis/` vs `packages/sardis-sdk-python/`**: - - The top-level `sardis/` package is a simulation wrapper that optionally imports `sardis_sdk`. - - This means there are two Python "SDK" surfaces: `sardis` (simple) and `sardis_sdk` (production). This is intentional but can confuse consumers about which to use. - -6. **MCP Server binary naming**: - - `package.json` defines `sardis-mcp` and `sardis-mcp-server` binaries pointing to the same file. - -7. **Version drift**: - - Root `pyproject.toml` says version `1.1.0`. - - `sardis-core/__init__.py` says `0.3.0`. - - `sardis-sdk-python/__init__.py` says `1.0.0`. - - `package.json` (monorepo root) says `0.2.0`. - - `sardis-mcp-server` says `1.1.0`. - - These versions are not aligned. - ---- - -## 9. Recommended Action for FIDES v2 +## 1. Repo Purpose -1. **Port generic patterns, NOT payment domain models:** - - **Port**: PreExecutionPipeline pattern, policy engine concept, kill switch primitive, approval flow, evidence ledger pattern, mandate chain abstraction. - - **Leave in Sardis**: stablecoin, MPC wallet, payment rails, merchants, compliance/KYA/AML, spending limits, AP2/TAP/x402 settlement logic. +Sardis is a financial authority layer for AI agents. It enforces mandates, deterministic policy, approvals, revocation, and audit evidence before wallets, cards, stablecoins, payment APIs, and x402 move money. -2. **Reuse Sardis error taxonomy** as inspiration for FIDES typed errors, but keep FIDES error categories protocol-focused (auth, trust, policy, evidence, runtime). +Local evidence: -3. **Reuse ledger anchoring pattern** (`SardisLedgerAnchor.sol`, `merkle_tree.py`) for FIDES evidence Merkle root anchoring, but implement it in TypeScript with adapter interfaces for on-chain anchoring. +- Public framing: `/Users/efebarandurmaz/sardis/README.md`. +- FastAPI composition root: `/Users/efebarandurmaz/sardis/apps/api/server/main.py`. +- Python package root: `/Users/efebarandurmaz/sardis/packages/sardis/pyproject.toml`. +- TypeScript SDK package: `/Users/efebarandurmaz/sardis/packages/sardis-js/package.json`. -4. **Do NOT create a FIDES-Sardis runtime dependency.** Instead, define adapter interfaces so Sardis can integrate FIDES trust/evidence layer, and FIDES can reference Sardis as a downstream consumer. +## 2. Main Packages / Modules -5. **Kill switch primitive** should be genericized in FIDES (per-agent, per-capability, per-principal emergency stop) and Sardis should specialize it for payment rails. +| Area | Path | Purpose | +|---|---|---| +| Python SDK | `/Users/efebarandurmaz/sardis/packages/sardis/` | Core, ledger, chain, UCP, protocol, compliance, guardrails, wallet, integrations. | +| API server | `/Users/efebarandurmaz/sardis/apps/api/server/main.py` | FastAPI application composition. | +| TypeScript SDK | `/Users/efebarandurmaz/sardis/packages/sardis-js/` | TS client resources for core, ledger, chain, UCP, protocol, compliance, guardrails, checkout, wallet, ramp, integrations, webhooks. | +| MCP server | `/Users/efebarandurmaz/sardis/packages/sardis-mcp-server/` | Payment, policy, agent, trust, project tools. | +| Contracts | `/Users/efebarandurmaz/sardis/contracts/src/` | Identity, reputation, ledger anchor, job/validation registries, refund protocol. | + +## 3. Existing Generic Primitives + +- FIDES DID format and helpers: `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/core/fides_did.py`. +- Sardis agent identity records with Ed25519/ECDSA verification, versioning, rotation, and revocation: `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/core/identity.py`. +- DID bridge linking Sardis agents to FIDES DIDs with Ed25519 ownership proof, but with in-memory default storage: `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/core/did_bridge.py`. +- FIDES API surface for registration, identity lookup, trust score, trust path, trust attestation, and policy-history verification: `/Users/efebarandurmaz/sardis/apps/api/server/routes/identity/fides_identity.py`. +- FIDES trust adapter wrapper over FIDES `/v1/trust/*`: `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/core/fides_trust_adapter.py`. +- Trust/reputation/attestation model with KYA, transaction history, compliance, reputation, behavioral consistency, and transitive trust: `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/core/trust_infrastructure.py`, `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/core/kya_trust_scoring.py`. +- Agent Auth discovery/capability/grant/session-like API with `.well-known/agent-configuration`, capability execution, registration, status, request-capability, and revoke: `/Users/efebarandurmaz/sardis/apps/api/server/routes/identity/agent_auth.py`. +- Signed attestation envelopes with canonical JSON, SHA-256 binding, optional Ed25519 signature, and verification: `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/core/attestation_envelope.py`. +- Policy history uses AGIT or an in-memory SHA-256 hash-chain fallback: `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/core/agit_policy_engine.py`. +- On-chain Merkle root anchor contract: `/Users/efebarandurmaz/sardis/contracts/src/SardisLedgerAnchor.sol`. +- Evidence export endpoints for ledger entries, side effects, idempotency records, policy decisions, and webhook evidence: `/Users/efebarandurmaz/sardis/apps/api/server/routes/evidence/records.py`. +- A2A agent-card discovery and trust table: `/Users/efebarandurmaz/sardis/packages/sardis-js/src/resources/a2a.ts`, `/Users/efebarandurmaz/sardis/apps/api/server/routes/protocol/a2a.py`. +- AP2 mandate models and verifier with proof payloads, nonce/replay, domain checks, canonicalization, and version fields: `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/core/mandates.py`, `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/protocol/verifier.py`, `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/protocol/schemas.py`. +- Privacy primitives for payment/privacy protocols: `/Users/efebarandurmaz/sardis/packages/sardis/src/sardis/protocol/paladin_privacy.py`. + +## 4. Payment-Specific Items To Keep Separate + +These are Sardis-domain primitives and should not move into generic FIDES core: + +- Payment mandates, AP2, TAP, x402, MPP. +- Spending policies, wallet/card/ramp/funding/escrow resources. +- Merchant/service payment directory. +- Stablecoin and payment rails. +- Compliance/KYA/AML. +- Payment kill switch scoped to global/org/agent/rail/chain: `/Users/efebarandurmaz/sardis/apps/api/server/kill_switch_dep.py`. +- x402 facilitator routes: `/Users/efebarandurmaz/sardis/apps/api/server/routes/protocol/x402.py`. +- MCP paid API proxy/project provisioning: `/Users/efebarandurmaz/sardis/packages/sardis-mcp-server/src/tools/proxy.ts`, `/Users/efebarandurmaz/sardis/packages/sardis-mcp-server/src/tools/projects.ts`. + +## 5. Reusable Components + +Reuse conceptually, not by runtime dependency: + +- FIDES DID encoding/parsing. +- DID bridge ownership proof shape. +- Ed25519 verification. +- Attestation envelope canonicalization. +- Policy-before-execution pipeline. +- Evidence/hash-chain and export shape. +- Trust path adapter shape. +- Reason-code taxonomy. +- A2A AgentCard and trust table patterns. +- Approval and high-risk action patterns. +- Kill switch semantics, generalized beyond payments. + +## 6. Missing Components + +I could not find generic FIDES-ready source primitives for: + +- DHT discovery. +- Relay discovery. +- Federation runtime. +- TEE/runtime attestation. +- Generic privacy-preserving identity/evidence protocol beyond payment/privacy transaction primitives. + +## 7. Conflicts With FIDES v2 Architecture + +- FIDES functionality currently lives inside Sardis API routes and payment/KYA context; extraction requires removing payment-control-plane assumptions. +- The FIDES trust adapter degrades to `0.0` / empty path and documents that it should never block payments. That is unacceptable as a generic FIDES authority core unless fail-open/fail-closed behavior is explicit and policy-governed. +- DID bridge and TrustFramework are in-memory by default, so they are not durable FIDES primitives as-is. +- Attestation envelopes can be unsigned when no signing key is provided. FIDES v2 should make unsigned envelopes debug-only or draft-only. +- MCP paid API proxy has fail-open policy-check behavior, which conflicts with authority-layer semantics. +- Some contracts exist for identity/reputation, but `contracts/AUDIT_SURFACE.md` says only `SardisLedgerAnchor` and `RefundProtocol` are intended production custom deployments; do not treat ERC-8004-style contracts as FIDES-ready without a separate decision. + +## 8. Recommended Action + +Use Sardis as the primary source of generic authority patterns: + +- policy-before-execution, +- guardrails, +- evidence ledger/export, +- approval flow, +- kill switch, +- high-risk action handling, +- mandate-chain abstraction. + +Keep payment-specific execution in Sardis. FIDES should expose generic primitives and a Sardis adapter; Sardis should consume FIDES for agent identity, trust, delegation, evidence, and policy context around payment-specific mandates. From 65e6bec9fd45b1aa156c597e24a2f507190c3ca5 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:02:55 +0300 Subject: [PATCH 002/282] docs: add fides v2 architecture plan --- .../fides-v2-agent-trust-fabric.md | 848 +++++++---- docs/architecture/gap-analysis.md | 432 ++++-- .../implementation-agent-prompt.md | 538 +++---- docs/architecture/implementation-plan.md | 1332 ++++++----------- 4 files changed, 1496 insertions(+), 1654 deletions(-) diff --git a/docs/architecture/fides-v2-agent-trust-fabric.md b/docs/architecture/fides-v2-agent-trust-fabric.md index d9bd82c..68cbdec 100644 --- a/docs/architecture/fides-v2-agent-trust-fabric.md +++ b/docs/architecture/fides-v2-agent-trust-fabric.md @@ -1,402 +1,594 @@ -# FIDES v2 / Agent Trust Fabric — Architecture +# FIDES v2 Agent Trust Fabric -## 1. Should FIDES Become the Main Home for Agent Trust Fabric? +FIDES v2 is the trust, authority, policy, delegation, runtime attestation, and evidence layer for agent-to-agent systems. -**Yes.** +It is not an agent app store. It is not a naive agent directory. Discovery is only the first step. A discovered agent is a candidate, not an authority. -FIDES is the only repository among the five with a complete runtime: services (discovery, trust-graph), SDK (`@fides/sdk`), CLI (`fides`), tests, CI/CD, and Docker deployment. AGIT, OSP, OAPS, and Sardis each have significant gaps (stub implementations, missing CI, incomplete Rust core, or payment-specific scope creep). FIDES should evolve into the Agent Trust Fabric, absorbing reusable concepts from the other repos while keeping their domain-specific implementations separate. +## Thesis ---- +Agents should not just call each other. They should know: -## 2. Which Packages Should Remain in FIDES? +- who they are calling, +- who published that agent, +- who the request is on behalf of, +- what capability is being requested, +- whether the agent is trusted for that specific capability, +- what policy applies, +- what authority was delegated, +- what runtime or build evidence exists, +- what revocations or incidents apply, +- what evidence is left behind. -All existing FIDES packages remain and are extended: +Discovery without trust becomes spam. +Trust without authority becomes unsafe. +Authority without evidence becomes unauditable. -- `@fides/sdk` — Core protocol (identity, signing, trust, discovery) -- `@fides/shared` — Shared types, constants, errors -- `@fides/cli` — CLI extended with new commands +## ARP Analogy -New packages: +ARP answers: "Who has this IP address?" -- `@fides/core` — Identity v2, AgentCards, capabilities, delegation, policy, evidence primitives -- `@fides/discovery` — Discovery provider architecture (local, well-known, registry, relay, DHT) -- `@fides/runtime` — Runtime attestation, TEE adapters, session grants -- `@fides/evidence` — Evidence ledger, hash chain, event streaming +FIDES v2 answers: "Who can perform this capability under these constraints, and can they prove identity, trust, authority, safety, and evidence?" -Services: +Mapping: -- `discovery` — Extended with registry + federation -- `trust-graph` — Extended with reputation v2 + incidents -- `policy-engine` — Standalone policy evaluation service -- `registry` — New (or merged into discovery) -- `relay` — New mock relay server -- `agentd` — New local daemon +- ARP resolves IP address to MAC address. +- FIDES resolves capability plus constraints to verified agent candidates. +- ARP assumes a local broadcast domain. +- FIDES supports local, well-known, registry, relay, DHT, and federation-ready discovery. +- ARP has weak trust. +- FIDES verifies signatures, AgentCards, publisher identity, trust anchors, reputation, runtime attestation, revocations, incidents, and policy constraints. +- ARP returns an address. +- FIDES returns candidates, explanations, policy decisions, scoped session grants, and evidence. ---- +The ARP-like step is only discovery. Authority comes later through policy and session grants. -## 3. Which Concepts Should Be Imported from AGIT? +## Hard Constraints -- **Hash-chained audit log** (`compute_audit_hash` pattern) → `@fides/evidence` -- **Canonical JSON + SHA-256 content addressing** → `@fides/core` canonical object signing -- **Guard framework** (Allow/Warn/Block) → `@fides/policy` guard concept -- **ApprovalStore pattern** → `@fides/core` ApprovalRequest/ApprovalDecision -- **Merkle tree construction** → `@fides/evidence` Merkle proof support +- FIDES v2 is TS-first and Rust adapter-ready. +- AGIT Rust may later be used through adapters for evidence chains, canonicalization, hashing, Merkle/DAG primitives, and performance-sensitive work. +- Rust is not required for the first working v2. +- OAPS concepts are ported into FIDES-owned runtime types. +- FIDES must not depend on `@oaps/core` as a runtime dependency. +- Sardis contributes generic patterns only: policy-before-execution, guardrails, evidence, approvals, kill switch, high-risk handling, mandate-chain abstraction. +- Payment-specific domain stays in Sardis: stablecoins, MPC wallets, rails, merchants, compliance, spending limits, payment-specific mandates. +- Effect may be used internally for services, workflows, typed errors, dependency injection, provider orchestration, daemon and CLI workflows. +- Protocol objects, schemas, crypto, canonical JSON, signing, AgentCards, EvidenceEvents, DHT records, SessionGrants, attestations, revocations, and incidents remain framework-agnostic. +- Public SDK APIs are Promise-based. Effect-native APIs may be added later. -**Not imported:** Full VCS (commit/branch/merge), SQLite/Postgres/S3 storage backends, Python ExecutionEngine, three-way merge. +## Current Baseline ---- +The repo is already a TypeScript monorepo with: -## 4. Which Concepts Should Be Imported from OSP? +- `packages/core` for identity, signing, AgentCards, capabilities, delegation, sessions, revocation, incidents, trust anchors, domain/passkey verification. +- `packages/evidence` for hash-chained evidence and Merkle roots. +- `packages/runtime` for MockTEE, attestation adapters, and kill switch. +- `packages/discovery` for local, well-known, registry, relay, and DHT providers. +- `packages/policy` and `packages/guard` for policy and pre-execution decisions. +- `packages/sdk` and `packages/cli` for developer surfaces. +- `services/agentd`, `discovery`, `trust-graph`, `registry`, `relay`, `policy-engine`, and `platform-api`. -- **Registry server pattern** (Axum-based) → FIDES registry service (Hono-based) -- **Service lifecycle semantics** (provision, rotate, deprovision, status) → FIDES agent lifecycle -- **JSON Schema discipline** → All FIDES v2 protocol objects get JSON Schema -- **Credential rotation pattern** → FIDES key rotation and delegation token rotation -- **URI scheme pattern** (`osp://`) → FIDES `fides://` URI scheme for referencing agents/capabilities +The baseline is strong but uneven: several objects are present as prototypes, not final protocol contracts. -**Not imported:** Provider adapters, service provisioning, encrypted credential delivery, payment rails. +## Protocol Layers ---- +### 1. Identity Layer -## 5. Which Concepts Should Be Imported from OAPS? +Owns: -- **ActorCard** → FIDES `AgentCard` -- **CapabilityCard** → FIDES `CapabilityDescriptor` -- **DelegationToken** → `@fides/core` DelegationToken -- **PolicyBundle + evaluatePolicy** → `@fides/policy` PolicyBundle + evaluator -- **EvidenceEvent + EvidenceChain** → `@fides/evidence` -- **ApprovalRequest + ApprovalDecision** → `@fides/core` -- **Version negotiation** (`negotiateVersion`) → `@fides/core` -- **Error taxonomy** (`ErrorObject` with 12 categories) → `@fides/shared` error hierarchy -- **Handshake protocol** → `@fides/runtime` session establishment -- **Well-known discovery** (`.well-known/aicp.json`) → FIDES `.well-known/fides.json` +- `AgentIdentity` +- `PublisherIdentity` +- `PrincipalIdentity` +- domainless identity +- platform-hosted identity +- domain-verified identity +- organization-verified identity +- trust anchors -**Not imported:** Full AICP interaction/task lifecycle, WebSocket binding, payment adapters (x402, MPP, AP2), commerce domain schemas. +Rules: ---- +- Domain must not be required. +- Identity must not equal trust. +- A valid cryptographic identity can still be low trust. +- Publisher identity and principal identity must be separate from agent identity. -## 6. Which Concepts Should Be Imported from Sardis? +Current anchors: -- **Pre-execution pipeline pattern** → `@fides/policy` PolicyEngine -- **Kill switch primitive** → `@fides/runtime` KillSwitch -- **Approval flow pattern** → `@fides/core` ApprovalRequest/ApprovalDecision -- **Evidence ledger pattern** → `@fides/evidence` -- **Mandate chain abstraction** → `@fides/core` MandateChain -- **High-risk action handling** → `@fides/policy` risk taxonomy +- `packages/core/src/identity.ts` +- `packages/core/src/trust-anchor.ts` +- `packages/core/src/domain-verifier.ts` +- `packages/core/src/passkey.ts` -**Not imported:** Payment-specific models (stablecoin, MPC wallet, spending limits, merchants, compliance/KYA/AML, AP2/TAP/x402 settlement). +Required hardening: ---- +- Replace loose identity creation with real Ed25519 keypair issuance. +- Add publisher type taxonomy. +- Add non-domain trust anchors: GitHub, email, npm, PyPI, wallet, passkey, organization invitation, runtime attestation, build attestation, peer attestation. -## 7. What Should Remain Separate and Only Be Integrated Through Adapters? +### 2. Attestation Layer -| Domain | Repo | Adapter Interface | -|--------|------|-------------------| -| Agent VCS / state versioning | AGIT | `AgitEvidenceAdapter` | -| Service provisioning / provider adapters | OSP | `OSPProvisioningAdapter` | -| Payment execution / stablecoin | Sardis | `SardisPaymentAdapter` | -| A2A protocol runtime | Google A2A | `A2AAdapter` | -| MCP server runtime | MCP | `MCPAdapter` | -| x402 payment challenges | x402 | `X402Adapter` | -| TEE attestation (Nitro, SGX, SEV) | Vendor SDKs | `TEEAttestationAdapter` | -| On-chain anchoring | EVM / Solana | `LedgerAnchorAdapter` | +Owns: ---- +- trust attestations, +- identity attestations, +- runtime attestations, +- build/container attestations, +- peer attestations, +- MockTEE. -## 8. Final Package Structure +Rules: -``` -fides/ -├── packages/ -│ ├── @fides/sdk/ # Existing: identity, signing, trust, discovery client -│ ├── @fides/shared/ # Existing: types, constants, errors (extended) -│ ├── @fides/cli/ # Existing: CLI (extended) -│ ├── @fides/core/ # NEW: identity v2, AgentCard, CapabilityDescriptor, -│ │ # DelegationToken, SessionGrant, PolicyBundle, -│ │ # ApprovalRequest, ApprovalDecision, MandateChain, -│ │ # canonical object signing, version negotiation -│ ├── @fides/discovery/ # NEW: discovery providers (local, well-known, registry, -│ │ # relay, DHT), provider orchestration -│ ├── @fides/runtime/ # NEW: runtime attestation, TEE adapters, session grants, -│ │ # kill switch -│ ├── @fides/evidence/ # NEW: evidence ledger, hash chain, Merkle proofs, -│ │ # event streaming, privacy model -│ └── @fides/policy/ # NEW: policy engine, risk taxonomy, guardrails, -│ # pre-execution pipeline -├── services/ -│ ├── discovery/ # Extended: identity + agent registry, well-known, -│ │ # federation peering -│ ├── trust-graph/ # Extended: reputation v2, incident penalties, -│ │ # novelty penalties, runtime safety score -│ ├── policy-engine/ # Full implementation: evaluate policies, approvals, -│ │ # kill switch enforcement -│ ├── registry/ # NEW: hosted registry (public/private mode) -│ ├── relay/ # NEW: mock relay server for discovery -│ └── agentd/ # NEW: local daemon (HTTP API, SDK proxy) -├── apps/ -│ └── web/ # Future: trust fabric dashboard -├── schemas/ # NEW: JSON Schemas for all protocol objects -├── examples/ # NEW: demo agents, end-to-end scripts -└── tests/ - ├── e2e/ # Extended: full trust fabric flows - └── adversarial/ # NEW: adversarial simulation harness -``` +- Attestation is evidence, not automatic authority. +- High-risk capabilities may require valid runtime attestation or approval. ---- +Current anchors: -## 9. Final Protocol Model +- `packages/runtime/src/index.ts` +- `packages/core/src/trust-anchor.ts` -### Protocol Object Hierarchy +Required hardening: -``` -SignedObject (abstract) -├── AgentCard -├── CapabilityDescriptor -├── TrustAttestation -├── DelegationToken -├── SessionGrant -├── PolicyBundle -├── ApprovalRequest -├── ApprovalDecision -├── EvidenceEvent -├── RevocationRecord -├── IncidentRecord -├── RuntimeAttestation -└── MandateChain -``` +- Add signed `Attestation` and `RuntimeAttestation` protocol objects. +- Add `NullAttestationProvider`. +- Integrate runtime/build attestation into trust and policy scoring. -### Canonical Signing Model - -Every signed protocol object follows this pattern: - -```typescript -interface SignedObject { - payload: T; - proof: { - type: "Ed25519Signature2024"; - created: string; // ISO 8601 - verificationMethod: DID; // did:fides: - proofPurpose: "assertionMethod" | "authentication" | "delegation" | "capabilityInvocation"; - canonicalizationAlgorithm: "https://fides.dev/canonical-json/v1"; - proofValue: string; // base58-encoded signature - }; -} -``` +### 3. Agent Metadata Layer -Canonicalization: deterministic JSON (sorted keys, no whitespace, explicit nulls) → SHA-256 digest → Ed25519 sign. +Owns: -### Protocol Version +- signed AgentCards, +- capability descriptors, +- capability ontology, +- risk taxonomy, +- endpoint metadata, +- transport metadata, +- policy requirements. -- Current protocol version: `fides-v2.0.0` -- Version negotiation: OAPS `negotiateVersion` pattern adapted -- Compatibility promise: minor versions are additive; major versions require explicit handshake +Rules: ---- +- AgentCards are signed metadata, not authority. +- Capability reputation is scoped by capability. -## 10. Implementation Order +Current anchors: -### Phase 0: Foundation (Milestones 1-3) -1. Stabilize FIDES core (fix spec/impl discrepancies) -2. Identity v2 (agent, publisher, principal) -3. AgentCards and capabilities +- `packages/core/src/agent-card.ts` +- `packages/core/src/capability.ts` -### Phase 1: Discovery & Registry (Milestones 4-6) -4. Discovery provider architecture -5. Registry and relay -6. DHT discovery +Required hardening: -### Phase 2: Trust & Policy (Milestones 7-9) -7. Trust and reputation v2 -8. Policy engine -9. Delegation and sessions +- Add required v2 AgentCard fields: agent id, publisher, public keys, trust anchors, runtime attestations, protocol versions, revocation URL/ref, expiry, signature. +- Add capability namespace/action/resource fields and supported controls. +- Add ontology entries and risk classes. -### Phase 3: Evidence & Runtime (Milestones 10-12) -10. Evidence ledger -11. Revocation and incidents -12. Runtime attestation +### 4. Discovery Layer -### Phase 4: Developer Surface (Milestones 13-15) -13. CLI and API -14. Examples and full demo -15. Docs and tests +Owns: ---- +- local discovery, +- well-known discovery, +- hosted/private/public registry discovery, +- relay discovery, +- DHT discovery, +- federation-ready discovery, +- discovery orchestration. -## Protocol Layers +Rules: -### 1. Identity Layer -- AgentIdentity, PublisherIdentity, PrincipalIdentity -- Domainless individual identity -- Platform-hosted identity -- Domain-verified identity -- Organization-verified identity -- Trust anchors +- Discovery never grants authority. +- DHT and relay must never be trust sources. +- Discovery results must include verification and explainability. -### 2. Attestation Layer -- Trust attestation creation/verification -- Signed AgentCards -- Capability attestations -- Canonical object signing +Current anchors: -### 3. Agent Metadata Layer -- AgentCard schema and validation -- CapabilityDescriptor schema and validation -- Endpoint metadata -- Transport metadata -- Policy requirements +- `packages/discovery/src/provider.ts` +- `packages/discovery/src/orchestrator.ts` +- `packages/discovery/src/local-provider.ts` +- `packages/discovery/src/well-known-provider.ts` +- `packages/discovery/src/registry-provider.ts` +- `packages/discovery/src/relay-provider.ts` +- `packages/discovery/src/dht-provider.ts` -### 4. Discovery Layer -- LocalDiscoveryProvider -- WellKnownDiscoveryProvider -- RegistryDiscoveryProvider -- RelayDiscoveryProvider -- DHTDiscoveryProvider -- Provider orchestration +Required hardening: + +- Change provider API from DID-only resolution to capability-query discovery. +- Verify signed records and AgentCards. +- Filter by version and capability compatibility. +- Compute trust and policy candidate explanations. +- Emit evidence for discovery. ### 5. Trust Layer -- Trust graph v2 -- Direct trust edges -- Transitive trust with decay -- Trust anchors + +Owns: + +- trust graph, +- trust score, +- trust bands, +- trust reasons, +- context-specific scoring, +- runtime safety score. + +Rules: + +- Trust score is a signal. +- Policy is the authority. + +Current anchors: + +- `services/trust-graph/src/services/trust-service.ts` +- `services/trust-graph/src/services/graph.ts` +- `services/trust-graph/src/services/capability-scoring.ts` + +Required hardening: + +- Add componentized `TrustResult`: IdentityScore, PublisherScore, TrustAnchorScore, CapabilityFitScore, EvidenceScore, PolicyComplianceScore, RuntimeSafetyScore, PeerAttestationScore, IncidentPenalty, NoveltyPenalty, ContextBoundaryPenalty. +- Add trust bands: unknown, low, medium, high, verified. +- Make explanations first-class and machine-readable. ### 6. Reputation Layer -- Capability-specific reputation -- Context-specific trust -- Incident penalties -- Novelty penalties -- Runtime safety score + +Owns: + +- capability-specific reputation, +- principal-specific reputation where possible, +- publisher-weighted reputation, +- incident penalty, +- novelty penalty, +- context boundary penalty. + +Rules: + +- No global popularity score. +- `calendar.schedule` reputation must not imply `payments.execute` reputation. + +Current anchors: + +- `services/trust-graph/src/db/migrations/003_capability_scoring.sql` +- `services/trust-graph/src/services/capability-scoring.ts` + +Required hardening: + +- Add time-aware and context-aware reputation record. +- Add publisher-weight and principal-scope inputs. +- Integrate incidents and revocations. ### 7. Policy Layer -- PolicyBundle evaluation -- Risk taxonomy -- Guardrails (Allow / Warn / Block) -- Pre-execution pipeline -- High-risk capability handling -- Revoked agent denial -- Invalid runtime attestation denial + +Owns: + +- policy-before-execution, +- guardrails, +- risk model, +- kill switch, +- approval model, +- pure evaluator, +- optional Effect workflow wrapper. + +Rules: + +- Every decision includes machine-readable reasons, human-readable reasons, required controls, and evidence refs. +- No policy decision may return only a boolean. +- Kill switch overrides normal trust and policy. + +Current anchors: + +- `packages/policy/src/index.ts` +- `packages/guard/src/index.ts` +- `services/policy-engine/src/index.ts` + +Required hardening: + +- Add decision actions: allow, deny, require_approval, dry_run_only, scope_limit, risk_limit. +- Add requested policy inputs. +- Normalize `approve-required` / `dry-run` compatibility names. +- Add first-class approval and kill switch objects. ### 8. Delegation Layer -- DelegationToken -- SessionGrant -- Scoped authority -- Expiry -- Nonce / replay protection -- Audience restriction + +Owns: + +- `DelegationToken`, +- `SessionGrant`, +- scoped authority, +- expiry, +- audience restriction, +- nonce/replay protection, +- principal-to-agent delegation, +- agent-to-agent delegation. + +Current anchors: + +- `packages/core/src/delegation.ts` +- `packages/core/src/session-store.ts` +- `services/agentd/src/index.ts` + +Required hardening: + +- Add requested v2 `SessionGrant` fields. +- Bind session grants to policy hash and trust result hash. +- Sign grants with canonical model. +- Enforce replay protection consistently. ### 9. Invocation Layer -- Capability invocation authorization -- Mandate chain verification -- Approval gating -- Kill switch enforcement + +Owns: + +- capability invocation, +- input validation, +- output validation, +- dry-run, +- approval-gated execution, +- policy-before-execution. + +Current anchors: + +- `packages/guard/src/index.ts` +- `services/agentd/src/index.ts` + +Required hardening: + +- Add signed `InvocationRequest` and `InvocationResult`. +- Verify `SessionGrant`. +- Validate schemas. +- Check revocations and kill switch. +- Emit evidence for every state transition. ### 10. Evidence Layer -- EvidenceEvent -- Hash chain -- Merkle proofs -- Privacy model (public, private, redacted, hash-only) -- Evidence export + +Owns: + +- EvidenceEvent, +- hash chain, +- verification, +- export, +- redacted/hash-only evidence, +- privacy model, +- tamper detection. + +Rules: + +- Default to hash-only or redacted for sensitive input/output. +- Store hashes and metadata by default. + +Current anchors: + +- `packages/evidence/src/index.ts` +- `services/agentd/src/storage.ts` + +Required hardening: + +- Add requested event fields and event taxonomy. +- Sign evidence events. +- Add evidence refs. +- Add Merkle proofs and stronger export format. +- Add AGIT adapter-ready interface. ### 11. Revocation Layer -- RevocationRecord -- CRL-style lists -- On-chain revocation registry (adapter) -- Propagation interfaces + +Owns: + +- key revocation, +- identity revocation, +- AgentCard revocation, +- capability revocation, +- session revocation, +- attestation revocation, +- publisher revocation. + +Current anchors: + +- `packages/core/src/revocation.ts` +- `services/agentd/src/index.ts` +- `services/trust-graph/src/db/migrations/002_revocations.sql` + +Required hardening: + +- Add revocation target taxonomy. +- Make revocations first-class signed protocol objects with schema versions. +- Propagate revocations across registry/relay/DHT/federation interfaces. ### 12. Incident Layer -- IncidentRecord -- Classification taxonomy -- Policy impact -- Trust impact -- Automated response + +Owns: + +- incident records, +- severity, +- categories, +- evidence refs, +- resolution, +- trust/policy impact. + +Current anchors: + +- `packages/core/src/revocation.ts` +- `services/agentd/src/index.ts` +- `services/trust-graph/src/db/migrations/002_revocations.sql` + +Required hardening: + +- Add requested categories. +- Add resolution status. +- Integrate into trust, reputation, discovery filtering, and policy. ### 13. Registry Layer -- Hosted registry (public/private) -- Federation peering -- Relay discovery -- DHT pointers + +Owns: + +- hosted registry, +- public registry mode, +- private registry mode, +- signed RegistryIndexRecord, +- RegistryPeerRecord, +- federation peering, +- revocation/incident propagation. + +Current anchors: + +- `services/registry/src/` +- `packages/discovery/src/registry-provider.ts` + +Required hardening: + +- Add signed index and peer records. +- Add federation interfaces and local mock federation provider. +- Add propagation for revocations and incidents. ### 14. Transport Layer -- HTTP + RFC 9421 signatures -- WebSocket (adapter-ready) -- DHT/relay (adapter-ready) - -### 15. Developer Layer -- CLI (`fides`) -- SDK (`@fides/sdk`, `@fides/core`) -- Local daemon (`agentd`) -- Examples and demos -- Documentation and specs - ---- - -## Protocol Hardening Components - -### 1. Canonical Object Signing Model -- **Schema:** `schemas/signed-object.schema.json` -- **Interface:** `CanonicalSigner` / `CanonicalVerifier` in `@fides/core` -- **Docs:** `docs/protocol/canonical-signing.md` -- **Implementation:** Pure TypeScript, `@noble/ed25519`, deterministic JSON canonicalization -- **Integration:** All protocol objects inherit from `SignedObject` - -### 2. Protocol Version Negotiation -- **Schema:** `schemas/version-negotiation.schema.json` -- **Interface:** `negotiateVersion(supported: VersionSupport[], requested: VersionSupport[]): VersionNegotiationResult` -- **Docs:** `docs/protocol/version-negotiation.md` -- **Implementation:** Ported from OAPS `@oaps/core` -- **Integration:** Handshake protocol, discovery, WebSocket binding - -### 3. Stable Typed Error Vocabulary -- **Schema:** `schemas/error-object.schema.json` -- **Interface:** `FidesError` hierarchy extended with OAPS categories -- **Docs:** `docs/protocol/errors.md` -- **Implementation:** `@fides/shared/src/errors.ts` extended -- **Integration:** All packages throw typed errors; CLI maps to exit codes - -### 4. Privacy Model for Evidence -- **Schema:** `schemas/evidence-privacy.schema.json` -- **Interface:** `EvidencePrivacy { level: "public" | "private" | "redacted" | "hash-only"; redactionKey?: string; }` -- **Docs:** `docs/protocol/evidence-privacy.md` -- **Implementation:** `@fides/evidence` applies privacy level before appending to chain -- **Integration:** Evidence export, compliance, audit - -### 5. Trust and Policy Explainability -- **Schema:** `schemas/decision-explanation.schema.json` -- **Interface:** `DecisionExplanation { decision: "allow" | "deny" | "approve-required"; factors: ExplanationFactor[]; }` -- **Docs:** `docs/protocol/explainability.md` -- **Implementation:** Policy engine and trust graph return explanations with every decision -- **Integration:** CLI `fides explain`, SDK `policy.evaluateWithExplanation()` - -### 6. Adversarial Simulation Harness -- **Schema:** `schemas/adversarial-scenario.schema.json` -- **Interface:** `SimulationHarness { run(scenario: Scenario): SimulationResult; }` -- **Docs:** `docs/testing/adversarial.md` -- **Implementation:** `tests/adversarial/` — Sybil attacks, replay attacks, policy bypass attempts -- **Integration:** CI runs adversarial suite on every PR - -### 7. Capability Ontology and Risk Taxonomy -- **Schema:** `schemas/capability-ontology.schema.json`, `schemas/risk-taxonomy.schema.json` -- **Interface:** `CapabilityClassifier`, `RiskAssessor` -- **Docs:** `docs/protocol/capabilities.md`, `docs/protocol/risk.md` -- **Implementation:** `@fides/policy` — deterministic risk classification for every capability -- **Integration:** Policy engine, approval gating, kill switch - -### 8. ApprovalRequest and ApprovalDecision Primitives -- **Schema:** `schemas/approval-request.schema.json`, `schemas/approval-decision.schema.json` -- **Interface:** `ApprovalRequest`, `ApprovalDecision` in `@fides/core` -- **Docs:** `docs/protocol/approvals.md` -- **Implementation:** Ported from OAPS, extended with FIDES signing -- **Integration:** Policy engine, high-risk action handling, SDK - -### 9. Kill Switch Primitives -- **Schema:** `schemas/kill-switch.schema.json` -- **Interface:** `KillSwitch { engage(target: KillSwitchTarget): void; disengage(target: KillSwitchTarget): void; isEngaged(target: KillSwitchTarget): boolean; }` -- **Docs:** `docs/protocol/kill-switch.md` -- **Implementation:** `@fides/runtime` — in-memory + persistent state -- **Integration:** Policy engine, daemon, CLI `fides killswitch` - -### 10. Adapter Interfaces -- **Schema:** `schemas/adapter-manifest.schema.json` -- **Interface:** `ProtocolAdapter { readonly protocol: string; handshake(): Promise; invoke(capability: string, params: unknown): Promise; }` -- **Docs:** `docs/adapters/README.md` -- **Implementation:** `@fides/core` base adapter class + per-protocol adapters in `packages/adapters/` -- **Integration:** MCP, A2A, OAPS, OSP, AP2, x402, Sardis + +Owns: + +- HTTP API, +- relay protocol, +- DHT pointer resolution, +- adapter boundaries for MCP/A2A/OAPS/OSP/AP2/x402/Sardis. + +Current anchors: + +- `services/agentd/src/index.ts` +- `services/relay/src/index.ts` +- `packages/sdk/src/*/client.ts` + +Required hardening: + +- Add explicit adapter interfaces. +- Keep transport-specific signing separate from canonical protocol-object signing. + +### 15. Runtime Layer + +Owns: + +- daemon workflows, +- service orchestration, +- storage, +- local-first runtime, +- optional Effect service layers. + +Current anchors: + +- `services/agentd` +- `packages/runtime` + +Required hardening: + +- Add local SQLite storage target under `~/.fides/`. +- Add migrations and local config. +- Preserve Postgres service stores where already used by services. + +### 16. Developer Layer + +Owns: + +- CLI, +- SDK, +- examples, +- docs, +- demos, +- adversarial simulation. + +Current anchors: + +- `packages/cli` +- `packages/sdk` +- `examples` +- `tests/adversarial` + +Required hardening: + +- Add `agentd` command surface or alias. +- Add full demo. +- Add manual DX runbook. +- Add complete SDK examples. + +### 17. Interop Layer + +Owns adapter interfaces for: + +- MCP, +- A2A, +- OAPS, +- OSP, +- AP2, +- x402, +- Sardis. + +Rules: + +- These adapters map identity, cards, capabilities, delegation, policy, evidence, invocation, and payment/action flows where relevant. +- Payment execution remains Sardis-specific. + +Required hardening: + +- Add `packages/adapters` with interface-only first pass. +- Add mapping docs and example adapters. + +## Target Package Structure + +The target shape is: + +```text +packages/ + core/ + crypto/ + identity/ + attestations/ + cards/ + discovery/ + dht/ + relay/ + registry/ + trust/ + reputation/ + policy/ + delegation/ + invocation/ + evidence/ + revocation/ + incidents/ + runtime-effect/ + adapters/ + daemon/ + cli/ + sdk/ +examples/ + calendar-agent/ + invoice-agent/ + payment-agent/ + malicious-agent/ + requester-agent/ + full-demo/ +docs/ + inspection/ + architecture/ + protocol/ + api/ + cli/ + sdk/ + threat-model/ + adr/ +``` + +Implementation should respect existing package names first. Split packages only when it removes real complexity or matches the target architecture cleanly. Do not churn package names just to match the tree. + +## Security Rules + +- Deny by default for invalid signatures, active revocations, active kill switch, expired sessions, and broken evidence chains. +- Discovery result never grants authority. +- Trust score never grants permission. +- Policy-before-execution is mandatory for invocation. +- Evidence defaults to hash-only or redacted for sensitive payloads. +- Signed objects use one canonical signing model. +- DHT pointers are not trust roots. +- Relay presence is not trust. + +## First Publishable Slice + +The first publishable v2 slice should include: + +1. Consolidated protocol object model. +2. Canonical signer hardening. +3. Version negotiation and typed error vocabulary. +4. Identity v2 hardening. +5. Signed AgentCards and capability ontology. +6. Evidence v2 with signed events. +7. Capability-query discovery with local/registry/well-known providers. +8. DHT signed pointer records. +9. Policy/trust/reputation v2 decision explanations. +10. CLI/API/SDK demo path. diff --git a/docs/architecture/gap-analysis.md b/docs/architecture/gap-analysis.md index fc4babd..54a993b 100644 --- a/docs/architecture/gap-analysis.md +++ b/docs/architecture/gap-analysis.md @@ -1,129 +1,303 @@ -# Gap Analysis - -This document classifies the current status of every required FIDES v2 / Agent Trust Fabric feature after the first implementation pass. It is evidence-based and uses local files in this repository as the source of truth. - -## Status Legend - -| Status | Meaning | -|--------|---------| -| Implemented | Usable in code with tests or service routes | -| Prototype | Usable local implementation, but not production-grade | -| Mock | Intentionally local or fake provider for development/testing | -| Adapter-ready | Interface or provider boundary exists; external production adapter is not implemented | -| Spec-complete | Documented architecture/protocol exists; runtime implementation is not present | -| Missing | Not implemented in this repository | -| Avoid | Deliberately not included because it would weaken the trust-fabric boundary | - -## Feature Gap Matrix - -| # | Feature | Status | Evidence / notes | -|---|---------|--------|------------------| -| 1 | Local daemon | Prototype | `services/agentd/src/index.ts`, `services/agentd/test/routes.test.ts`; local HTTP API proxies identity/card/trust and hosts local policy/evidence/attestation/killswitch endpoints. | -| 2 | Agent identity | Implemented | `packages/core/src/identity.ts`, `packages/sdk/src/identity/*`, `packages/core/test/identity.test.ts`, `packages/sdk/test/identity.test.ts`. | -| 3 | Publisher identity | Prototype | `PublisherIdentity` in `packages/core/src/identity.ts`; verification providers are not production-backed. | -| 4 | Principal identity | Prototype | `PrincipalIdentity` in `packages/core/src/identity.ts`; session/principal binding is basic. | -| 5 | Domainless individual identity | Prototype | DID-based identity exists in SDK/core; individual proofing is not production-backed. | -| 6 | Platform-hosted identity | Prototype | Architecture describes hosted identity. `services/platform-api` now exposes topology metadata and file-backed passkey credential binding persistence for platform-hosted principals, but does not issue identities or perform live verifier-backed proofing. | -| 7 | Domain-verified identity | Prototype | `PublisherIdentity.verificationMethod = "dns"`, `packages/core/src/domain-verifier.ts`, `fides identity domain verify`, `GET /v1/identities/domain/verify`, discovery `POST /identities/{did}/domain/verify`, and registry rejection of unbacked `publisher.verified` claims. | -| 8 | Organization-verified identity | Prototype | Org identity is modeled through `PrincipalIdentity.type = "organization"` with optional domain verification fields; `verifyOrganizationDomainDid` checks `_fides-org.` DNS TXT ownership, discovery persists organization-domain verification state, the SDK exposes `verifyOrganizationDomain`, and registry rejects unbacked `publisher.organization.verified` claims. | -| 9 | Trust anchors | Prototype | `TrustAnchor` exists in `packages/core/src/identity.ts`; `GovernedTrustAnchor`, status/scope/issuer validation, and deterministic distribution bundles exist in `packages/core/src/trust-anchor.ts`; `services/platform-api` now exposes file-backed governed trust-anchor CRUD, status revocation, and distribution routes, but no federation peering or external governance workflow exists yet. | -| 10 | Signed AgentCards | Implemented | `AgentCard` and `SignedAgentCard` in `packages/core/src/agent-card.ts`; canonical signing in `packages/core/src/canonical-signer.ts`; tests in `packages/core/test/agent-card.test.ts`. | -| 11 | Capability descriptors | Implemented | `CapabilityDescriptor` and risk classifier in `packages/core/src/capability.ts`; tests in `packages/core/test/capability.test.ts`. | -| 12 | Local discovery | Prototype | `packages/discovery/src/local-provider.ts`; file-backed local provider, not mDNS. | -| 13 | Local network discovery interface | Adapter-ready | Provider abstraction exists in `packages/discovery/src/provider.ts`; LAN/mDNS transport not implemented. | -| 14 | Well-known discovery | Implemented | `services/discovery/src/routes/well-known.ts`, `packages/discovery/src/well-known-provider.ts`, route tests. | -| 15 | Hosted registry | Prototype | `services/registry/src/index.ts`; file-backed registry with public/private modes. | -| 16 | Public registry API | Prototype | `services/registry/src/index.ts` exposes card registration, lookup, search, stats, mode updates. | -| 17 | Private registry mode | Prototype | `services/registry/src/index.ts`, registry route tests cover private mode. | -| 18 | Relay-based discovery | Prototype | `services/relay/src/index.ts`, `packages/discovery/src/relay-provider.ts`; relay is in-memory. | -| 19 | DHT-based discovery | Mock | `packages/discovery/src/dht-provider.ts`; local/in-memory or pointer-level behavior, no libp2p production network. | -| 20 | Federation-ready registry peering | Spec-complete | Architecture reserves the layer; peering protocol/runtime is not implemented. | -| 21 | Trust graph | Implemented | `services/trust-graph/src/services/graph.ts`, route/service tests. | -| 22 | Reputation engine | Implemented | `services/trust-graph/src/services/scoring.ts`, tests in `services/trust-graph/test/*`. | -| 23 | Capability-specific reputation | Prototype | `services/trust-graph/src/services/capability-scoring.ts`, `services/trust-graph/test/capability-scoring.test.ts`. | -| 24 | Trust scoring | Implemented | Direct/transitive scoring in `services/trust-graph/src/services/scoring.ts`. | -| 25 | Policy engine | Implemented | `packages/policy/src/index.ts`, `packages/policy/test/policy.test.ts`; `services/policy-engine/src/index.ts` exposes standalone evaluation routes; `agentd` uses it in `/v1/policy/evaluate`. | -| 26 | Delegation tokens | Implemented | `packages/core/src/delegation.ts`, `packages/core/test/delegation.test.ts`. | -| 27 | Session grants | Implemented | `SessionGrant` helpers plus `SessionStore`, `InMemorySessionStore`, `FileSessionStore`, nonce replay rejection, and session invocation authorization in `packages/core/src/session-store.ts`; route coverage in `services/agentd/test/routes.test.ts`. | -| 28 | Capability invocation | Prototype | Guard/policy decision path exists in `packages/guard/src/index.ts`; `services/agentd/src/index.ts` exposes `/v1/authorize`; actual remote invocation transport is adapter-ready. | -| 29 | Evidence ledger | Implemented | `packages/evidence/src/index.ts`, `services/agentd/src/index.ts`, tests in `packages/evidence/test/evidence.test.ts`. | -| 30 | Hash-chained evidence events | Implemented | `appendEvidenceEvent` and `verifyEvidenceChain` in `packages/evidence/src/index.ts`. | -| 31 | Revocation records | Implemented | `packages/core/src/revocation.ts`, core tests, `agentd` revocation API routes, propagation outbox, and trust-graph record/read routes. | -| 32 | Incident records | Implemented | `IncidentRecord` and impact aggregation in `packages/core/src/revocation.ts`; `agentd` incident API routes feed authorization context. | -| 33 | Runtime attestation | Prototype | `packages/runtime/src/index.ts`, `services/agentd/src/index.ts`, runtime tests. | -| 34 | TEE-ready attestation | Adapter-ready | `TEEAdapter`, `HttpTEEAdapter`, `AwsNitroTEEAdapter`, `IntelSGXTEEAdapter`, and `AmdSEVTEEAdapter` in `packages/runtime/src/index.ts`; vendor verification services are external adapters. | -| 35 | Mock TEE provider | Mock | `MockTEEProvider` in `packages/runtime/src/index.ts`, `packages/runtime/test/runtime.test.ts`. | -| 36 | Container image attestation provider interface | Prototype | `ContainerImageAttestationInput`, `ContainerImageAttestationAdapter`, and `ContainerImageAttestationProvider` validate registry/repository/digest claims locally; registry transparency or Sigstore verification is not implemented. | -| 37 | Reproducible build attestation interface | Prototype | `BuildAttestationInput`, `BuildAttestationAdapter`, and `BuildProvenanceAttestationProvider` model image digest, source commit, and builder identity; external SLSA/in-toto verification is not implemented. | -| 38 | GitHub attestation | Prototype | `GitHubAttestationInput`, `GitHubAttestationAdapter`, and `GitHubActionsAttestationProvider` validate structured workflow evidence and allowed repositories; GitHub artifact attestation API verification is not implemented. | -| 39 | Email attestation | Missing | No email verifier/provider exists yet. | -| 40 | Domain attestation | Prototype | DNS TXT verifier helper exists in `packages/core/src/domain-verifier.ts`; CLI, agentd, and discovery have Node DNS verification, with discovery persistence for verified identity domains. | -| 41 | Package registry attestation | Prototype | `PackageAttestationInput`, `PackageAttestationAdapter`, and `PackageRegistryAttestationProvider` validate structured package integrity claims; live npm/PyPI/crates metadata verification is not implemented. | -| 42 | Wallet attestation | Missing | Deliberately left out of FIDES core; Sardis should own payment/wallet-specific proofing. | -| 43 | Passkey identity interface | Adapter-ready | `packages/core/src/passkey.ts` defines WebAuthn/passkey challenge, credential binding, verification result, and verifier adapter boundaries with local RP/origin/expiry/credential policy checks; `services/platform-api` persists verified credential bindings through memory/file storage; live WebAuthn cryptographic verification is external. | -| 44 | CLI | Prototype | `packages/cli/src/index.ts` plus v2 commands for cards, policy, runtime, killswitch; daemon control is thin. | -| 45 | Local HTTP API | Prototype | `services/agentd/src/index.ts`; local API tests in `services/agentd/test/routes.test.ts`. | -| 46 | TypeScript SDK | Implemented | `packages/sdk/src/*`, SDK tests. | -| 47 | Example agents | Prototype | `examples/calendar-agent.ts`, `examples/invoice-agent.ts`, `examples/payment-agent.ts`, `examples/requester-agent.ts`. | -| 48 | End-to-end demo | Prototype | `examples/demo.ts`, `scripts/two-agents-demo.ts`, `scripts/authority-path-demo.ts`, `tests/e2e/full-flow.test.ts`. | -| 49 | Threat model | Spec-complete | `docs/threat-model.md`. | -| 50 | Protocol documentation | Spec-complete | `docs/protocol/fides-v2-spec.md`, `docs/protocol-spec.md`, architecture docs. | -| 51 | Test suite | Implemented | `pnpm test` covers 15 packages and the service routes. | -| 52 | Migration/versioning system | Spec-complete | `docs/migration-v1-v2.md`; runtime migration tooling is not implemented. | -| 53 | Security review checklist | Spec-complete | `SECURITY.md`, `docs/threat-model.md`; no automated checklist gate yet. | -| 54 | Future production hardening notes | Spec-complete | `docs/deployment.md`, `docs/threat-model.md`, implementation plan notes. | - -## Current Reality by Layer - -### Production-like - -- Existing v1 identity/signing SDK primitives: Ed25519 key generation, DID parsing, keystore, HTTP request signing and verification. -- Trust graph service primitives: trust edge validation, graph traversal, trust/reputation scoring tests. -- Core canonical signing, AgentCard validation, delegation signatures, evidence hash-chain verification, policy evaluator tests. - -### Working prototype - -- `agentd` local HTTP API. -- Hosted registry service with file persistence and public/private mode. -- Relay service with in-memory queues and TTL. -- Discovery provider orchestration. -- Guard decision engine integrating trust, evidence, runtime attestation, incidents, kill switch, and policy. -- Example agents and local demo scripts, including an authority path demo through service routes. - -### Local mock - -- `MockTEEProvider` runtime attestation. -- DHT discovery provider behavior. -- Example-agent signatures in `examples/*`. - -### Adapter-ready - -- Runtime TEE adapters. -- Local network/mDNS discovery. -- DHT/libp2p discovery. -- Federation peering. -- Domain verification helper plus CLI, agentd, discovery DNS verification, and registry checks for claimed verified publishers. -- External payment/action control plane integration through Sardis, not FIDES core. - -### Still missing - -- Production attestation providers: live Nitro, SGX, SEV, Sigstore/SLSA, GitHub artifact-attestation, email, package registry metadata, and WebAuthn verifier adapters. -- Federation registry peering implementation. -- Real mDNS/libp2p transport. -- Platform metadata API under `services/platform-api/`. - -## Remaining Blockers for a Production FIDES v2 - -1. Durable trust-fabric state: registry, evidence, revocation, and incidents need real storage contracts; sessions now have a file-backed local store but not a production database adapter. -2. Production attestation providers: the TEE/build/container/package/GitHub/passkey providers now have adapter boundaries or local structured verification, but live vendor/API-backed verification is still missing; domain and organization verification still depend on DNS/discovery availability. -3. Federation semantics: registry peering, cross-node revocation conflict handling, and cross-node trust-anchor governance workflows need implementation. -4. Authority separation hardening: identity, trust score, and policy are separated conceptually, but remote invocation execution remains adapter-ready rather than implemented. -5. Service packaging: platform-api and policy-engine now have standalone service packages, Dockerfiles, compose wiring, CI image builds, file-backed platform passkey binding routes, and policy evaluation routes; they still need production-grade auth beyond shared API keys. - -## Next Implementation Slice - -1. Add production storage adapters for registry, evidence, revocation, incidents, and session state. -2. Add live vendor-backed attestation verification for Nitro, SGX, SEV, Sigstore/SLSA, GitHub artifact attestations, email, package registries, and WebAuthn/passkey adapters. -3. Add federation peering for trust-anchor governance and organization publisher policy beyond DNS-backed registry enforcement. -4. Extend the authority path demo into a multi-process demo that starts discovery, trust-graph, registry, relay, policy-engine, agentd, and platform-api. -5. Add federation peering and cross-node revocation conflict semantics. +# FIDES v2 Gap Analysis + +This gap analysis compares the current local FIDES implementation with the requested FIDES v2 Agent Trust Fabric. + +## Summary + +FIDES is not a blank slate. It already has a strong TypeScript monorepo, packages, services, CLI, SDK, docs, tests, and several v2-shaped primitives. + +The main gaps are not lack of code. They are: + +- protocol-object coherence, +- signing envelope consistency, +- capability-query discovery, +- DHT pointer semantics, +- first-class approval/invocation/version/error objects, +- local daemon storage target, +- interop adapter package, +- complete CLI/API/SDK/demo alignment. + +## Baseline Strengths + +| Area | Current evidence | Assessment | +|---|---|---| +| TS-first monorepo | `package.json`, `pnpm-workspace.yaml`, `turbo.json` | Strong. Keep. | +| Core package | `packages/core/src/` | Good start, needs consolidation/hardening. | +| Canonical signing | `packages/core/src/canonical-signer.ts` | Present, must become the one signing model. | +| Policy/guard | `packages/policy/src/index.ts`, `packages/guard/src/index.ts` | Present, needs richer v2 decisions. | +| Evidence | `packages/evidence/src/index.ts` | Present, needs signed v2 event model. | +| Runtime attestation | `packages/runtime/src/index.ts` | Present, needs schema alignment and integration. | +| Discovery providers | `packages/discovery/src/` | Present, needs capability-query and verification pipeline. | +| Trust graph | `services/trust-graph/src/` | Present, needs v2 component scoring. | +| Agent daemon | `services/agentd/src/` | Present, needs requested API/CLI/storage alignment. | +| Registry/relay services | `services/registry/src/`, `services/relay/src/` | Present, prototype-to-v2 hardening needed. | +| SDK | `packages/sdk/src/` | Present, Promise-based. | +| CLI | `packages/cli/src/` | Present as `fides`; requested surface is `agentd`. | +| Tests | `packages/*/test`, `services/*/test`, `tests/e2e`, `tests/adversarial` | Strong baseline. | + +## Blocking Architecture Gaps + +### 1. Protocol Object Model + +Current state: + +- Core objects exist across `packages/core`, `packages/shared`, package-specific types, and service-local payloads. +- There are multiple AgentCard and identity shapes. +- Signed objects use `SignedObject` in some places, raw `signature` fields in others. + +Required: + +- One framework-agnostic protocol object model with shared signed fields: + - `schema_version` + - `id` + - `issuer` + - `subject` where applicable + - `created_at` or `issued_at` + - `expires_at` where applicable + - `payload_hash` + - `signature` + +Action: + +- Create/extend protocol modules under `packages/core`. +- Keep compatibility exports until services/SDK/CLI migrate. + +### 2. Identity v2 Hardening + +Current state: + +- `packages/core/src/identity.ts` defines Agent/Publisher/Principal types. +- `createIdentity` accepts an external DID and fills `publicKey` with random bytes, not a real keypair. + +Required: + +- Real Ed25519 keypair issuance. +- Publisher type taxonomy. +- Domainless, hosted, domain-verified, org-verified identities. +- Trust anchors beyond domain. + +Action: + +- Add `packages/core/src/identity-v2.ts` or harden `identity.ts`. +- Preserve existing exports but make new creation path cryptographically correct. + +### 3. Signing Consistency + +Current state: + +- `SignedObject` proof model exists. +- Delegation/revocation/incident use raw hex `signature` fields. +- HTTP request signing exists separately. + +Required: + +- One canonical signing model for all signed protocol objects. +- HTTP signatures remain transport-level, not object-level protocol signing. + +Action: + +- Add a signed envelope and helper functions. +- Migrate object-specific signing to shared helpers. + +### 4. Discovery Semantics + +Current state: + +- Discovery provider interface resolves by DID. +- DHT provider stores direct AgentCards. + +Required: + +- Discover by `DiscoveryQuery` containing capability, constraints, principal, policy context, and versions. +- Providers return `DiscoveryCandidate[]`. +- Verification pipeline: + 1. verify signed records, + 2. verify AgentCards, + 3. check version compatibility, + 4. check capability compatibility, + 5. compute trust, + 6. evaluate policy, + 7. emit evidence. +- DHT provides signed pointers only. + +Action: + +- Extend provider API while preserving existing `resolve` compatibility. +- Add signed `DHTPointerRecord`. + +### 5. Trust/Reputation v2 + +Current state: + +- Trust graph and capability scoring exist. +- Guard explainability exists. + +Required: + +- `TrustResult` with component scores, band, reasons, risk flags, evidence refs, required controls. +- Capability-specific, principal-specific, publisher-weighted reputation. +- Incident, novelty, and context-boundary penalties. + +Action: + +- Add new types to core/shared. +- Extend trust graph service scoring incrementally. + +### 6. Policy/Approval/Kill Switch + +Current state: + +- Policy evaluator returns `allow`, `deny`, `approve-required`, `dry-run`. +- Guard handles approval and kill switch context. +- Kill switch package exists. + +Required: + +- Decision vocabulary includes `require_approval`, `dry_run_only`, `scope_limit`, `risk_limit`. +- First-class `ApprovalRequest`, `ApprovalDecision`, `ApprovalPolicy`. +- Kill switch rules for agent, publisher, capability, session, principal, high-risk class. + +Action: + +- Add protocol objects and compatibility mapping. +- Move approval from context flag to durable signed object lifecycle. + +### 7. Delegation/Session/Invocation + +Current state: + +- DelegationToken and SessionGrant exist. +- Agentd authorizes sessions and invocation-style checks. + +Required: + +- SessionGrant fields: session_id, requester_agent_id, target_agent_id, principal_id, capability, scopes, constraints, policy_hash, trust_result_hash, issued_at, expires_at, nonce, signature. +- Signed InvocationRequest and InvocationResult. +- Input/output validation. +- Evidence events for invocation lifecycle. + +Action: + +- Add v2 objects first. +- Then adapt agentd route logic. + +### 8. Evidence v2 + +Current state: + +- Evidence events have id/type/timestamp/actor/action/target/payload/privacy/prevHash/hash/signature. +- Merkle root is computed. + +Required: + +- Event taxonomy and fields: + - event_id, + - actor, + - subject, + - principal, + - capability, + - input_hash, + - output_hash, + - policy_hash, + - decision, + - risk_level, + - privacy_mode, + - prev_event_hash, + - signature. +- Default redacted/hash-only behavior. +- Export and tamper detection. + +Action: + +- Add EvidenceEvent v2 and compatibility adapter. +- Keep current chain verifier until migration is complete. + +### 9. Version/Error Vocabularies + +Current state: + +- Error classes exist but are broad. +- Versioning error exists, not a negotiation protocol. + +Required: + +- `ErrorEnvelope` with code/category/severity/retryable/message/details. +- Stable codes such as `IDENTITY_INVALID_SIGNATURE`, `POLICY_DENIED`, `DHT_POINTER_TAMPERED`. +- `VersionNegotiationRecord` with supported/required/negotiated versions and compatibility errors. + +Action: + +- Add to `packages/core` and export via SDK/CLI/API. + +### 10. Interop Adapters + +Current state: + +- Some A2A-like types exist. +- No unified `packages/adapters` package. + +Required: + +- MCP, A2A, OAPS, OSP, AP2, x402, Sardis adapter interfaces. + +Action: + +- Add interface-only package first. +- Provide mapping docs. + +### 11. Local Daemon Storage + +Current state: + +- Agentd supports file-backed and Postgres authority stores. + +Required: + +- Local-first SQLite with versioned migrations and `~/.fides/` config layout. + +Action: + +- Add SQLite adapter without removing existing Postgres support. + +### 12. CLI/API/Demo + +Current state: + +- CLI binary is `fides`. +- Agentd API is `/v1/*`. +- Examples exist. + +Required: + +- `agentd` command surface. +- Requested endpoint set. +- Full demo and adversarial simulation command. + +Action: + +- Add `agentd` binary/alias or `fides agentd`. +- Add compatibility routes where practical. +- Add `demo run` and `simulate adversarial`. + +## Cross-Repo Import Boundaries + +| Repo | Bring into FIDES | Do not bring | +|---|---|---| +| AGIT | Hashing, lineage, Merkle/state diff concepts, audit/event ideas, causal graph, Rust adapter option | VCS semantics as core protocol, current FIDES adapter as canon | +| OAPS | Actor/delegation/mandate/approval/evidence/version/error semantics | Runtime dependency on `@oaps/core`, payment profile execution | +| OSP | Registry/provision/rotate/deprovision adapter semantics | Service lifecycle as FIDES core authority | +| Sardis | Policy-before-execution, approvals, kill switch, evidence, mandate-chain abstraction | Stablecoins, wallets, merchants, compliance, spending limits, payment rails | + +## Definition of Publishable v2 Prototype + +Publishable prototype means: + +- Core packages build. +- Signed protocol objects are coherent. +- Discovery returns verified candidates, not authority. +- Policy-before-execution gates invocation. +- Evidence is hash-chained and privacy-aware. +- DHT uses signed pointers only. +- CLI/SDK/demo can run a full local scenario. +- Adversarial simulation demonstrates tampering, revocation, expired attestation, and evidence-chain failure. +- Docs and tests report current limitations honestly. diff --git a/docs/architecture/implementation-agent-prompt.md b/docs/architecture/implementation-agent-prompt.md index 31a02e8..41a15a8 100644 --- a/docs/architecture/implementation-agent-prompt.md +++ b/docs/architecture/implementation-agent-prompt.md @@ -1,386 +1,230 @@ # Implementation Agent Prompt -This prompt is designed for a future coding agent to implement FIDES v2 / Agent Trust Fabric with atomic commits. +You are continuing the FIDES v2 Agent Trust Fabric implementation in `/Users/efebarandurmaz/fides`. ## Mission -Implement FIDES v2 as a full Agent Trust Fabric. The implementation must: +Evolve FIDES into a TS-first, Rust adapter-ready Agent Trust Fabric for autonomous agent systems. -1. Build on the existing FIDES v1 codebase in `~/fides` -2. Port and adapt concepts from AGIT, OSP, OAPS, and Sardis as documented -3. Follow the architecture in `~/fides/docs/architecture/fides-v2-agent-trust-fabric.md` -4. Follow the implementation plan in `~/fides/docs/architecture/implementation-plan.md` -5. Close the gaps identified in `~/fides/docs/architecture/gap-analysis.md` -6. Use atomic commits with meaningful messages -7. Run tests and type checks where practical -8. Do not overwrite uncommitted user work +FIDES v2 owns generic: -## Architecture Decisions (Non-Negotiable) +- identity, +- discovery, +- trust, +- reputation, +- authority, +- policy, +- delegation, +- runtime attestation, +- revocation, +- incidents, +- invocation, +- evidence. -- **TS-first, Rust adapter-ready.** All new code is TypeScript. AGIT Rust core may be bridged later via adapters. -- **OAPS concepts are ported into FIDES, not imported as runtime dependencies.** OAPS remains the spec/source of semantic compatibility. -- **Sardis contributes generic patterns only:** policy-before-execution, guardrails, evidence ledger, approvals, kill switch, high-risk action handling. Payment-specific models stay in Sardis. -- **FIDES owns the generic authority/trust/evidence layer. Sardis owns the payment-specific authority model.** -- **Protocol objects, crypto, canonical JSON, signing primitives, and public schemas must remain framework-agnostic.** -- **Public SDK exposes Promise-based APIs, with optional Effect-native APIs later.** +It is not an agent app store and not a naive directory. -## Package Structure +Discovery never equals authority. +Identity never equals trust. +Trust score never equals permission. +Policy is the authority. +## Current Branch and State + +Work on: + +```bash +cd /Users/efebarandurmaz/fides +git checkout fides-v2-agent-trust-fabric ``` -packages/ - @fides/sdk/ # Existing: identity, signing, trust, discovery client - @fides/shared/ # Extended: types, constants, errors - @fides/cli/ # Extended: CLI commands - @fides/core/ # NEW: identity v2, AgentCard, CapabilityDescriptor, - # DelegationToken, SessionGrant, PolicyBundle, - # ApprovalRequest, ApprovalDecision, MandateChain, - # canonical object signing, version negotiation - @fides/discovery/ # NEW: discovery providers (local, well-known, registry, - # relay, DHT), provider orchestration - @fides/runtime/ # NEW: runtime attestation, TEE adapters, session grants, - # kill switch - @fides/evidence/ # NEW: evidence ledger, hash chain, Merkle proofs, - # event streaming, privacy model - @fides/policy/ # NEW: policy engine, risk taxonomy, guardrails, - # pre-execution pipeline -services/ - discovery/ # Extended: identity + agent registry, well-known, - # federation peering - trust-graph/ # Extended: reputation v2, incident penalties, - # novelty penalties, runtime safety score - policy-engine/ # Full implementation: evaluate policies, approvals, - # kill switch enforcement - registry/ # NEW: hosted registry (public/private mode) - relay/ # NEW: mock relay server for discovery - agentd/ # NEW: local daemon (HTTP API, SDK proxy) + +The branch was fast-forwarded to local `main` at `7e52774` before the v2 pivot docs were rewritten. + +Untracked local files/directories may exist: + +- `.projects/` +- `.agents/` +- `.claude/` +- `.cursor/` +- `AGENTS.md` +- `CLAUDE.md` +- `new_fides.md` + +Do not delete or overwrite them unless explicitly asked. + +Neighbor repos are evidence sources only unless the user explicitly expands scope: + +- `/Users/efebarandurmaz/agit` +- `/Users/efebarandurmaz/osp` +- `/Users/efebarandurmaz/OAPS` +- `/Users/efebarandurmaz/sardis` + +Several neighbor repos have dirty worktrees. Treat them as read-only. + +## Source of Truth Docs + +Read these first: + +- `docs/inspection/fides-report.md` +- `docs/inspection/agit-report.md` +- `docs/inspection/osp-report.md` +- `docs/inspection/oaps-report.md` +- `docs/inspection/sardis-report.md` +- `docs/inspection/cross-repo-primitive-map.md` +- `docs/architecture/fides-v2-agent-trust-fabric.md` +- `docs/architecture/gap-analysis.md` +- `docs/architecture/implementation-plan.md` + +## Hard Constraints + +- TypeScript/Node is the primary implementation. +- Rust is adapter-ready only; do not require Rust for first working v2. +- OAPS concepts are ported into FIDES-owned runtime types. +- Do not add `@oaps/core` as a runtime dependency. +- Sardis contributes generic patterns only. +- Payment-specific execution remains in Sardis. +- Effect may be used internally later, but protocol objects and public schemas are framework-agnostic. +- Public SDK APIs are Promise-based. +- Use one canonical signing model for all signed protocol objects. +- DHT pointers are discovery hints only, not trust roots. +- Relay presence is not trust. + +## Implementation Order + +Follow `docs/architecture/implementation-plan.md`. + +Current next steps after docs: + +1. Commit docs: + - `docs: add fides v2 inspection reports` + - `docs: add fides v2 architecture plan` +2. Milestone 1: + - protocol constants, + - `ErrorEnvelope`, + - `VersionNegotiationRecord`, + - base signed protocol object types, + - canonical signing tests. +3. Milestone 2: + - identity v2 hardening and real Ed25519 identity issuance. +4. Milestone 3: + - signed AgentCards and capability ontology. + +## Current Repo Shape + +Important packages: + +- `packages/core` +- `packages/evidence` +- `packages/runtime` +- `packages/discovery` +- `packages/policy` +- `packages/guard` +- `packages/sdk` +- `packages/cli` +- `packages/shared` + +Important services: + +- `services/agentd` +- `services/discovery` +- `services/trust-graph` +- `services/registry` +- `services/relay` +- `services/policy-engine` +- `services/platform-api` + +Important commands: + +```bash +pnpm install +pnpm build +pnpm typecheck +pnpm test +pnpm verify +pnpm --filter @fides/core test +pnpm --filter @fides/discovery test +pnpm --filter @fides/agentd test ``` -## Required Components +Use targeted package tests after small milestones. Use broader `pnpm test` / `pnpm verify` after integration. -### 1. Canonical Object Signing +## Coding Rules -Every signed protocol object must use: +- Run `git status --short --branch` before edits. +- Use `apply_patch` for manual edits. +- Do not revert user work. +- Keep commits atomic. +- Inspect diffs before committing. +- Add focused tests for every behavioral change. +- Report failing tests honestly. +- Avoid new dependencies unless justified. -```typescript -interface SignedObject { - payload: T; - proof: { - type: "Ed25519Signature2024"; - created: string; - verificationMethod: string; // DID - proofPurpose: "assertionMethod" | "authentication" | "delegation" | "capabilityInvocation"; - canonicalizationAlgorithm: "https://fides.dev/canonical-json/v1"; - proofValue: string; // base58 signature - }; -} -``` +## Security Rules -Implementation: -- `packages/@fides/core/src/canonical-signer.ts` -- Deterministic JSON (sorted keys, no whitespace, explicit nulls) -- SHA-256 digest → Ed25519 sign -- `@noble/ed25519` for crypto +- Fail closed for invalid signatures, active revocations, broken evidence chains, expired sessions, and kill-switch hits. +- Default evidence privacy to hash-only or redacted for sensitive input/output. +- Policy evaluation happens before invocation/execution/signing where applicable. +- No raw secrets, private keys, credentials, or sensitive payment data in logs. +- DHT and relay cannot grant authority. +- Trust score is only an input to policy. -### 2. Identity v2 +## Cross-Repo Boundaries -```typescript -interface AgentIdentity { did: string; publicKey: Uint8Array; keyType: "Ed25519"; createdAt: string; publisher?: PublisherIdentity; principal?: PrincipalIdentity; } -interface PublisherIdentity { did: string; name: string; domain?: string; verified: boolean; verificationMethod: "dns" | "github" | "email" | "manual"; } -interface PrincipalIdentity { did: string; type: "individual" | "organization" | "platform"; displayName: string; } -interface TrustAnchor { did: string; name: string; publicKey: Uint8Array; attestation: SignedObject; } -``` +AGIT: -### 3. AgentCard and CapabilityDescriptor - -```typescript -interface AgentCard { - id: string; - identity: AgentIdentity; - publisher?: PublisherIdentity; - capabilities: CapabilityDescriptor[]; - endpoints: EndpointDescriptor[]; - policies: PolicyRequirement[]; - createdAt: string; - updatedAt: string; -} - -interface CapabilityDescriptor { - id: string; - name: string; - description: string; - inputSchema: JSONSchema; - outputSchema: JSONSchema; - riskLevel: "low" | "medium" | "high" | "critical"; - requiresApproval: boolean; - requiresRuntimeAttestation: boolean; -} -``` +- Use as evidence/hash/lineage/Rust-adapter prior art. +- Do not import its FIDES adapters as protocol canon. -### 4. Discovery Providers - -Implement 5 providers: -- `LocalDiscoveryProvider` — mDNS-ready interface (stub acceptable) -- `WellKnownDiscoveryProvider` — HTTP `.well-known/fides.json` -- `RegistryDiscoveryProvider` — Hosted registry -- `RelayDiscoveryProvider` — Relay server (stub acceptable) -- `DHTDiscoveryProvider` — DHT pointers (stub acceptable) - -`DiscoveryOrchestrator` tries providers in priority order. - -### 5. Policy Engine - -Port from OAPS `@oaps/policy`: - -```typescript -interface PolicyBundle { - id: string; - version: string; - rules: PolicyRule[]; - defaultAction: "allow" | "deny" | "approve-required"; -} - -interface PolicyRule { - id: string; - condition: PolicyExpression; - action: "allow" | "deny" | "approve-required" | "dry-run"; - explanation: string; -} - -interface PolicyExpression { - operator: "eq" | "neq" | "lt" | "lte" | "gt" | "gte" | "in" | "all" | "any"; - field: string; - value: unknown; -} -``` +OAPS: -Add Sardis-inspired pre-execution pipeline with Allow/Warn/Block guards. - -### 6. Delegation and Sessions - -Port from OAPS `@oaps/core`: - -```typescript -interface DelegationToken { - id: string; - delegator: string; - delegatee: string; - capabilities: string[]; - constraints: DelegationConstraint; - issuedAt: string; - expiresAt: string; - nonce: string; - audience?: string[]; - signature: string; -} - -interface SessionGrant { - id: string; - token: DelegationToken; - sessionKey: string; - expiresAt: string; - boundTo?: string; -} -``` +- Port semantic concepts: ActorRef, ActorCard, DelegationToken, Mandate, ApprovalRequest, ApprovalDecision, EvidenceEvent, versioning, errors. +- Do not depend on `@oaps/core`. -### 7. Evidence Ledger - -Merge OAPS `@oaps/evidence` + AGIT hash-chain semantics: - -```typescript -interface EvidenceEvent { - id: string; - type: string; - timestamp: string; - actor: string; - action: string; - target?: string; - payload: unknown; - privacy: EvidencePrivacy; - prevHash: string; - hash: string; - signature: string; -} - -interface EvidencePrivacy { - level: "public" | "private" | "redacted" | "hash-only"; - redactionKey?: string; -} -``` +OSP: -### 8. Runtime Attestation - -```typescript -interface RuntimeAttestation { - id: string; - agentDid: string; - provider: string; - measurement: string; - timestamp: string; - expiresAt: string; - evidence: unknown; - signature: string; -} - -interface TEEAdapter { - readonly provider: string; - attest(agentDid: string): Promise; - verify(attestation: RuntimeAttestation): Promise; -} -``` +- Use for registry/service lifecycle adapter semantics: discover, provision, rotate, deprovision. +- Do not make OSP service lifecycle FIDES core authority. -Implement `MockTEEProvider`. Add adapter stubs for Nitro, SGX, SEV. +Sardis: -### 9. Kill Switch +- Use generic patterns: policy-before-execution, approvals, kill switch, high-risk action handling, evidence, mandate-chain abstraction. +- Keep stablecoins, MPC wallets, rails, merchants, compliance, spending limits, and payment execution in Sardis. -Port concept from Sardis: +## First 30 Minutes Checklist -```typescript -interface KillSwitch { - engage(target: KillSwitchTarget): void; - disengage(target: KillSwitchTarget): void; - isEngaged(target: KillSwitchTarget): boolean; -} +1. Run: -type KillSwitchTarget = { type: "global" } | { type: "agent"; did: string } | { type: "capability"; id: string } | { type: "principal"; did: string }; +```bash +git status --short --branch +git log --oneline -n 10 +pnpm --filter @fides/core test ``` -### 10. Adapter Interfaces +2. Read: -Create base adapter: - -```typescript -interface ProtocolAdapter { - readonly protocol: string; - handshake(): Promise; - invoke(capability: string, params: unknown): Promise; -} +```bash +sed -n '1,220p' docs/architecture/implementation-plan.md +sed -n '1,220p' docs/architecture/gap-analysis.md +sed -n '1,220p' packages/core/src/canonical-signer.ts +sed -n '1,220p' packages/core/src/identity.ts +sed -n '1,220p' packages/core/src/agent-card.ts ``` -Add stubs for: MCP, A2A, OAPS, OSP, AP2, x402, Sardis. +3. Start Milestone 1 only after confirming docs are committed. -## Required CLI Commands +## Completion Reporting -``` -fides init # Initialize FIDES identity -fides identity create # Create identity (agent, publisher, principal) -fides identity show # Show current identity -fides card create # Create and sign AgentCard -fides card verify # Verify AgentCard -fides capability add # Add capability to card -fides discover # Discover agent -fides trust attest # Create trust attestation -fides trust score # Get reputation score -fides delegate --to # Create delegation token -fides session grant --token # Create session grant -fides session revoke # Revoke session -fides policy evaluate # Evaluate policy -fides policy explain # Explain policy decision -fides evidence append # Append evidence event -fides evidence verify # Verify evidence chain -fides evidence export # Export evidence -fides revoke # Revoke agent -fides incident report # Report incident -fides incident list # List incidents -fides runtime attest # Create runtime attestation -fides runtime verify # Verify runtime attestation -fides registry register # Register with registry -fides registry resolve # Resolve from registry -fides relay send # Send relay message -fides dht publish # Publish DHT pointer -fides dht resolve # Resolve DHT pointer -fides killswitch engage # Engage kill switch -fides killswitch disengage # Disengage kill switch -fides daemon start # Start agentd -fides daemon status # Check agentd status -fides daemon stop # Stop agentd -``` +When stopping, report: -## Required API Endpoints (agentd) +- summary, +- files changed, +- commits created, +- verification run, +- risks/follow-ups. -``` -POST /v1/identities -GET /v1/identities/:did -POST /v1/cards -GET /v1/cards/:did -POST /v1/trust -GET /v1/trust/:did/score -POST /v1/delegate -POST /v1/sessions -DELETE /v1/sessions/:id -POST /v1/evidence -GET /v1/evidence/:did -POST /v1/policy/evaluate -POST /v1/revoke -POST /v1/incidents -GET /v1/incidents -POST /v1/runtime/attest -POST /v1/runtime/verify -GET /v1/killswitch/status -POST /v1/killswitch/engage -POST /v1/killswitch/disengage -GET /v1/discovery/resolve/:did -POST /v1/discovery/register -``` +Do not say done unless: -## Tests - -- Every new package must have unit tests -- Every new service must have integration tests -- E2E tests in `tests/e2e/` -- Adversarial tests in `tests/adversarial/` -- Target coverage: > 80% for new packages - -## Docs - -- Update `README.md` -- Create `docs/protocol/*.md` for each layer -- Create `docs/api/README.md` for HTTP API -- Create `docs/cli/README.md` for CLI -- Create `examples/README.md` -- Update `docs/architecture.md` - -## Commit Rules - -1. **Atomic commits:** One logical change per commit -2. **Meaningful messages:** `feat: add DelegationToken signing`, `fix: trust decay formula`, `test: add adversarial sybil test` -3. **Run tests before commit:** `pnpm test` should pass -4. **Run typecheck before commit:** `pnpm typecheck` should pass -5. **No breaking changes without migration path** -6. **Document breaking changes in commit message** - -## Completion Contract - -The implementation is complete when: - -1. All 15 milestones are implemented -2. All tests pass (`pnpm test`) -3. Typecheck passes (`pnpm typecheck`) -4. Build passes (`pnpm build`) -5. E2E demo runs successfully (`pnpm demo`) -6. All docs are written and cross-referenced -7. No uncommitted user work was overwritten -8. All changes are on branch `fides-v2-agent-trust-fabric` -9. A summary of what was implemented is provided - -## Reference Docs - -Read these before starting: -- `~/fides/docs/inspection/fides-report.md` -- `~/fides/docs/inspection/agit-report.md` -- `~/fides/docs/inspection/osp-report.md` -- `~/fides/docs/inspection/oaps-report.md` -- `~/fides/docs/inspection/sardis-report.md` -- `~/fides/docs/inspection/cross-repo-primitive-map.md` -- `~/fides/docs/architecture/fides-v2-agent-trust-fabric.md` -- `~/fides/docs/architecture/gap-analysis.md` -- `~/fides/docs/architecture/implementation-plan.md` - -## Important Reminders - -- **Do not ask the user what is in the repos.** The inspection reports already document everything. -- **Do not start implementation before reading all reference docs.** -- **Do not delete or rewrite large parts of any repo without explicit reason.** -- **Prefer evolving existing code over creating new files where possible.** -- **Every major claim must be backed by actual file paths from the repos.** -- **Do not hallucinate features.** If you cannot find something, say so. -- **Do not stop after a shallow README scan.** Inspect source code, package structure, docs, tests, examples, and config. +- docs exist, +- core packages build, +- tests/typecheck status is reported, +- demo status is reported, +- blockers are documented. diff --git a/docs/architecture/implementation-plan.md b/docs/architecture/implementation-plan.md index a7af4b0..018a551 100644 --- a/docs/architecture/implementation-plan.md +++ b/docs/architecture/implementation-plan.md @@ -1,879 +1,511 @@ -# Implementation Plan +# FIDES v2 Implementation Plan -This document provides a detailed, milestone-based implementation plan for FIDES v2 / Agent Trust Fabric. Each milestone is designed to be executable by a coding agent with atomic commits. +This plan is designed for a long-running coding goal on branch `fides-v2-agent-trust-fabric`. -## Architecture Decisions (Enforced) +Work style: -- **TS-first, Rust adapter-ready.** -- **OAPS concepts ported into FIDES, not imported as runtime dependencies.** -- **Sardis contributes generic patterns only.** -- **Protocol objects, crypto, canonical JSON, and public schemas remain framework-agnostic.** -- **Public SDK exposes Promise-based APIs.** +- Use atomic commits. +- Run `git status` before edits. +- Preserve untracked local instruction/config files unless explicitly asked otherwise. +- Do not touch dirty sibling repos except read-only inspection. +- Docs first, then code. +- Verify after each meaningful milestone. +- Do not claim completion without test/typecheck/demo status. ---- +## Phase 0: Inspection and Architecture Docs -## Milestone 1: Stabilize FIDES Core +Status: started. -**Goal:** Fix known issues in FIDES v1 before building v2. +Deliverables: -**Files to modify:** -- `packages/shared/src/errors.ts` — Add OAPS error categories -- `packages/shared/src/constants.ts` — Add protocol version constant -- `packages/sdk/src/identity/did.ts` — Document W3C non-compliance -- `packages/sdk/src/identity/rotation.ts` — Document DID-changing rotation behavior -- `services/trust-graph/src/services/scoring.ts` — Fix trust decay formula or update spec -- `docs/protocol-spec.md` — Update to match implementation or vice versa -- `package.json` (discovery, trust-graph) — Normalize to `@fides/discovery`, `@fides/trust-graph` +- `docs/inspection/fides-report.md` +- `docs/inspection/agit-report.md` +- `docs/inspection/osp-report.md` +- `docs/inspection/oaps-report.md` +- `docs/inspection/sardis-report.md` +- `docs/inspection/cross-repo-primitive-map.md` +- `docs/architecture/fides-v2-agent-trust-fabric.md` +- `docs/architecture/gap-analysis.md` +- `docs/architecture/implementation-plan.md` +- `docs/architecture/implementation-agent-prompt.md` -**Packages to create:** None +Commits: -**Types/schemas to add:** -- `ProtocolVersion = "fides-v2.0.0"` -- Extended `FidesError` categories: `capability`, `execution`, `economic`, `settlement`, `versioning` +- `docs: add fides v2 inspection reports` +- `docs: add fides v2 architecture plan` -**Tests to add:** -- Test that trust decay formula matches spec (or update spec test) -- Test nonce replay protection in server services +Verification: -**Docs to update:** -- `docs/protocol-spec.md` -- `docs/architecture.md` +- `pnpm typecheck` after docs if package scripts tolerate doc-only changes. +- `git diff --check`. -**Expected CLI/API behavior:** No breaking changes. `fides status` should report protocol version. +## Milestone 1: Stabilize Protocol Foundation -**Commit checklist:** -- [ ] Fix package names -- [ ] Fix trust decay formula or spec -- [ ] Extend error hierarchy -- [ ] Add protocol version constant -- [ ] Update docs +Goal: establish a coherent v2 protocol surface without breaking existing services. -**Validation command:** `pnpm test` (all existing tests pass) +Tasks: ---- +1. Add v2 protocol constants and version list. +2. Add `ErrorEnvelope` and stable error vocabulary. +3. Add `VersionNegotiationRecord` and helper functions. +4. Add `ProtocolObject` and `SignedProtocolObject` base types. +5. Add compatibility helpers from current `SignedObject`. +6. Add tests for canonical hash stability and error/version records. + +Primary files: + +- `packages/core/src/protocol.ts` +- `packages/core/src/errors.ts` +- `packages/core/src/versioning.ts` +- `packages/core/src/canonical-signer.ts` +- `packages/core/src/index.ts` +- `packages/core/test/*` +- `packages/shared/src/errors.ts` + +Commit: + +- `feat(core): add protocol versioning and error envelopes` + +Verification: + +- `pnpm --filter @fides/core test` +- `pnpm --filter @fides/core typecheck` ## Milestone 2: Identity v2 -**Goal:** Introduce AgentIdentity, PublisherIdentity, PrincipalIdentity, and trust anchors. - -**Files to modify:** -- `packages/sdk/src/identity/` — Refactor to support multi-level identity -- `packages/shared/src/types.ts` — Add new identity types - -**Packages to create:** -- `packages/@fides/core/` — New package for core primitives - -**Types/schemas to add:** -```typescript -interface AgentIdentity { - did: string; - publicKey: Uint8Array; - keyType: "Ed25519"; - createdAt: string; - publisher?: PublisherIdentity; - principal?: PrincipalIdentity; -} - -interface PublisherIdentity { - did: string; - name: string; - domain?: string; - verified: boolean; - verificationMethod: "dns" | "github" | "email" | "manual"; -} - -interface PrincipalIdentity { - did: string; - type: "individual" | "organization" | "platform"; - displayName: string; -} - -interface TrustAnchor { - did: string; - name: string; - publicKey: Uint8Array; - attestation: SignedObject; -} -``` - -**Tests to add:** -- Identity creation and round-trip -- Publisher verification mock -- Principal identity resolution -- Trust anchor validation - -**Docs to update:** -- `docs/protocol/identity-v2.md` - -**Expected CLI/API behavior:** -- `fides identity create --type agent` -- `fides identity create --type publisher --name "Acme Corp" --domain acme.com` -- `fides identity create --type principal --name "Alice"` -- `fides trust anchor add ` - -**Commit checklist:** -- [ ] Create `@fides/core` package -- [ ] Add identity v2 types -- [ ] Refactor existing identity code to use new types (backward compatible) -- [ ] Add trust anchor primitive -- [ ] Add CLI commands -- [ ] Write tests - -**Validation command:** `pnpm test && pnpm typecheck` - ---- - -## Milestone 3: AgentCards and Capabilities - -**Goal:** Signed AgentCards with CapabilityDescriptors. - -**Files to modify:** -- `packages/shared/src/types.ts` — Extend AgentCard -- `packages/sdk/src/discovery/agent-client.ts` — Use new AgentCard -- `services/discovery/src/routes/well-known.ts` — Serve signed AgentCards - -**Packages to create:** None (use `@fides/core`) - -**Types/schemas to add:** -```typescript -interface AgentCard { - id: string; - identity: AgentIdentity; - publisher?: PublisherIdentity; - capabilities: CapabilityDescriptor[]; - endpoints: EndpointDescriptor[]; - policies: PolicyRequirement[]; - createdAt: string; - updatedAt: string; -} - -interface CapabilityDescriptor { - id: string; - name: string; - description: string; - inputSchema: JSONSchema; - outputSchema: JSONSchema; - riskLevel: "low" | "medium" | "high" | "critical"; - requiresApproval: boolean; - requiresRuntimeAttestation: boolean; -} - -interface SignedAgentCard extends SignedObject {} -``` - -**Tests to add:** -- AgentCard creation and validation -- CapabilityDescriptor risk classification -- SignedAgentCard verification -- A2A compatibility conversion - -**Docs to update:** -- `docs/protocol/agent-cards.md` -- `docs/protocol/capabilities.md` - -**Expected CLI/API behavior:** -- `fides card create` — creates and signs AgentCard -- `fides card verify ` — verifies signed AgentCard -- `fides capability add --risk high` - -**Commit checklist:** -- [ ] Add AgentCard v2 types -- [ ] Add CapabilityDescriptor -- [ ] Implement canonical signing for AgentCard -- [ ] Update discovery service to serve signed cards -- [ ] Update CLI -- [ ] Write tests - -**Validation command:** `pnpm test && pnpm typecheck` - ---- - -## Milestone 4: Discovery Provider Architecture - -**Goal:** Pluggable discovery with 5 providers. - -**Files to modify:** -- `packages/sdk/src/discovery/` — Refactor to use provider pattern - -**Packages to create:** -- `packages/@fides/discovery/` — Discovery provider framework - -**Types/schemas to add:** -```typescript -interface DiscoveryProvider { - readonly name: string; - resolve(did: string): Promise; - register(card: SignedAgentCard): Promise; - deregister(did: string): Promise; -} - -class LocalDiscoveryProvider implements DiscoveryProvider { /* mDNS / local network */ } -class WellKnownDiscoveryProvider implements DiscoveryProvider { /* HTTP .well-known */ } -class RegistryDiscoveryProvider implements DiscoveryProvider { /* Hosted registry */ } -class RelayDiscoveryProvider implements DiscoveryProvider { /* Relay server */ } -class DHTDiscoveryProvider implements DiscoveryProvider { /* DHT pointers */ } - -class DiscoveryOrchestrator { - constructor(providers: DiscoveryProvider[]); - resolve(did: string): Promise; -} -``` - -**Tests to add:** -- Each provider unit test -- Orchestrator fallback test -- Provider priority test - -**Docs to update:** -- `docs/protocol/discovery.md` - -**Expected CLI/API behavior:** -- `fides discover --provider local` -- `fides discover --provider registry` -- `fides discover --provider all` - -**Commit checklist:** -- [ ] Create `@fides/discovery` package -- [ ] Implement provider interface -- [ ] Implement WellKnown provider (port from existing) -- [ ] Implement Local provider (stub with mDNS-ready interface) -- [ ] Implement Registry provider (stub with HTTP interface) -- [ ] Implement Relay provider (stub) -- [ ] Implement DHT provider (stub) -- [ ] Write tests - -**Validation command:** `pnpm test && pnpm typecheck` - ---- - -## Milestone 5: Registry and Relay - -**Goal:** Hosted registry (public/private) and mock relay server. - -**Files to modify:** -- `services/discovery/src/` — Extend with registry routes - -**Packages to create:** -- `services/registry/` — New registry service (or extend discovery) -- `services/relay/` — Mock relay server - -**Types/schemas to add:** -```typescript -interface RegistryRecord { - did: string; - card: SignedAgentCard; - registeredAt: string; - updatedAt: string; - registryMode: "public" | "private"; - federationPeers: string[]; -} - -interface RelayMessage { - id: string; - to: string; - payload: unknown; - ttl: number; -} -``` - -**Tests to add:** -- Registry CRUD -- Private registry access control -- Relay message routing -- Federation peering (stub) - -**Docs to update:** -- `docs/protocol/registry.md` -- `docs/protocol/relay.md` - -**Expected CLI/API behavior:** -- `fides registry register --card ./agent-card.json` -- `fides registry resolve ` -- `fides registry set-mode private` -- `fides relay send --to --message "..."` - -**Commit checklist:** -- [ ] Create registry service -- [ ] Add public/private mode -- [ ] Create mock relay server -- [ ] Add federation peering stubs -- [ ] Update discovery service to use registry -- [ ] Write tests - -**Validation command:** `pnpm test && docker-compose -f docker-compose.dev.yml up --build` - ---- - -## Milestone 6: DHT Discovery - -**Goal:** Signed DHT pointer records and in-memory DHT simulator. - -**Files to modify:** -- `packages/@fides/discovery/src/dht-provider.ts` - -**Packages to create:** None - -**Types/schemas to add:** -```typescript -interface DHTPointerRecord { - did: string; - registryUrl: string; - relayUrl?: string; - signature: string; // signed by DID - ttl: number; -} -``` - -**Tests to add:** -- DHT record creation and validation -- In-memory DHT simulator lookup -- Record expiry - -**Docs to update:** -- `docs/protocol/dht.md` - -**Expected CLI/API behavior:** -- `fides dht publish --registry ` -- `fides dht resolve ` - -**Commit checklist:** -- [ ] Implement DHT pointer record -- [ ] Implement in-memory DHT simulator -- [ ] Add libp2p adapter interface (stub) -- [ ] Write tests - -**Validation command:** `pnpm test` - ---- - -## Milestone 7: Trust and Reputation v2 - -**Goal:** Capability-specific reputation, context-specific trust, incident penalties. - -**Files to modify:** -- `services/trust-graph/src/services/scoring.ts` -- `services/trust-graph/src/db/schema.ts` -- `packages/sdk/src/trust/` - -**Packages to create:** None - -**Types/schemas to add:** -```typescript -interface ReputationScore { - did: string; - globalScore: number; - capabilityScores: Record; - contextScores: Record; - incidentPenalty: number; - noveltyPenalty: number; - runtimeSafetyScore: number; - calculatedAt: string; -} - -interface TrustEdge { - from: string; - to: string; - level: TrustLevel; - capability?: string; - context?: string; - createdAt: string; - expiresAt?: string; -} -``` - -**Tests to add:** -- Capability-specific reputation calculation -- Context-specific trust path -- Incident penalty application -- Time-based decay - -**Docs to update:** -- `docs/protocol/trust-v2.md` -- `docs/protocol/reputation.md` - -**Expected CLI/API behavior:** -- `fides trust score --capability ` -- `fides trust score --context ` - -**Commit checklist:** -- [ ] Update DB schema for capability/context trust edges -- [ ] Update scoring algorithm -- [ ] Add incident/novelty/runtime penalties -- [ ] Update trust attestation to support capability scope -- [ ] Write tests - -**Validation command:** `pnpm test` - ---- - -## Milestone 8: Policy Engine - -**Goal:** Full policy engine with risk taxonomy, guardrails, and pre-execution pipeline. - -**Files to modify:** -- `services/policy-engine/` — Replace stub with full implementation - -**Packages to create:** -- `packages/@fides/policy/` — Policy engine package - -**Types/schemas to add:** -```typescript -interface PolicyBundle { - id: string; - version: string; - rules: PolicyRule[]; - defaultAction: "allow" | "deny" | "approve-required"; -} - -interface PolicyRule { - id: string; - condition: PolicyExpression; - action: "allow" | "deny" | "approve-required" | "dry-run"; - explanation: string; -} - -interface PolicyExpression { - operator: "eq" | "neq" | "lt" | "lte" | "gt" | "gte" | "in" | "all" | "any"; - field: string; - value: unknown; -} - -interface PolicyResult { - decision: "allow" | "deny" | "approve-required" | "dry-run"; - explanation: DecisionExplanation; - matchedRules: string[]; -} - -interface DecisionExplanation { - decision: string; - factors: { factor: string; weight: number; description: string }[]; -} -``` - -**Tests to add:** -- Policy evaluation (all expression operators) -- Guardrail Allow/Warn/Block -- High-risk capability handling -- Revoked agent denial -- Invalid runtime attestation denial - -**Docs to update:** -- `docs/protocol/policy.md` -- `docs/protocol/guardrails.md` -- `docs/protocol/risk.md` - -**Expected CLI/API behavior:** -- `fides policy evaluate --bundle ./policy.json --request ./request.json` -- `fides policy explain --did --capability ` - -**Commit checklist:** -- [ ] Create `@fides/policy` package -- [ ] Implement policy evaluator (ported from OAPS) -- [ ] Implement pre-execution pipeline (ported from Sardis pattern) -- [ ] Implement risk taxonomy -- [ ] Integrate with trust graph scores -- [ ] Integrate with runtime attestation -- [ ] Write tests - -**Validation command:** `pnpm test && pnpm typecheck` - ---- - -## Milestone 9: Delegation and Sessions - -**Goal:** DelegationToken, SessionGrant, scoped authority. - -**Files to modify:** -- `packages/@fides/core/` — Add delegation primitives - -**Packages to create:** None - -**Types/schemas to add:** -```typescript -interface DelegationToken { - id: string; - delegator: string; // DID - delegatee: string; // DID - capabilities: string[]; // capability IDs - constraints: DelegationConstraint; - issuedAt: string; - expiresAt: string; - nonce: string; - audience?: string[]; - signature: string; -} - -interface SessionGrant { - id: string; - token: DelegationToken; - sessionKey: string; - expiresAt: string; - boundTo?: string; // IP, device fingerprint, etc. -} - -interface DelegationConstraint { - maxActions?: number; - maxSpend?: string; // currency amount - allowedContexts?: string[]; - forbiddenContexts?: string[]; -} -``` - -**Tests to add:** -- DelegationToken creation and verification -- SessionGrant issuance and expiry -- Replay protection (nonce) -- Audience restriction -- Constraint enforcement - -**Docs to update:** -- `docs/protocol/delegation.md` -- `docs/protocol/sessions.md` - -**Expected CLI/API behavior:** -- `fides delegate --to --capability --expires 1h` -- `fides session grant --token ` -- `fides session revoke ` - -**Commit checklist:** -- [ ] Add DelegationToken type and signing -- [ ] Add SessionGrant type -- [ ] Implement nonce/replay protection -- [ ] Implement audience restriction -- [ ] Implement constraint validation -- [ ] Write tests - -**Validation command:** `pnpm test` - ---- - -## Milestone 10: Evidence Ledger - -**Goal:** Append-only evidence ledger with hash chain and Merkle proofs. - -**Files to modify:** None - -**Packages to create:** -- `packages/@fides/evidence/` — Evidence ledger package - -**Types/schemas to add:** -```typescript -interface EvidenceEvent { - id: string; - type: string; - timestamp: string; - actor: string; // DID - action: string; - target?: string; - payload: unknown; - privacy: EvidencePrivacy; - prevHash: string; - hash: string; - signature: string; -} - -interface EvidencePrivacy { - level: "public" | "private" | "redacted" | "hash-only"; - redactionKey?: string; -} - -interface EvidenceChain { - events: EvidenceEvent[]; - merkleRoot?: string; -} -``` - -**Tests to add:** -- EvidenceEvent creation and signing -- Hash chain integrity -- Merkle proof generation and verification -- Privacy level application -- Chain export - -**Docs to update:** -- `docs/protocol/evidence.md` -- `docs/protocol/evidence-privacy.md` - -**Expected CLI/API behavior:** -- `fides evidence append --type invocation --actor --action ` -- `fides evidence verify --chain ./evidence.json` -- `fides evidence export --did --format json` - -**Commit checklist:** -- [ ] Create `@fides/evidence` package -- [ ] Implement EvidenceEvent with canonical signing -- [ ] Implement hash chain (SHA-256 chaining) -- [ ] Implement Merkle tree builder -- [ ] Implement privacy levels -- [ ] Write tests - -**Validation command:** `pnpm test && pnpm typecheck` - ---- - -## Milestone 11: Revocation and Incidents - -**Goal:** RevocationRecord, IncidentRecord, and automated response. - -**Files to modify:** -- `packages/sdk/src/identity/rotation.ts` — Extend revocation - -**Packages to create:** None - -**Types/schemas to add:** -```typescript -interface RevocationRecord { - id: string; - did: string; - reason: string; - revokedAt: string; - revokedBy: string; - signature: string; - propagatedTo: string[]; -} - -interface IncidentRecord { - id: string; - type: "compromise" | "misbehavior" | "policy_violation" | "runtime_failure" | "sybil"; - severity: "low" | "medium" | "high" | "critical"; - actor: string; - description: string; - evidenceRefs: string[]; - reportedAt: string; - resolvedAt?: string; - impact: { - trustPenalty: number; - reputationPenalty: number; - capabilitiesRevoked: string[]; - }; -} -``` - -**Tests to add:** -- RevocationRecord creation and propagation -- IncidentRecord creation and impact calculation -- Policy engine integration (revoked agent denial) -- Trust graph integration (incident penalties) - -**Docs to update:** -- `docs/protocol/revocation.md` -- `docs/protocol/incidents.md` - -**Expected CLI/API behavior:** -- `fides revoke --reason "key compromise"` -- `fides incident report --actor --type misbehavior` -- `fides incident list --severity critical` - -**Commit checklist:** -- [ ] Add RevocationRecord type -- [ ] Add IncidentRecord type -- [ ] Implement revocation propagation interface -- [ ] Integrate with policy engine -- [ ] Integrate with trust graph -- [ ] Write tests - -**Validation command:** `pnpm test` - ---- - -## Milestone 12: Runtime Attestation - -**Goal:** RuntimeAttestation, MockTEEProvider, adapter interfaces. - -**Files to modify:** None - -**Packages to create:** -- `packages/@fides/runtime/` — Runtime attestation package - -**Types/schemas to add:** -```typescript -interface RuntimeAttestation { - id: string; - agentDid: string; - provider: string; // "mock-tee", "aws-nitro", "intel-sgx", "amd-sev" - measurement: string; // hash of runtime state - timestamp: string; - expiresAt: string; - evidence: unknown; // provider-specific attestation evidence - signature: string; -} - -interface TEEAdapter { - readonly provider: string; - attest(agentDid: string): Promise; - verify(attestation: RuntimeAttestation): Promise; -} - -interface BuildAttestationAdapter { - attest(imageHash: string): Promise; -} -``` - -**Tests to add:** -- MockTEEProvider attestation and verification -- RuntimeAttestation expiry -- Policy engine integration (invalid attestation denial) -- Adapter interface compliance - -**Docs to update:** -- `docs/protocol/runtime-attestation.md` -- `docs/protocol/tee-adapters.md` - -**Expected CLI/API behavior:** -- `fides runtime attest --provider mock-tee` -- `fides runtime verify --attestation ./attestation.json` - -**Commit checklist:** -- [ ] Create `@fides/runtime` package -- [ ] Implement RuntimeAttestation type -- [ ] Implement MockTEEProvider -- [ ] Add adapter interfaces (Nitro, SGX, SEV stubs) -- [ ] Add container/build attestation interfaces -- [ ] Integrate with policy engine -- [ ] Write tests - -**Validation command:** `pnpm test && pnpm typecheck` - ---- - -## Milestone 13: CLI and API - -**Goal:** Extended CLI and local HTTP API. - -**Files to modify:** -- `packages/cli/src/` — Add new commands -- `services/` — Ensure all services expose stable APIs - -**Packages to create:** -- `services/agentd/` — Local daemon - -**Types/schemas to add:** -```typescript -// agentd HTTP API routes -POST /v1/identities -GET /v1/identities/:did -POST /v1/cards -GET /v1/cards/:did -POST /v1/trust -GET /v1/trust/:did/score -POST /v1/delegate -POST /v1/sessions -POST /v1/evidence -GET /v1/evidence/:did -POST /v1/policy/evaluate -POST /v1/revoke -POST /v1/incidents -GET /v1/runtime/attest -``` - -**Tests to add:** -- CLI command tests for all new commands -- agentd HTTP API integration tests - -**Docs to update:** -- `docs/api/README.md` -- `docs/cli/README.md` - -**Expected CLI/API behavior:** -- All previous CLI commands work -- New commands: `fides card`, `fides delegate`, `fides session`, `fides evidence`, `fides policy`, `fides revoke`, `fides incident`, `fides runtime`, `fides registry`, `fides relay`, `fides dht` -- `agentd` runs local HTTP API on port 7345 (FIDES) - -**Commit checklist:** -- [ ] Extend CLI with all new commands -- [ ] Create `agentd` service -- [ ] Add local HTTP API -- [ ] Write CLI tests -- [ ] Write agentd integration tests - -**Validation command:** `pnpm test && pnpm build` - ---- - -## Milestone 14: Examples and Full Demo - -**Goal:** Runnable example agents and end-to-end demo. - -**Files to modify:** None - -**Packages to create:** None - -**Files to add:** -- `examples/calendar-agent/` — Calendar agent with FIDES identity -- `examples/invoice-agent/` — Invoice agent with delegation -- `examples/payment-agent/` — Payment agent with policy + evidence -- `examples/requester-agent/` — Agent that discovers and invokes others -- `examples/demo/` — Full demo script - -**Tests to add:** -- E2E demo test - -**Docs to update:** -- `examples/README.md` -- `docs/getting-started.md` +Goal: harden identity around real cryptographic issuance and trust anchors. -**Expected behavior:** -- `pnpm demo` runs full multi-agent trust fabric demo -- Demo shows: identity creation, discovery, trust attestation, delegation, policy enforcement, evidence collection, revocation +Tasks: -**Commit checklist:** -- [ ] Create example agents -- [ ] Create demo script -- [ ] Write E2E test -- [ ] Update docs +1. Replace loose identity creation with real Ed25519 keypair creation. +2. Add `AgentIdentity`, `PublisherIdentity`, `PrincipalIdentity` v2 fields. +3. Add publisher types: anonymous, self_signed, verified_individual, platform_hosted, domain_verified, organization_verified. +4. Add trust anchor types: domain, GitHub, email, npm, PyPI, wallet, passkey, organization invitation, runtime attestation, build attestation, peer attestation. +5. Keep domain and org DNS verification. +6. Add tests for domainless identity and trust anchor validation. -**Validation command:** `pnpm demo` (manual verification) +Primary files: ---- +- `packages/core/src/identity.ts` +- `packages/core/src/trust-anchor.ts` +- `packages/core/src/domain-verifier.ts` +- `packages/core/src/passkey.ts` +- `packages/core/test/identity.test.ts` +- `packages/core/test/trust-anchor.test.ts` -## Milestone 15: Docs and Tests +Commit: -**Goal:** Complete documentation, threat model, and test suite. +- `feat(identity): harden fides identity v2` -**Files to modify:** None +Verification: -**Files to add:** -- `docs/threat-model.md` -- `docs/security-review-checklist.md` -- `docs/production-hardening.md` -- `tests/adversarial/` — Adversarial simulation harness +- `pnpm --filter @fides/core test` +- `pnpm --filter @fides/core typecheck` + +## Milestone 3: AgentCards and Capability Ontology + +Goal: signed AgentCards that describe capability, risk, transport, policy, and trust metadata. + +Tasks: + +1. Add AgentCard v2 fields from the pivot. +2. Add `CapabilityDescriptor` fields: namespace, action, resource, capability id, input/output schemas, risk class, scopes, controls, dry-run, approval, policy proof. +3. Add `CapabilityOntologyEntry`. +4. Add risk classes and sample capabilities. +5. Add signed AgentCard creation/verification helpers. +6. Add compatibility mapping for current AgentCard shapes. + +Primary files: + +- `packages/core/src/agent-card.ts` +- `packages/core/src/capability.ts` +- `packages/core/src/canonical-signer.ts` +- `packages/core/test/agent-card.test.ts` +- `packages/core/test/capability.test.ts` + +Commit: + +- `feat(cards): add signed agent cards and capability ontology` + +Verification: + +- `pnpm --filter @fides/core test` +- `pnpm --filter @fides/core typecheck` + +## Milestone 4: Evidence v2 + +Goal: signed, privacy-aware, hash-chained evidence events. + +Tasks: + +1. Add EvidenceEvent v2 with requested fields and event taxonomy. +2. Default sensitive payloads to hash-only/redacted. +3. Add input/output/policy/decision hashing helpers. +4. Add signed event append/verify. +5. Add Merkle proof-ready export shape. +6. Add compatibility adapter from current evidence event. + +Primary files: + +- `packages/evidence/src/index.ts` +- `packages/evidence/test/evidence.test.ts` +- `packages/core/src/protocol.ts` + +Commit: + +- `feat(evidence): add signed privacy-aware evidence events` + +Verification: + +- `pnpm --filter @fides/evidence test` +- `pnpm --filter @fides/evidence typecheck` + +## Milestone 5: Discovery v2 and DHT Pointers + +Goal: discovery returns verified candidates and never authority. + +Tasks: + +1. Add `DiscoveryQuery` and `DiscoveryCandidate`. +2. Extend provider interface with `discover(query): Promise`. +3. Preserve old `resolve(did)` as compatibility. +4. Add verification pipeline in orchestrator. +5. Add signed `DHTPointerRecord`. +6. Replace DHT direct-card lookup with pointer publish/find path while keeping compatibility methods behind tests. +7. Add DHT tests: valid pointer, tamper rejection, expiry, card hash mismatch, revoked agent. + +Primary files: + +- `packages/discovery/src/provider.ts` +- `packages/discovery/src/orchestrator.ts` +- `packages/discovery/src/dht-provider.ts` +- `packages/discovery/src/local-provider.ts` +- `packages/discovery/src/registry-provider.ts` +- `packages/discovery/src/relay-provider.ts` +- `packages/discovery/test/*` +- `packages/core/src/discovery.ts` +- `packages/core/src/dht.ts` + +Commits: + +- `feat(discovery): add capability query provider contract` +- `feat(dht): add signed pointer records` + +Verification: + +- `pnpm --filter @fides/discovery test` +- `pnpm --filter @fides/discovery typecheck` + +## Milestone 6: Trust and Reputation v2 + +Goal: capability-specific trust and explainable scoring. + +Tasks: + +1. Add `TrustResult`, trust bands, score component types. +2. Add `ReputationRecord`. +3. Extend trust graph service scoring with component explanations. +4. Integrate incidents, novelty, context boundary, publisher weighting. +5. Add API/SDK compatibility layer. + +Primary files: + +- `packages/core/src/trust.ts` +- `services/trust-graph/src/services/trust-service.ts` +- `services/trust-graph/src/services/capability-scoring.ts` +- `services/trust-graph/src/routes/trust.ts` +- `packages/sdk/src/trust/client.ts` + +Commit: + +- `feat(trust): add explainable capability trust results` + +Verification: + +- `pnpm --filter @fides/trust-graph test` +- `pnpm --filter @fides/sdk test` + +## Milestone 7: Policy, Approvals, Kill Switch + +Goal: policy-before-execution with durable approval and kill switch primitives. + +Tasks: + +1. Add decision vocabulary compatibility. +2. Add `ApprovalRequest`, `ApprovalDecision`, `ApprovalPolicy`. +3. Add `KillSwitchRule` protocol object. +4. Wire guard/policy to durable approval state. +5. Add evidence events for approval and kill-switch lifecycle. + +Primary files: + +- `packages/core/src/approval.ts` +- `packages/core/src/kill-switch.ts` +- `packages/policy/src/index.ts` +- `packages/guard/src/index.ts` +- `packages/runtime/src/index.ts` +- `services/agentd/src/index.ts` + +Commits: + +- `feat(policy): add approval primitives` +- `feat(policy): add kill switch rules` + +Verification: + +- `pnpm --filter @fides/policy test` +- `pnpm --filter @fides/guard test` +- `pnpm --filter @fides/agentd test` + +## Milestone 8: Delegation, Sessions, Invocation + +Goal: signed scoped authority and generic invocation flow. + +Tasks: + +1. Harden `DelegationToken`. +2. Add v2 `SessionGrant`. +3. Add replay protection and audience restriction tests. +4. Add `InvocationRequest` and `InvocationResult`. +5. Add dry-run/approval-required/denied/allowed/failed status flow. +6. Emit evidence events. + +Primary files: + +- `packages/core/src/delegation.ts` +- `packages/core/src/session-store.ts` +- `packages/core/src/invocation.ts` +- `services/agentd/src/index.ts` +- `packages/sdk/src/agentd/client.ts` + +Commits: + +- `feat(delegation): add signed session grants` +- `feat(invocation): add capability invocation objects` -**Tests to add:** -- Adversarial tests: Sybil, replay, policy bypass, delegation abuse -- Load tests for trust graph -- Fuzz tests for policy evaluator +Verification: -**Docs to update:** +- `pnpm --filter @fides/core test` +- `pnpm --filter @fides/agentd test` +- `pnpm --filter @fides/sdk test` + +## Milestone 9: Revocation, Incidents, Runtime Attestation + +Goal: revocation/incident/attestation become first-class v2 signed objects. + +Tasks: + +1. Add revocation target taxonomy. +2. Add incident categories and resolution status. +3. Add `RuntimeAttestation` v2 schema fields. +4. Add `NullAttestationProvider`. +5. Integrate invalid/expired attestations into policy/trust. + +Primary files: + +- `packages/core/src/revocation.ts` +- `packages/core/src/runtime-attestation.ts` +- `packages/runtime/src/index.ts` +- `services/agentd/src/index.ts` +- `services/trust-graph/src/services/trust-service.ts` + +Commits: + +- `feat(revocation): add v2 revocation records` +- `feat(incidents): add incident resolution records` +- `feat(attestations): add runtime attestation v2` + +Verification: + +- `pnpm --filter @fides/core test` +- `pnpm --filter @fides/runtime test` +- `pnpm --filter @fides/trust-graph test` + +## Milestone 10: Registry, Relay, Federation + +Goal: hosted/public/private registry, relay presence, and federation-ready records. + +Tasks: + +1. Add signed `RegistryIndexRecord`. +2. Add `RegistryPeerRecord`. +3. Add public/private mode docs and tests. +4. Add local mock federation provider. +5. Add revocation/incident propagation interfaces. +6. Keep relay as presence/rendezvous only. + +Primary files: + +- `services/registry/src/` +- `services/relay/src/` +- `packages/core/src/registry.ts` +- `packages/core/src/federation.ts` +- `packages/discovery/src/registry-provider.ts` +- `packages/discovery/src/relay-provider.ts` + +Commits: + +- `feat(registry): add signed index records` +- `feat(registry): add federation peer records` +- `feat(relay): clarify relay discovery authority boundaries` + +Verification: + +- `pnpm --filter @fides/registry-service test` +- `pnpm --filter @fides/relay-service test` +- `pnpm --filter @fides/discovery test` + +## Milestone 11: Adapters + +Goal: FIDES has explicit interop boundaries. + +Tasks: + +1. Add `packages/adapters`. +2. Add interfaces for MCP, A2A, OAPS, OSP, AP2, x402, Sardis. +3. Add mapping docs. +4. Add simple no-network tests. + +Primary files: + +- `packages/adapters/` +- `docs/protocol/interop-adapters.md` + +Commit: + +- `feat(adapters): add interop adapter interfaces` + +Verification: + +- `pnpm --filter @fides/adapters test` +- `pnpm --filter @fides/adapters typecheck` + +## Milestone 12: Agentd CLI/API/SDK Alignment + +Goal: make local developer flow match the requested `agentd` surface. + +Tasks: + +1. Add `agentd` binary or alias. +2. Add missing CLI commands: + - identity create/list/show, + - attest, + - card create/sign/verify/inspect, + - registry/relay/dht, + - demo run, + - simulate adversarial. +3. Add requested local HTTP endpoints as compatibility routes where needed. +4. Add SDK methods matching the example. +5. Add local SQLite store if still pending. + +Primary files: + +- `packages/cli/src/index.ts` +- `packages/cli/src/commands/` +- `services/agentd/src/index.ts` +- `services/agentd/src/storage.ts` +- `packages/sdk/src/` +- `docs/api/agentd.yaml` +- `docs/cli-reference.md` +- `docs/sdk-reference.md` + +Commits: + +- `feat(cli): add agentd command surface` +- `feat(api): add fides v2 local endpoints` +- `feat(sdk): add promise client v2 flow` + +Verification: + +- `pnpm --filter @fides/cli test` +- `pnpm --filter @fides/agentd test` +- `pnpm --filter @fides/sdk test` + +## Milestone 13: Examples, Demo, Adversarial Simulation + +Goal: prove the full local DX. + +Tasks: + +1. Add example agent folders. +2. Add full demo scenario. +3. Add malicious agent. +4. Add adversarial simulation harness. +5. Add manual DX script/runbook. + +Primary files: + +- `examples/calendar-agent/` +- `examples/invoice-agent/` +- `examples/payment-agent/` +- `examples/malicious-agent/` +- `examples/requester-agent/` +- `examples/full-demo/` +- `tests/adversarial/` +- `docs/adversarial-simulation.md` + +Commits: + +- `feat(examples): add fides v2 demo agents` +- `feat(sim): add adversarial simulation harness` + +Verification: + +- `pnpm test` +- `agentd demo run` +- `agentd simulate adversarial` + +## Milestone 14: Docs Completion + +Goal: make the repo publishable. + +Tasks: + +1. Add protocol docs from the pivot. +2. Add ADRs. +3. Update README and getting started. +4. Add API/CLI/SDK references. +5. Add threat model. +6. Document limitations honestly. + +Primary files: + +- `docs/protocol/*.md` +- `docs/adr/*.md` +- `docs/threat-model.md` +- `docs/getting-started.md` +- `docs/api-reference.md` +- `docs/cli-reference.md` +- `docs/sdk-reference.md` - `README.md` -- `docs/README.md` -- All protocol docs reviewed and cross-linked - -**Expected behavior:** -- All docs are consistent and cross-referenced -- Adversarial tests run in CI -- Test coverage > 80% for new packages - -**Commit checklist:** -- [ ] Write threat model -- [ ] Write security review checklist -- [ ] Write production hardening notes -- [ ] Implement adversarial simulation harness -- [ ] Add adversarial tests to CI -- [ ] Review and update all docs - -**Validation command:** `pnpm test && pnpm coverage` - ---- - -## Summary Timeline - -| Phase | Milestones | Estimated Duration | -|-------|-----------|-------------------| -| Phase 0: Foundation | 1-3 | 2 weeks | -| Phase 1: Discovery | 4-6 | 2 weeks | -| Phase 2: Trust & Policy | 7-9 | 2 weeks | -| Phase 3: Evidence & Runtime | 10-12 | 2 weeks | -| Phase 4: Developer Surface | 13-15 | 2 weeks | -| **Total** | **1-15** | **10 weeks** | - -This is a rough estimate. Parallel work on independent milestones can shorten the timeline. + +Commits: + +- `docs: add fides v2 protocol docs` +- `docs: add fides v2 getting started` + +Verification: + +- `pnpm verify` +- Manual CLI demo. + +## Final Completion Contract + +Final report must include: + +1. What was implemented. +2. What is production-like. +3. What is working prototype. +4. What is local mock. +5. What is adapter-ready. +6. What is spec-complete. +7. Package overview. +8. CLI command overview. +9. API endpoint overview. +10. SDK example. +11. How to run tests. +12. How to run demo. +13. How to run adversarial simulation. +14. Known limitations. +15. Future hardening steps. +16. Commit history summary. From a416e03554b4321647915c1902bbd251623c2071 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:05:20 +0300 Subject: [PATCH 003/282] feat(core): add protocol versioning and error envelopes --- packages/core/src/errors.ts | 175 ++++++++++++++++++++++++++ packages/core/src/index.ts | 3 + packages/core/src/protocol.ts | 86 +++++++++++++ packages/core/src/versioning.ts | 80 ++++++++++++ packages/core/test/errors.test.ts | 40 ++++++ packages/core/test/protocol.test.ts | 69 ++++++++++ packages/core/test/versioning.test.ts | 53 ++++++++ 7 files changed, 506 insertions(+) create mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/protocol.ts create mode 100644 packages/core/src/versioning.ts create mode 100644 packages/core/test/errors.test.ts create mode 100644 packages/core/test/protocol.test.ts create mode 100644 packages/core/test/versioning.test.ts diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..7734b10 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,175 @@ +export type FidesErrorCategory = + | 'identity' + | 'agent_card' + | 'capability' + | 'trust' + | 'policy' + | 'approval' + | 'session' + | 'attestation' + | 'discovery' + | 'dht' + | 'evidence' + | 'revocation' + | 'kill_switch' + | 'version' + | 'internal' + +export type FidesErrorSeverity = 'info' | 'warning' | 'error' | 'critical' + +export const FIDES_ERROR_CODES = { + IDENTITY_INVALID_SIGNATURE: { + category: 'identity', + severity: 'error', + retryable: false, + message: 'Identity signature is invalid', + }, + AGENT_CARD_EXPIRED: { + category: 'agent_card', + severity: 'error', + retryable: false, + message: 'AgentCard is expired', + }, + AGENT_CARD_REVOKED: { + category: 'agent_card', + severity: 'critical', + retryable: false, + message: 'AgentCard is revoked', + }, + CAPABILITY_NOT_FOUND: { + category: 'capability', + severity: 'error', + retryable: false, + message: 'Capability was not found', + }, + CAPABILITY_SCHEMA_INVALID: { + category: 'capability', + severity: 'error', + retryable: false, + message: 'Capability schema is invalid', + }, + TRUST_BELOW_THRESHOLD: { + category: 'trust', + severity: 'warning', + retryable: false, + message: 'Trust score is below policy threshold', + }, + POLICY_DENIED: { + category: 'policy', + severity: 'error', + retryable: false, + message: 'Policy denied the request', + }, + APPROVAL_REQUIRED: { + category: 'approval', + severity: 'warning', + retryable: true, + message: 'Approval is required before execution', + }, + SESSION_EXPIRED: { + category: 'session', + severity: 'error', + retryable: true, + message: 'Session is expired', + }, + SESSION_SCOPE_INVALID: { + category: 'session', + severity: 'error', + retryable: false, + message: 'Session scope does not allow this action', + }, + ATTESTATION_INVALID: { + category: 'attestation', + severity: 'error', + retryable: false, + message: 'Runtime attestation is invalid', + }, + ATTESTATION_EXPIRED: { + category: 'attestation', + severity: 'error', + retryable: true, + message: 'Runtime attestation is expired', + }, + DHT_POINTER_TAMPERED: { + category: 'dht', + severity: 'critical', + retryable: false, + message: 'DHT pointer record was tampered with', + }, + DHT_POINTER_EXPIRED: { + category: 'dht', + severity: 'error', + retryable: true, + message: 'DHT pointer record is expired', + }, + EVIDENCE_CHAIN_BROKEN: { + category: 'evidence', + severity: 'critical', + retryable: false, + message: 'Evidence hash chain is broken', + }, + REVOCATION_ACTIVE: { + category: 'revocation', + severity: 'critical', + retryable: false, + message: 'An active revocation blocks this action', + }, + KILL_SWITCH_ACTIVE: { + category: 'kill_switch', + severity: 'critical', + retryable: false, + message: 'Kill switch is active', + }, + VERSION_INCOMPATIBLE: { + category: 'version', + severity: 'error', + retryable: false, + message: 'Protocol versions are incompatible', + }, +} as const satisfies Record + +export type FidesErrorCode = keyof typeof FIDES_ERROR_CODES + +export interface ErrorEnvelope { + code: FidesErrorCode + category: FidesErrorCategory + severity: FidesErrorSeverity + retryable: boolean + message: string + details?: Record +} + +export function createErrorEnvelope( + code: FidesErrorCode, + options: { + message?: string + details?: Record + } = {} +): ErrorEnvelope { + const definition = FIDES_ERROR_CODES[code] + const envelope: ErrorEnvelope = { + code, + category: definition.category, + severity: definition.severity, + retryable: definition.retryable, + message: options.message ?? definition.message, + } + if (options.details !== undefined) envelope.details = options.details + return envelope +} + +export function isErrorEnvelope(value: unknown): value is ErrorEnvelope { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return typeof candidate.code === 'string' && + candidate.code in FIDES_ERROR_CODES && + typeof candidate.category === 'string' && + typeof candidate.severity === 'string' && + typeof candidate.retryable === 'boolean' && + typeof candidate.message === 'string' +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ff94ddf..500efda 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,9 @@ */ export * from './identity.js' +export * from './protocol.js' +export * from './errors.js' +export * from './versioning.js' export * from './trust-anchor.js' export * from './domain-verifier.js' export * from './passkey.js' diff --git a/packages/core/src/protocol.ts b/packages/core/src/protocol.ts new file mode 100644 index 0000000..c07fedb --- /dev/null +++ b/packages/core/src/protocol.ts @@ -0,0 +1,86 @@ +import { bytesToHex } from '@noble/hashes/utils' +import { canonicalDigest } from './canonical-signer.js' + +export const FIDES_PROTOCOL_FAMILY = 'fides' as const +export const FIDES_PROTOCOL_VERSION = 'fides.v2.0' as const +export const FIDES_SUPPORTED_PROTOCOL_VERSIONS = [ + FIDES_PROTOCOL_VERSION, + 'fides.v2', +] as const + +export type FidesProtocolVersion = typeof FIDES_SUPPORTED_PROTOCOL_VERSIONS[number] + +export type ProtocolObjectId = string +export type FidesDid = `did:fides:${string}` +export type HashValue = `sha256:${string}` + +export interface ProtocolObjectBase { + schema_version: string + id: ProtocolObjectId + issuer: string + subject?: string + created_at?: string + issued_at?: string + expires_at?: string +} + +export interface SignedProtocolObjectBase extends ProtocolObjectBase { + payload_hash: HashValue + signature: string +} + +export interface SignedProtocolObject { + payload: TPayload + payload_hash: HashValue + signature: string +} + +export interface ProtocolSignatureInput { + schema_version: string + id: string + issuer: string + subject?: string + created_at?: string + issued_at?: string + expires_at?: string + payload_hash: HashValue +} + +export function hashProtocolPayload(payload: unknown): HashValue { + return `sha256:${bytesToHex(canonicalDigest(payload))}` +} + +export function createProtocolSignatureInput(payload: ProtocolObjectBase): ProtocolSignatureInput { + const input: ProtocolSignatureInput = { + schema_version: payload.schema_version, + id: payload.id, + issuer: payload.issuer, + payload_hash: hashProtocolPayload(payload), + } + + if (payload.subject !== undefined) input.subject = payload.subject + if (payload.created_at !== undefined) input.created_at = payload.created_at + if (payload.issued_at !== undefined) input.issued_at = payload.issued_at + if (payload.expires_at !== undefined) input.expires_at = payload.expires_at + + return input +} + +export function isProtocolObjectExpired( + object: Pick, + now: Date = new Date() +): boolean { + if (!object.expires_at) return false + const expiresAt = new Date(object.expires_at) + if (Number.isNaN(expiresAt.getTime())) return true + return expiresAt.getTime() <= now.getTime() +} + +export function assertProtocolObjectBase(value: ProtocolObjectBase): void { + if (!value.schema_version) throw new Error('Protocol object schema_version is required') + if (!value.id) throw new Error('Protocol object id is required') + if (!value.issuer) throw new Error('Protocol object issuer is required') + if (!value.created_at && !value.issued_at) { + throw new Error('Protocol object created_at or issued_at is required') + } +} diff --git a/packages/core/src/versioning.ts b/packages/core/src/versioning.ts new file mode 100644 index 0000000..33c36a3 --- /dev/null +++ b/packages/core/src/versioning.ts @@ -0,0 +1,80 @@ +import { + FIDES_PROTOCOL_VERSION, + FIDES_SUPPORTED_PROTOCOL_VERSIONS, + type FidesProtocolVersion, +} from './protocol.js' +import { createErrorEnvelope, type ErrorEnvelope } from './errors.js' + +export interface VersionNegotiationRecord { + schema_version: 'fides.version_negotiation.v1' + supported_versions: string[] + required_versions?: string[] + peer_supported_versions: string[] + peer_required_versions?: string[] + negotiated_version?: string + compatible: boolean + errors: ErrorEnvelope[] +} + +export interface VersionNegotiationInput { + localSupported?: readonly string[] + localRequired?: readonly string[] + peerSupported: readonly string[] + peerRequired?: readonly string[] +} + +export function negotiateProtocolVersion(input: VersionNegotiationInput): VersionNegotiationRecord { + const localSupported = [...(input.localSupported ?? FIDES_SUPPORTED_PROTOCOL_VERSIONS)] + const localRequired = input.localRequired ? [...input.localRequired] : undefined + const peerSupported = [...input.peerSupported] + const peerRequired = input.peerRequired ? [...input.peerRequired] : undefined + const common = localSupported.filter(version => peerSupported.includes(version)) + const negotiated = common[0] + const requiredCompatible = requiredVersionsCompatible(localSupported, peerRequired) && + requiredVersionsCompatible(peerSupported, localRequired) + const compatible = Boolean(negotiated) && requiredCompatible + const errors = compatible + ? [] + : [createErrorEnvelope('VERSION_INCOMPATIBLE', { + details: { + localSupported, + localRequired, + peerSupported, + peerRequired, + }, + })] + + const record: VersionNegotiationRecord = { + schema_version: 'fides.version_negotiation.v1', + supported_versions: localSupported, + peer_supported_versions: peerSupported, + compatible, + errors, + } + + if (localRequired !== undefined) record.required_versions = localRequired + if (peerRequired !== undefined) record.peer_required_versions = peerRequired + if (negotiated !== undefined && compatible) record.negotiated_version = negotiated + + return record +} + +export function isSupportedProtocolVersion(version: string): version is FidesProtocolVersion { + return (FIDES_SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(version) +} + +export function defaultVersionNegotiationRecord(peerSupported: readonly string[]): VersionNegotiationRecord { + return negotiateProtocolVersion({ + localSupported: FIDES_SUPPORTED_PROTOCOL_VERSIONS, + localRequired: [FIDES_PROTOCOL_VERSION], + peerSupported, + }) +} + +function requiredVersionsCompatible( + supported: string[], + required: string[] | undefined +): boolean { + if (!required || required.length === 0) return true + return required.every(version => supported.includes(version)) +} diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts new file mode 100644 index 0000000..aa53335 --- /dev/null +++ b/packages/core/test/errors.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { + createErrorEnvelope, + FIDES_ERROR_CODES, + isErrorEnvelope, +} from '../src/errors.js' + +describe('error envelopes', () => { + it('creates stable typed error envelopes', () => { + const envelope = createErrorEnvelope('POLICY_DENIED', { + details: { rule: 'revoked-agent-deny' }, + }) + + expect(envelope).toEqual({ + code: 'POLICY_DENIED', + category: 'policy', + severity: 'error', + retryable: false, + message: 'Policy denied the request', + details: { rule: 'revoked-agent-deny' }, + }) + }) + + it('allows caller messages without changing machine fields', () => { + const envelope = createErrorEnvelope('APPROVAL_REQUIRED', { + message: 'Human approval is required for payments.prepare', + }) + + expect(envelope.code).toBe('APPROVAL_REQUIRED') + expect(envelope.category).toBe(FIDES_ERROR_CODES.APPROVAL_REQUIRED.category) + expect(envelope.retryable).toBe(true) + expect(envelope.message).toBe('Human approval is required for payments.prepare') + }) + + it('detects error envelopes', () => { + expect(isErrorEnvelope(createErrorEnvelope('DHT_POINTER_TAMPERED'))).toBe(true) + expect(isErrorEnvelope({ code: 'NOPE' })).toBe(false) + expect(isErrorEnvelope(null)).toBe(false) + }) +}) diff --git a/packages/core/test/protocol.test.ts b/packages/core/test/protocol.test.ts new file mode 100644 index 0000000..43cb4af --- /dev/null +++ b/packages/core/test/protocol.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { + assertProtocolObjectBase, + createProtocolSignatureInput, + FIDES_PROTOCOL_VERSION, + hashProtocolPayload, + isProtocolObjectExpired, +} from '../src/protocol.js' + +describe('protocol object foundation', () => { + it('hashes protocol payloads with stable sha256 prefixes', () => { + const first = hashProtocolPayload({ b: 2, a: 1 }) + const second = hashProtocolPayload({ a: 1, b: 2 }) + + expect(first).toBe(second) + expect(first).toMatch(/^sha256:[0-9a-f]{64}$/) + }) + + it('creates signature input from required protocol fields', () => { + const payload = { + schema_version: 'fides.test.v1', + id: 'obj_123', + issuer: 'did:fides:issuer', + subject: 'did:fides:subject', + issued_at: '2026-05-29T00:00:00.000Z', + expires_at: '2026-05-30T00:00:00.000Z', + value: 'ignored by signature input except hash', + } + + const input = createProtocolSignatureInput(payload) + + expect(input).toMatchObject({ + schema_version: 'fides.test.v1', + id: 'obj_123', + issuer: 'did:fides:issuer', + subject: 'did:fides:subject', + issued_at: '2026-05-29T00:00:00.000Z', + expires_at: '2026-05-30T00:00:00.000Z', + }) + expect(input.payload_hash).toMatch(/^sha256:[0-9a-f]{64}$/) + }) + + it('treats invalid or elapsed expiration as expired', () => { + expect(isProtocolObjectExpired({ expires_at: 'not-a-date' })).toBe(true) + expect(isProtocolObjectExpired( + { expires_at: '2026-05-28T00:00:00.000Z' }, + new Date('2026-05-29T00:00:00.000Z') + )).toBe(true) + expect(isProtocolObjectExpired( + { expires_at: '2026-05-30T00:00:00.000Z' }, + new Date('2026-05-29T00:00:00.000Z') + )).toBe(false) + }) + + it('requires schema version, id, issuer, and time field', () => { + expect(() => assertProtocolObjectBase({ + schema_version: FIDES_PROTOCOL_VERSION, + id: 'obj_123', + issuer: 'did:fides:issuer', + issued_at: '2026-05-29T00:00:00.000Z', + })).not.toThrow() + + expect(() => assertProtocolObjectBase({ + schema_version: FIDES_PROTOCOL_VERSION, + id: 'obj_123', + issuer: 'did:fides:issuer', + })).toThrow('created_at or issued_at') + }) +}) diff --git a/packages/core/test/versioning.test.ts b/packages/core/test/versioning.test.ts new file mode 100644 index 0000000..8b33ebf --- /dev/null +++ b/packages/core/test/versioning.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + defaultVersionNegotiationRecord, + isSupportedProtocolVersion, + negotiateProtocolVersion, +} from '../src/versioning.js' +import { FIDES_PROTOCOL_VERSION } from '../src/protocol.js' + +describe('version negotiation', () => { + it('negotiates the first common supported version', () => { + const record = negotiateProtocolVersion({ + localSupported: ['fides.v2.0', 'fides.v2'], + peerSupported: ['fides.v2', 'fides.v1'], + }) + + expect(record.compatible).toBe(true) + expect(record.negotiated_version).toBe('fides.v2') + expect(record.errors).toEqual([]) + }) + + it('returns a typed compatibility error when no version matches', () => { + const record = negotiateProtocolVersion({ + localSupported: ['fides.v2.0'], + peerSupported: ['fides.v1'], + }) + + expect(record.compatible).toBe(false) + expect(record.negotiated_version).toBeUndefined() + expect(record.errors[0]).toMatchObject({ + code: 'VERSION_INCOMPATIBLE', + category: 'version', + }) + }) + + it('honors required versions from both sides', () => { + const record = negotiateProtocolVersion({ + localSupported: ['fides.v2.0'], + peerSupported: ['fides.v2.0'], + peerRequired: ['fides.v2.1'], + }) + + expect(record.compatible).toBe(false) + expect(record.errors[0].code).toBe('VERSION_INCOMPATIBLE') + }) + + it('provides a default FIDES negotiation record', () => { + const record = defaultVersionNegotiationRecord([FIDES_PROTOCOL_VERSION]) + + expect(record.compatible).toBe(true) + expect(record.required_versions).toEqual([FIDES_PROTOCOL_VERSION]) + expect(isSupportedProtocolVersion(record.negotiated_version!)).toBe(true) + }) +}) From b8c8452c1c20b7beb481fd4299af488d57ffc97d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:06:43 +0300 Subject: [PATCH 004/282] feat(identity): harden fides identity issuance --- packages/core/src/identity.ts | 178 +++++++++++++++++++++++++++- packages/core/test/identity.test.ts | 71 ++++++++++- 2 files changed, 245 insertions(+), 4 deletions(-) diff --git a/packages/core/src/identity.ts b/packages/core/src/identity.ts index 0a9971e..1910bcc 100644 --- a/packages/core/src/identity.ts +++ b/packages/core/src/identity.ts @@ -9,6 +9,39 @@ */ import { type SignedObject } from './canonical-signer.js' +import * as ed from '@noble/ed25519' +import bs58 from 'bs58' + +export type PublisherIdentityType = + | 'anonymous' + | 'self_signed' + | 'verified_individual' + | 'platform_hosted' + | 'domain_verified' + | 'organization_verified' + +export type IdentityVerificationMethod = + | 'none' + | 'self_signed' + | 'dns' + | 'github' + | 'email' + | 'manual' + | 'platform' + | 'organization_invitation' + +export type TrustAnchorType = + | 'domain' + | 'github' + | 'email' + | 'npm' + | 'pypi' + | 'wallet' + | 'passkey' + | 'organization_invitation' + | 'runtime_attestation' + | 'build_attestation' + | 'peer_attestation' export interface AgentIdentity { /** DID in the form did:fides: */ @@ -19,6 +52,8 @@ export interface AgentIdentity { keyType: 'Ed25519' /** ISO 8601 timestamp of identity creation */ createdAt: string + /** Trust anchors claimed or verified for this agent. */ + trustAnchors?: IdentityTrustAnchor[] /** The publisher that created this agent (optional) */ publisher?: PublisherIdentity /** The principal this agent acts for (optional) */ @@ -28,6 +63,8 @@ export interface AgentIdentity { export interface PublisherIdentity { /** DID of the publisher */ did: string + /** Publisher trust/verification class. */ + publisherType?: PublisherIdentityType /** Human-readable name */ name: string /** Verified domain (optional) */ @@ -35,7 +72,9 @@ export interface PublisherIdentity { /** Whether the publisher identity has been verified */ verified: boolean /** Method used to verify the publisher */ - verificationMethod: 'dns' | 'github' | 'email' | 'manual' + verificationMethod: IdentityVerificationMethod + /** Trust anchors used to support publisher claims. */ + trustAnchors?: IdentityTrustAnchor[] } export interface PrincipalIdentity { @@ -50,7 +89,9 @@ export interface PrincipalIdentity { /** Whether the principal identity has been verified */ verified?: boolean /** Method used to verify the principal */ - verificationMethod?: 'dns' | 'github' | 'email' | 'manual' + verificationMethod?: IdentityVerificationMethod + /** Trust anchors used to support principal claims. */ + trustAnchors?: IdentityTrustAnchor[] } export interface TrustAnchor { @@ -64,6 +105,45 @@ export interface TrustAnchor { attestation: SignedObject } +export interface IdentityTrustAnchor { + type: TrustAnchorType + value: string + verified: boolean + verifiedAt?: string + evidenceRef?: string +} + +export interface IssuedIdentity { + identity: TIdentity + privateKey: Uint8Array + publicKey: Uint8Array +} + +export interface CreateAgentIdentityInput { + publisher?: PublisherIdentity + principal?: PrincipalIdentity + trustAnchors?: IdentityTrustAnchor[] + createdAt?: string +} + +export interface CreatePublisherIdentityInput { + name: string + publisherType?: PublisherIdentityType + verificationMethod?: IdentityVerificationMethod + verified?: boolean + domain?: string + trustAnchors?: IdentityTrustAnchor[] +} + +export interface CreatePrincipalIdentityInput { + type: PrincipalIdentity['type'] + displayName: string + domain?: string + verificationMethod?: IdentityVerificationMethod + verified?: boolean + trustAnchors?: IdentityTrustAnchor[] +} + /** * Validates that a DID string conforms to the did:fides: format. */ @@ -71,11 +151,103 @@ export function isValidFidesDid(did: string): boolean { return did.startsWith('did:fides:') && did.length > 'did:fides:'.length } +export function didFromPublicKey(publicKey: Uint8Array): string { + if (publicKey.length !== 32) { + throw new Error('FIDES DID public key must be 32 bytes') + } + return `did:fides:${bs58.encode(publicKey)}` +} + +export function publicKeyFromDid(did: string): Uint8Array { + if (!isValidFidesDid(did)) { + throw new Error('Invalid FIDES DID') + } + const encoded = did.slice('did:fides:'.length) + const publicKey = bs58.decode(encoded) + if (publicKey.length !== 32) { + throw new Error('FIDES DID public key must decode to 32 bytes') + } + return publicKey +} + +export async function createIdentityKeyPair(): Promise<{ privateKey: Uint8Array; publicKey: Uint8Array; did: string }> { + const privateKey = ed.utils.randomPrivateKey() + const publicKey = await ed.getPublicKeyAsync(privateKey) + return { + privateKey, + publicKey, + did: didFromPublicKey(publicKey), + } +} + +export async function createAgentIdentity(input: CreateAgentIdentityInput = {}): Promise> { + const issued = await createIdentityKeyPair() + return { + ...issued, + identity: { + did: issued.did, + publicKey: issued.publicKey, + keyType: 'Ed25519', + createdAt: input.createdAt ?? new Date().toISOString(), + ...(input.trustAnchors !== undefined && { trustAnchors: input.trustAnchors }), + ...(input.publisher !== undefined && { publisher: input.publisher }), + ...(input.principal !== undefined && { principal: input.principal }), + }, + } +} + +export async function createPublisherIdentity(input: CreatePublisherIdentityInput): Promise> { + const issued = await createIdentityKeyPair() + const verificationMethod = input.verificationMethod ?? 'self_signed' + return { + ...issued, + identity: { + did: issued.did, + name: input.name, + publisherType: input.publisherType ?? 'self_signed', + verified: input.verified ?? verificationMethod !== 'none', + verificationMethod, + ...(input.domain !== undefined && { domain: input.domain }), + ...(input.trustAnchors !== undefined && { trustAnchors: input.trustAnchors }), + }, + } +} + +export async function createPrincipalIdentity(input: CreatePrincipalIdentityInput): Promise> { + const issued = await createIdentityKeyPair() + return { + ...issued, + identity: { + did: issued.did, + type: input.type, + displayName: input.displayName, + ...(input.domain !== undefined && { domain: input.domain }), + ...(input.verified !== undefined && { verified: input.verified }), + ...(input.verificationMethod !== undefined && { verificationMethod: input.verificationMethod }), + ...(input.trustAnchors !== undefined && { trustAnchors: input.trustAnchors }), + }, + } +} + +export function validateIdentityKeyBinding(identity: Pick): boolean { + try { + return bs58.encode(identity.publicKey) === identity.did.slice('did:fides:'.length) + } catch { + return false + } +} + /** * Creates an AgentIdentity with a random Ed25519 key pair. */ export function createIdentity(did: string, type: 'agent' | 'publisher' | 'principal' | 'trust-anchor', metadata: Record = {}): AgentIdentity & { metadata: Record } { - const publicKey = crypto.getRandomValues(new Uint8Array(32)) + let publicKey: Uint8Array + try { + publicKey = publicKeyFromDid(did) + } catch { + publicKey = crypto.getRandomValues(new Uint8Array(32)) + } + return { did, publicKey, diff --git a/packages/core/test/identity.test.ts b/packages/core/test/identity.test.ts index 10d0f7a..991b861 100644 --- a/packages/core/test/identity.test.ts +++ b/packages/core/test/identity.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect } from 'vitest' -import { isValidFidesDid, identityDisplayName } from '../src/identity.js' +import { + createAgentIdentity, + createIdentity, + createPrincipalIdentity, + createPublisherIdentity, + didFromPublicKey, + identityDisplayName, + isValidFidesDid, + publicKeyFromDid, + validateIdentityKeyBinding, +} from '../src/identity.js' import type { AgentIdentity, PrincipalIdentity, PublisherIdentity } from '../src/identity.js' describe('Identity v2', () => { @@ -14,6 +24,65 @@ describe('Identity v2', () => { }) }) + describe('cryptographic issuance', () => { + it('creates an agent identity whose DID is bound to the Ed25519 public key', async () => { + const issued = await createAgentIdentity({ + trustAnchors: [ + { type: 'github', value: 'EfeDurmaz16', verified: true, verifiedAt: '2026-05-29T00:00:00.000Z' }, + ], + }) + + expect(issued.privateKey).toBeInstanceOf(Uint8Array) + expect(issued.privateKey.length).toBe(32) + expect(issued.publicKey.length).toBe(32) + expect(issued.identity.did).toBe(didFromPublicKey(issued.publicKey)) + expect(validateIdentityKeyBinding(issued.identity)).toBe(true) + expect(issued.identity.trustAnchors?.[0]).toMatchObject({ type: 'github', verified: true }) + }) + + it('round-trips public keys through did:fides identifiers', async () => { + const issued = await createAgentIdentity() + + expect(publicKeyFromDid(issued.identity.did)).toEqual(issued.identity.publicKey) + }) + + it('creates publisher identities with explicit publisher type', async () => { + const issued = await createPublisherIdentity({ + name: 'Example Publisher', + publisherType: 'domain_verified', + verificationMethod: 'dns', + verified: true, + domain: 'example.com', + }) + + expect(issued.identity.publisherType).toBe('domain_verified') + expect(issued.identity.verificationMethod).toBe('dns') + expect(issued.identity.domain).toBe('example.com') + expect(isValidFidesDid(issued.identity.did)).toBe(true) + }) + + it('creates domainless principal identities', async () => { + const issued = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Alice', + verificationMethod: 'self_signed', + }) + + expect(issued.identity.displayName).toBe('Alice') + expect(issued.identity.domain).toBeUndefined() + expect(issued.identity.verificationMethod).toBe('self_signed') + expect(isValidFidesDid(issued.identity.did)).toBe(true) + }) + + it('keeps deprecated createIdentity compatible while decoding real did keys', async () => { + const issued = await createAgentIdentity() + const identity = createIdentity(issued.identity.did, 'agent') + + expect(identity.publicKey).toEqual(issued.identity.publicKey) + expect(validateIdentityKeyBinding(identity)).toBe(true) + }) + }) + describe('identityDisplayName', () => { it('should return displayName for principal', () => { const principal: PrincipalIdentity = { From bc3a836ae656ee43cd205a754edd5388b1e2d67b Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:08:38 +0300 Subject: [PATCH 005/282] feat(cards): add signed agent cards and capability ontology --- packages/core/src/agent-card.ts | 59 ++++++- packages/core/src/capability.ts | 207 +++++++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/runtime-attestation.ts | 13 ++ packages/core/test/agent-card.test.ts | 58 ++++++- packages/core/test/capability.test.ts | 64 ++++++- 6 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/runtime-attestation.ts diff --git a/packages/core/src/agent-card.ts b/packages/core/src/agent-card.ts index 1961d1a..6ed112e 100644 --- a/packages/core/src/agent-card.ts +++ b/packages/core/src/agent-card.ts @@ -7,8 +7,10 @@ */ import type { AgentIdentity, PublisherIdentity } from './identity.js' -import type { SignedObject } from './canonical-signer.js' +import { signObject, verifyObject, type SignedObject } from './canonical-signer.js' import type { CapabilityDescriptor } from './capability.js' +import type { RuntimeAttestation } from './runtime-attestation.js' +import type { IdentityTrustAnchor } from './identity.js' export interface EndpointDescriptor { /** Endpoint URL */ @@ -33,8 +35,12 @@ export interface PolicyRequirement { } export interface AgentCard { + /** Schema version for v2 AgentCards. */ + schema_version?: 'fides.agent_card.v1' /** Unique identifier (typically the agent's DID) */ id: string + /** Stable agent DID, repeated for compatibility with external card formats. */ + agent_id?: string /** Agent identity */ identity: AgentIdentity /** Publisher identity (optional) */ @@ -45,6 +51,20 @@ export interface AgentCard { endpoints: EndpointDescriptor[] /** Policy requirements for invokers */ policies: PolicyRequirement[] + /** Public keys advertised for verification and invocation. */ + publicKeys?: Array<{ id: string; type: 'Ed25519'; publicKey: string }> + /** Trust anchors claimed or verified for this card. */ + trustAnchors?: IdentityTrustAnchor[] + /** Runtime attestations bound to this card. */ + runtimeAttestations?: RuntimeAttestation[] + /** Supported protocol versions. */ + protocolVersions?: string[] + /** Revocation URL for this card or agent. */ + revocationUrl?: string + /** Revocation record reference when available. */ + revocationRef?: string + /** ISO 8601 expiry timestamp. */ + expiresAt?: string /** ISO 8601 creation timestamp */ createdAt: string /** ISO 8601 last update timestamp */ @@ -54,6 +74,23 @@ export interface AgentCard { /** A signed AgentCard — the canonical form used in discovery and verification */ export type SignedAgentCard = SignedObject +export async function signAgentCard( + card: AgentCard, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(normalizeAgentCard(card), privateKey, { + verificationMethod, + proofPurpose: 'assertionMethod', + }) +} + +export async function verifySignedAgentCard(card: SignedAgentCard): Promise { + const validation = validateAgentCard(card.payload) + if (!validation.valid) return false + return verifyObject(card) +} + /** * Validate that an AgentCard has all required fields and sensible values. */ @@ -70,6 +107,26 @@ export function validateAgentCard(card: AgentCard): { valid: boolean; errors: st if (!Array.isArray(card.policies)) errors.push('AgentCard.policies must be an array') if (!card.createdAt) errors.push('AgentCard.createdAt is required') if (!card.updatedAt) errors.push('AgentCard.updatedAt is required') + if (card.agent_id && card.agent_id !== card.identity.did) { + errors.push('AgentCard.agent_id must match AgentCard.identity.did') + } + if (card.expiresAt && new Date(card.expiresAt).getTime() <= Date.now()) { + errors.push('AgentCard.expiresAt must be in the future') + } + for (const capability of card.capabilities ?? []) { + if (!capability.id) errors.push('CapabilityDescriptor.id is required') + if (capability.namespace && capability.id.split('.')[0] !== capability.namespace) { + errors.push(`CapabilityDescriptor ${capability.id} namespace does not match id`) + } + } return { valid: errors.length === 0, errors } } + +export function normalizeAgentCard(card: AgentCard): AgentCard { + return { + ...card, + schema_version: card.schema_version ?? 'fides.agent_card.v1', + agent_id: card.agent_id ?? card.identity.did, + } +} diff --git a/packages/core/src/capability.ts b/packages/core/src/capability.ts index dd41d96..5b70367 100644 --- a/packages/core/src/capability.ts +++ b/packages/core/src/capability.ts @@ -16,6 +16,12 @@ export interface JSONSchema { export interface CapabilityDescriptor { /** Unique capability ID (URL-friendly) */ id: string + /** Namespace such as calendar, invoice, payments, code, file, deploy. */ + namespace?: string + /** Action verb such as schedule, reconcile, prepare, execute, read, write. */ + action?: string + /** Resource class this capability acts on. */ + resource?: string /** Human-readable name */ name: string /** Human-readable description */ @@ -30,6 +36,36 @@ export interface CapabilityDescriptor { requiresApproval: boolean /** Whether invocation requires runtime attestation (e.g., TEE) */ requiresRuntimeAttestation: boolean + /** Required scopes for delegated sessions. */ + requiredScopes?: string[] + /** Controls supported by this capability. */ + supportedControls?: CapabilityControl[] + /** Whether this capability supports dry-run. */ + supportsDryRun?: boolean + /** Whether this capability supports explicit human approval. */ + supportsHumanApproval?: boolean + /** Whether this capability can produce policy proof/evidence. */ + supportsPolicyProof?: boolean +} + +export type CapabilityControl = + | 'dry_run' + | 'human_approval' + | 'policy_proof' + | 'runtime_attestation' + | 'scope_limit' + | 'rate_limit' + +export interface CapabilityOntologyEntry { + schema_version: 'fides.capability_ontology_entry.v1' + id: string + namespace: string + action: string + resource: string + riskClass: CapabilityDescriptor['riskLevel'] + description: string + defaultRequiredScopes: string[] + supportedControls: CapabilityControl[] } /** @@ -48,3 +84,174 @@ export function classifyCapabilityRisk(name: string): CapabilityDescriptor['risk if (mediumKeywords.some(k => lower.includes(k))) return 'medium' return 'low' } + +export function parseCapabilityId(id: string): Pick { + const [namespace, action, ...resourceParts] = id.split('.') + return { + ...(namespace && { namespace }), + ...(action && { action }), + ...(resourceParts.length > 0 && { resource: resourceParts.join('.') }), + } +} + +export function createCapabilityDescriptor(input: { + id: string + name?: string + description?: string + inputSchema?: JSONSchema + outputSchema?: JSONSchema + riskLevel?: CapabilityDescriptor['riskLevel'] + requiredScopes?: string[] + supportedControls?: CapabilityControl[] + supportsDryRun?: boolean + supportsHumanApproval?: boolean + supportsPolicyProof?: boolean +}): CapabilityDescriptor { + const parsed = parseCapabilityId(input.id) + const supportedControls = input.supportedControls ?? [ + ...(input.supportsDryRun ? ['dry_run' as const] : []), + ...(input.supportsHumanApproval ? ['human_approval' as const] : []), + ...(input.supportsPolicyProof ? ['policy_proof' as const] : []), + ] + + return { + id: input.id, + ...parsed, + name: input.name ?? input.id, + description: input.description ?? input.id, + inputSchema: input.inputSchema ?? { type: 'object' }, + outputSchema: input.outputSchema ?? { type: 'object' }, + riskLevel: input.riskLevel ?? classifyCapabilityRisk(input.id), + requiresApproval: input.supportsHumanApproval ?? supportedControls.includes('human_approval'), + requiresRuntimeAttestation: supportedControls.includes('runtime_attestation'), + requiredScopes: input.requiredScopes ?? [], + supportedControls, + supportsDryRun: input.supportsDryRun ?? supportedControls.includes('dry_run'), + supportsHumanApproval: input.supportsHumanApproval ?? supportedControls.includes('human_approval'), + supportsPolicyProof: input.supportsPolicyProof ?? supportedControls.includes('policy_proof'), + } +} + +export const DEFAULT_CAPABILITY_ONTOLOGY: CapabilityOntologyEntry[] = [ + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'calendar.schedule', + namespace: 'calendar', + action: 'schedule', + resource: 'event', + riskClass: 'low', + description: 'Schedule or update calendar events.', + defaultRequiredScopes: ['calendar:write'], + supportedControls: ['dry_run', 'human_approval'], + }, + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'invoice.reconcile', + namespace: 'invoice', + action: 'reconcile', + resource: 'invoice', + riskClass: 'medium', + description: 'Reconcile invoice records against supporting data.', + defaultRequiredScopes: ['invoice:read', 'invoice:write'], + supportedControls: ['dry_run', 'policy_proof'], + }, + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'payments.prepare', + namespace: 'payments', + action: 'prepare', + resource: 'payment', + riskClass: 'high', + description: 'Prepare a payment plan without executing funds movement.', + defaultRequiredScopes: ['payments:prepare'], + supportedControls: ['dry_run', 'human_approval', 'policy_proof', 'runtime_attestation'], + }, + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'payments.execute', + namespace: 'payments', + action: 'execute', + resource: 'payment', + riskClass: 'critical', + description: 'Execute payment movement. Generic FIDES should route this to Sardis-specific authority.', + defaultRequiredScopes: ['payments:execute'], + supportedControls: ['human_approval', 'policy_proof', 'runtime_attestation'], + }, + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'code.review', + namespace: 'code', + action: 'review', + resource: 'change', + riskClass: 'medium', + description: 'Review code changes and produce findings.', + defaultRequiredScopes: ['code:read'], + supportedControls: ['policy_proof'], + }, + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'code.merge', + namespace: 'code', + action: 'merge', + resource: 'change', + riskClass: 'high', + description: 'Merge code changes into a protected branch.', + defaultRequiredScopes: ['code:write'], + supportedControls: ['human_approval', 'policy_proof'], + }, + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'file.read', + namespace: 'file', + action: 'read', + resource: 'file', + riskClass: 'medium', + description: 'Read local or remote file contents.', + defaultRequiredScopes: ['file:read'], + supportedControls: ['scope_limit'], + }, + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'file.write', + namespace: 'file', + action: 'write', + resource: 'file', + riskClass: 'high', + description: 'Write or update file contents.', + defaultRequiredScopes: ['file:write'], + supportedControls: ['dry_run', 'human_approval', 'scope_limit'], + }, + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'file.delete', + namespace: 'file', + action: 'delete', + resource: 'file', + riskClass: 'critical', + description: 'Delete file contents.', + defaultRequiredScopes: ['file:delete'], + supportedControls: ['human_approval', 'scope_limit'], + }, + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'deploy.preview', + namespace: 'deploy', + action: 'preview', + resource: 'deployment', + riskClass: 'medium', + description: 'Create a preview deployment.', + defaultRequiredScopes: ['deploy:preview'], + supportedControls: ['policy_proof'], + }, + { + schema_version: 'fides.capability_ontology_entry.v1', + id: 'deploy.production', + namespace: 'deploy', + action: 'production', + resource: 'deployment', + riskClass: 'critical', + description: 'Deploy to production.', + defaultRequiredScopes: ['deploy:production'], + supportedControls: ['human_approval', 'policy_proof', 'runtime_attestation'], + }, +] diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 500efda..c62ba56 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,7 @@ export * from './passkey.js' export * from './canonical-signer.js' export * from './agent-card.js' export * from './capability.js' +export * from './runtime-attestation.js' export * from './delegation.js' export * from './session-store.js' export * from './revocation.js' diff --git a/packages/core/src/runtime-attestation.ts b/packages/core/src/runtime-attestation.ts new file mode 100644 index 0000000..8c342aa --- /dev/null +++ b/packages/core/src/runtime-attestation.ts @@ -0,0 +1,13 @@ +export interface RuntimeAttestation { + schema_version?: 'fides.runtime_attestation.v1' + attestation_id: string + agent_id: string + provider: 'mock-tee' | 'null' | 'aws-nitro' | 'intel-sgx' | 'amd-sev' | 'container-image' | 'reproducible-build' | string + code_hash: string + runtime_hash: string + policy_hash: string + enclave_measurement?: string + issued_at: string + expires_at: string + signature: string +} diff --git a/packages/core/test/agent-card.test.ts b/packages/core/test/agent-card.test.ts index 6b0c320..565ca93 100644 --- a/packages/core/test/agent-card.test.ts +++ b/packages/core/test/agent-card.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' -import { validateAgentCard } from '../src/agent-card.js' +import { normalizeAgentCard, signAgentCard, validateAgentCard, verifySignedAgentCard } from '../src/agent-card.js' import type { AgentCard } from '../src/agent-card.js' +import { createAgentIdentity } from '../src/identity.js' describe('AgentCard', () => { const validCard: AgentCard = { @@ -52,5 +53,60 @@ describe('AgentCard', () => { expect(result.valid).toBe(false) expect(result.errors).toContain('AgentCard.capabilities must be an array') }) + + it('should reject mismatched agent_id', () => { + const result = validateAgentCard({ + ...validCard, + agent_id: 'did:fides:other', + }) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('AgentCard.agent_id must match AgentCard.identity.did') + }) + + it('should normalize v2 schema and agent id fields', () => { + expect(normalizeAgentCard(validCard)).toMatchObject({ + schema_version: 'fides.agent_card.v1', + agent_id: validCard.identity.did, + }) + }) + + it('should sign and verify AgentCards with the canonical signing model', async () => { + const issued = await createAgentIdentity() + const card: AgentCard = { + ...validCard, + id: issued.identity.did, + identity: issued.identity, + capabilities: [ + { + id: 'calendar.schedule', + namespace: 'calendar', + action: 'schedule', + resource: 'event', + name: 'Schedule calendar event', + description: 'Schedule a calendar event', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object' }, + riskLevel: 'low', + requiresApproval: false, + requiresRuntimeAttestation: false, + requiredScopes: ['calendar:write'], + supportedControls: ['dry_run'], + supportsDryRun: true, + }, + ], + protocolVersions: ['fides.v2.0'], + expiresAt: '2999-01-01T00:00:00.000Z', + } + + const signed = await signAgentCard(card, issued.privateKey, issued.identity.did) + + expect(signed.payload.schema_version).toBe('fides.agent_card.v1') + expect(signed.payload.agent_id).toBe(issued.identity.did) + expect(await verifySignedAgentCard(signed)).toBe(true) + + signed.payload.capabilities[0].name = 'Tampered' + expect(await verifySignedAgentCard(signed)).toBe(false) + }) }) }) diff --git a/packages/core/test/capability.test.ts b/packages/core/test/capability.test.ts index 3c63ae0..f220a8d 100644 --- a/packages/core/test/capability.test.ts +++ b/packages/core/test/capability.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { classifyCapabilityRisk } from '../src/capability.js' +import { + DEFAULT_CAPABILITY_ONTOLOGY, + classifyCapabilityRisk, + createCapabilityDescriptor, + parseCapabilityId, +} from '../src/capability.js' describe('CapabilityDescriptor', () => { describe('classifyCapabilityRisk', () => { @@ -23,4 +28,61 @@ describe('CapabilityDescriptor', () => { expect(classifyCapabilityRisk('healthCheck')).toBe('low') }) }) + + describe('capability ontology', () => { + it('parses namespace and action from capability ids', () => { + expect(parseCapabilityId('invoice.reconcile')).toEqual({ + namespace: 'invoice', + action: 'reconcile', + }) + expect(parseCapabilityId('deploy.production.web')).toEqual({ + namespace: 'deploy', + action: 'production', + resource: 'web', + }) + }) + + it('creates v2 capability descriptors with controls and scopes', () => { + const capability = createCapabilityDescriptor({ + id: 'payments.prepare', + requiredScopes: ['payments:prepare'], + supportedControls: ['dry_run', 'human_approval', 'policy_proof', 'runtime_attestation'], + }) + + expect(capability).toMatchObject({ + id: 'payments.prepare', + namespace: 'payments', + action: 'prepare', + riskLevel: 'critical', + requiredScopes: ['payments:prepare'], + requiresApproval: true, + requiresRuntimeAttestation: true, + supportsDryRun: true, + supportsHumanApproval: true, + supportsPolicyProof: true, + }) + }) + + it('ships the requested seed ontology entries', () => { + const ids = DEFAULT_CAPABILITY_ONTOLOGY.map(entry => entry.id) + + expect(ids).toEqual(expect.arrayContaining([ + 'calendar.schedule', + 'invoice.reconcile', + 'payments.prepare', + 'payments.execute', + 'code.review', + 'code.merge', + 'file.read', + 'file.write', + 'file.delete', + 'deploy.preview', + 'deploy.production', + ])) + expect(DEFAULT_CAPABILITY_ONTOLOGY.find(entry => entry.id === 'payments.execute')).toMatchObject({ + riskClass: 'critical', + supportedControls: expect.arrayContaining(['human_approval', 'runtime_attestation']), + }) + }) + }) }) From 4e06d67f64c15e647dccce25497687520cc0a109 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:09:57 +0300 Subject: [PATCH 006/282] feat(evidence): add signed privacy-aware evidence events --- packages/evidence/src/index.ts | 172 +++++++++++++++++++++++- packages/evidence/test/evidence.test.ts | 69 +++++++++- 2 files changed, 239 insertions(+), 2 deletions(-) diff --git a/packages/evidence/src/index.ts b/packages/evidence/src/index.ts index d85e6df..4e79dde 100644 --- a/packages/evidence/src/index.ts +++ b/packages/evidence/src/index.ts @@ -6,7 +6,7 @@ import { sha256 } from '@noble/hashes/sha256' import { bytesToHex } from '@noble/hashes/utils' -import { canonicalJson } from '@fides/core' +import { canonicalJson, signObject, verifyObject } from '@fides/core' export interface EvidencePrivacy { level: 'public' | 'private' | 'redacted' | 'hash-only' @@ -32,11 +32,175 @@ export interface EvidenceChain { merkleRoot?: string } +export type EvidenceEventType = + | 'agent.registered' + | 'agent.updated' + | 'agent.revoked' + | 'discovery.performed' + | 'trust.computed' + | 'policy.evaluated' + | 'approval.requested' + | 'approval.granted' + | 'approval.denied' + | 'session.requested' + | 'session.granted' + | 'session.denied' + | 'capability.invoked' + | 'capability.completed' + | 'capability.failed' + | 'attestation.issued' + | 'attestation.verified' + | 'attestation.failed' + | 'revocation.recorded' + | 'incident.reported' + | 'kill_switch.triggered' + +export type EvidencePrivacyMode = 'public' | 'private' | 'redacted' | 'hash_only' + +export interface EvidenceEventV2 { + schema_version: 'fides.evidence_event.v1' + event_id: string + type: EvidenceEventType + actor: string + subject?: string + principal?: string + capability?: string + input_hash?: string + output_hash?: string + policy_hash?: string + decision?: string + risk_level?: 'low' | 'medium' | 'high' | 'critical' + privacy_mode: EvidencePrivacyMode + timestamp: string + prev_event_hash: string + event_hash: string + signature: string + metadata?: Record +} + +export interface EvidenceEventV2Input { + event_id?: string + type: EvidenceEventType + actor: string + subject?: string + principal?: string + capability?: string + input?: unknown + output?: unknown + input_hash?: string + output_hash?: string + policy?: unknown + policy_hash?: string + decision?: string + risk_level?: EvidenceEventV2['risk_level'] + privacy_mode?: EvidencePrivacyMode + timestamp?: string + metadata?: Record +} + function hashEvent(event: Omit): string { const canonical = canonicalJson(event) return bytesToHex(sha256(new TextEncoder().encode(canonical))) } +export function hashEvidenceValue(value: unknown): string { + return `sha256:${bytesToHex(sha256(new TextEncoder().encode(canonicalJson(value))))}` +} + +export function createEvidenceEventV2( + input: EvidenceEventV2Input, + previousEventHash = '0' +): EvidenceEventV2 { + const eventWithoutHash: Omit = { + schema_version: 'fides.evidence_event.v1', + event_id: input.event_id ?? crypto.randomUUID(), + type: input.type, + actor: input.actor, + privacy_mode: input.privacy_mode ?? defaultPrivacyMode(input), + timestamp: input.timestamp ?? new Date().toISOString(), + prev_event_hash: previousEventHash, + ...(input.subject !== undefined && { subject: input.subject }), + ...(input.principal !== undefined && { principal: input.principal }), + ...(input.capability !== undefined && { capability: input.capability }), + ...(input.input_hash !== undefined || input.input !== undefined + ? { input_hash: input.input_hash ?? hashEvidenceValue(input.input) } + : {}), + ...(input.output_hash !== undefined || input.output !== undefined + ? { output_hash: input.output_hash ?? hashEvidenceValue(input.output) } + : {}), + ...(input.policy_hash !== undefined || input.policy !== undefined + ? { policy_hash: input.policy_hash ?? hashEvidenceValue(input.policy) } + : {}), + ...(input.decision !== undefined && { decision: input.decision }), + ...(input.risk_level !== undefined && { risk_level: input.risk_level }), + ...(input.metadata !== undefined && { metadata: input.metadata }), + } + const event_hash = hashEvidenceValue(eventWithoutHash) + return { + ...eventWithoutHash, + event_hash, + signature: '', + } +} + +export async function signEvidenceEventV2( + event: EvidenceEventV2, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + const unsigned = { ...event, signature: '' } + const signed = await signObject(unsigned, privateKey, { + verificationMethod, + proofPurpose: 'assertionMethod', + }) + return { + ...event, + signature: signed.proof.proofValue, + } +} + +export async function verifyEvidenceEventV2( + event: EvidenceEventV2, + verificationMethod = event.actor +): Promise { + if (!event.signature) return false + const { event_hash, signature, ...withoutHashAndSignature } = event + if (hashEvidenceValue(withoutHashAndSignature) !== event_hash) return false + return verifyObject({ + payload: { ...event, signature: '' }, + proof: { + type: 'Ed25519Signature2024', + created: event.timestamp, + verificationMethod, + proofPurpose: 'assertionMethod', + canonicalizationAlgorithm: 'https://fides.dev/canonical-json/v1', + proofValue: signature, + }, + }) +} + +export function appendEvidenceEventV2( + events: EvidenceEventV2[], + event: EvidenceEventV2 +): EvidenceEventV2[] { + const expectedPrevious = events.length > 0 ? events[events.length - 1].event_hash : '0' + if (event.prev_event_hash !== expectedPrevious) { + throw new Error('EvidenceEvent.prev_event_hash does not match chain head') + } + return [...events, event] +} + +export function verifyEvidenceEventsV2(events: EvidenceEventV2[]): boolean { + for (let index = 0; index < events.length; index++) { + const event = events[index] + const expectedPrevious = index === 0 ? '0' : events[index - 1].event_hash + if (event.prev_event_hash !== expectedPrevious) return false + const { event_hash, signature: _signature, ...withoutHashAndSignature } = event + if (hashEvidenceValue(withoutHashAndSignature) !== event_hash) return false + } + return true +} + /** * Build a Merkle tree from event hashes and return the root. */ @@ -122,3 +286,9 @@ export function redactEvent(event: EvidenceEvent, level?: EvidencePrivacy['level return event } } + +function defaultPrivacyMode(input: EvidenceEventV2Input): EvidencePrivacyMode { + if (input.privacy_mode) return input.privacy_mode + if (input.input !== undefined || input.output !== undefined) return 'hash_only' + return 'redacted' +} diff --git a/packages/evidence/test/evidence.test.ts b/packages/evidence/test/evidence.test.ts index f1c328b..2765624 100644 --- a/packages/evidence/test/evidence.test.ts +++ b/packages/evidence/test/evidence.test.ts @@ -1,6 +1,18 @@ import { describe, it, expect } from 'vitest' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, redactEvent } from '../src/index.js' +import { + appendEvidenceEvent, + appendEvidenceEventV2, + createEvidenceChain, + createEvidenceEventV2, + hashEvidenceValue, + redactEvent, + signEvidenceEventV2, + verifyEvidenceChain, + verifyEvidenceEventV2, + verifyEvidenceEventsV2, +} from '../src/index.js' import type { EvidenceEvent } from '../src/index.js' +import { createAgentIdentity } from '@fides/core' describe('Evidence Ledger', () => { it('should create an empty chain', () => { @@ -71,4 +83,59 @@ describe('Evidence Ledger', () => { expect(redactEvent(event, 'redacted').payload).toBe('[REDACTED]') expect(redactEvent(event, 'hash-only').payload).toBeNull() }) + + it('creates privacy-aware v2 events with hashes instead of raw payloads', () => { + const event = createEvidenceEventV2({ + type: 'capability.invoked', + actor: 'did:fides:agent', + principal: 'did:fides:principal', + capability: 'invoice.reconcile', + input: { invoiceId: 'inv_123', secret: 'hidden' }, + policy: { id: 'default' }, + decision: 'allow', + risk_level: 'medium', + }) + + expect(event.schema_version).toBe('fides.evidence_event.v1') + expect(event.privacy_mode).toBe('hash_only') + expect(event.input_hash).toBe(hashEvidenceValue({ invoiceId: 'inv_123', secret: 'hidden' })) + expect(event.policy_hash).toMatch(/^sha256:/) + expect(JSON.stringify(event)).not.toContain('hidden') + }) + + it('signs and verifies v2 events', async () => { + const issued = await createAgentIdentity() + const event = createEvidenceEventV2({ + type: 'trust.computed', + actor: issued.identity.did, + subject: 'did:fides:target', + metadata: { score: 0.8 }, + timestamp: '2026-05-29T00:00:00.000Z', + }) + + const signed = await signEvidenceEventV2(event, issued.privateKey, issued.identity.did) + + expect(signed.signature).not.toBe('') + expect(await verifyEvidenceEventV2(signed)).toBe(true) + + expect(await verifyEvidenceEventV2({ ...signed, metadata: { score: 0.1 } })).toBe(false) + }) + + it('verifies v2 hash chains and detects broken links', () => { + const first = createEvidenceEventV2({ + type: 'session.requested', + actor: 'did:fides:requester', + timestamp: '2026-05-29T00:00:00.000Z', + }) + const second = createEvidenceEventV2({ + type: 'session.granted', + actor: 'did:fides:target', + timestamp: '2026-05-29T00:00:01.000Z', + }, first.event_hash) + + const events = appendEvidenceEventV2(appendEvidenceEventV2([], first), second) + + expect(verifyEvidenceEventsV2(events)).toBe(true) + expect(verifyEvidenceEventsV2([{ ...second, prev_event_hash: 'wrong' }])).toBe(false) + }) }) From 2501ff2076ed9a55be58d6b9f55a5b4146535487 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:11:34 +0300 Subject: [PATCH 007/282] feat(discovery): add capability query provider contract --- packages/core/src/discovery.ts | 69 +++++++++++++++++++ packages/core/src/index.ts | 1 + packages/discovery/src/local-provider.ts | 27 +++++++- packages/discovery/src/orchestrator.ts | 42 +++++++++++- packages/discovery/src/provider.ts | 3 +- packages/discovery/test/orchestrator.test.ts | 71 +++++++++++++++++++- 6 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/discovery.ts diff --git a/packages/core/src/discovery.ts b/packages/core/src/discovery.ts new file mode 100644 index 0000000..3ac05f1 --- /dev/null +++ b/packages/core/src/discovery.ts @@ -0,0 +1,69 @@ +import type { AgentCard } from './agent-card.js' +import type { ErrorEnvelope } from './errors.js' + +export interface DiscoveryQuery { + schema_version: 'fides.discovery_query.v1' + id: string + capability?: string + constraints?: Record + principal_id?: string + requester_agent_id?: string + supported_versions?: string[] + required_versions?: string[] + providers?: string[] + limit?: number +} + +export interface DiscoveryCandidate { + schema_version: 'fides.discovery_candidate.v1' + provider: string + agentId: string + card: AgentCard + capability?: string + verified: boolean + rank: number + explanations: string[] + errors: ErrorEnvelope[] +} + +export function createDiscoveryQuery(input: Omit & { id?: string }): DiscoveryQuery { + return { + schema_version: 'fides.discovery_query.v1', + id: input.id ?? crypto.randomUUID(), + ...(input.capability !== undefined && { capability: input.capability }), + ...(input.constraints !== undefined && { constraints: input.constraints }), + ...(input.principal_id !== undefined && { principal_id: input.principal_id }), + ...(input.requester_agent_id !== undefined && { requester_agent_id: input.requester_agent_id }), + ...(input.supported_versions !== undefined && { supported_versions: input.supported_versions }), + ...(input.required_versions !== undefined && { required_versions: input.required_versions }), + ...(input.providers !== undefined && { providers: input.providers }), + ...(input.limit !== undefined && { limit: input.limit }), + } +} + +export function cardSupportsCapability(card: AgentCard, capability?: string): boolean { + if (!capability) return true + return card.capabilities.some(candidate => candidate.id === capability) +} + +export function createDiscoveryCandidate(input: { + provider: string + card: AgentCard + capability?: string + verified?: boolean + rank?: number + explanations?: string[] + errors?: ErrorEnvelope[] +}): DiscoveryCandidate { + return { + schema_version: 'fides.discovery_candidate.v1', + provider: input.provider, + agentId: input.card.agent_id ?? input.card.identity.did, + card: input.card, + ...(input.capability !== undefined && { capability: input.capability }), + verified: input.verified ?? false, + rank: input.rank ?? 0, + explanations: input.explanations ?? [], + errors: input.errors ?? [], + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c62ba56..8a6fa5e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,6 +22,7 @@ export * from './canonical-signer.js' export * from './agent-card.js' export * from './capability.js' export * from './runtime-attestation.js' +export * from './discovery.js' export * from './delegation.js' export * from './session-store.js' export * from './revocation.js' diff --git a/packages/discovery/src/local-provider.ts b/packages/discovery/src/local-provider.ts index 03d6bf3..9b48b77 100644 --- a/packages/discovery/src/local-provider.ts +++ b/packages/discovery/src/local-provider.ts @@ -1,4 +1,11 @@ -import type { AgentCard, SignedAgentCard } from '@fides/core' +import { + cardSupportsCapability, + createDiscoveryCandidate, + type AgentCard, + type DiscoveryCandidate, + type DiscoveryQuery, + type SignedAgentCard, +} from '@fides/core' import { DiscoveryProvider } from './provider.js' import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs' import { join } from 'node:path' @@ -25,6 +32,24 @@ export class LocalDiscoveryProvider implements DiscoveryProvider { return store.get(did) || null } + async discover(query: DiscoveryQuery): Promise { + return this.list() + .filter(card => cardSupportsCapability(card, query.capability)) + .map((card, index) => createDiscoveryCandidate({ + provider: this.name, + card, + capability: query.capability, + verified: false, + rank: 100 - index, + explanations: [ + query.capability + ? `Local AgentCard advertises ${query.capability}` + : 'Local AgentCard matched discovery query', + ], + })) + .slice(0, query.limit ?? Number.POSITIVE_INFINITY) + } + async register(card: SignedAgentCard): Promise { const store = this.loadStore() const did = card.payload.id diff --git a/packages/discovery/src/orchestrator.ts b/packages/discovery/src/orchestrator.ts index 37fe13f..cc57f15 100644 --- a/packages/discovery/src/orchestrator.ts +++ b/packages/discovery/src/orchestrator.ts @@ -1,4 +1,10 @@ -import type { AgentCard } from '@fides/core' +import { + cardSupportsCapability, + createDiscoveryCandidate, + type AgentCard, + type DiscoveryCandidate, + type DiscoveryQuery, +} from '@fides/core' import { DiscoveryProvider } from './provider.js' /** @@ -8,6 +14,40 @@ import { DiscoveryProvider } from './provider.js' export class DiscoveryOrchestrator { constructor(private providers: DiscoveryProvider[]) {} + async discover(query: DiscoveryQuery): Promise { + const candidates: DiscoveryCandidate[] = [] + + for (const provider of this.providers) { + if (query.providers && !query.providers.includes(provider.name)) continue + + try { + if (provider.discover) { + candidates.push(...await provider.discover(query)) + continue + } + + if (query.requester_agent_id) { + const card = await provider.resolve(query.requester_agent_id) + if (card && cardSupportsCapability(card, query.capability)) { + candidates.push(createDiscoveryCandidate({ + provider: provider.name, + card, + capability: query.capability, + verified: false, + explanations: ['Resolved through legacy DID provider path'], + })) + } + } + } catch (error) { + console.warn(`Discovery provider ${provider.name} failed for query ${query.id}:`, error) + } + } + + return candidates + .sort((a, b) => b.rank - a.rank || a.provider.localeCompare(b.provider)) + .slice(0, query.limit ?? candidates.length) + } + async resolve(did: string): Promise { for (const provider of this.providers) { try { diff --git a/packages/discovery/src/provider.ts b/packages/discovery/src/provider.ts index 9e27b75..8a56466 100644 --- a/packages/discovery/src/provider.ts +++ b/packages/discovery/src/provider.ts @@ -1,10 +1,11 @@ -import type { AgentCard, SignedAgentCard } from '@fides/core' +import type { AgentCard, DiscoveryCandidate, DiscoveryQuery, SignedAgentCard } from '@fides/core' /** * DiscoveryProvider is the interface that all discovery mechanisms implement. */ export interface DiscoveryProvider { readonly name: string + discover?(query: DiscoveryQuery): Promise resolve(did: string): Promise register?(card: SignedAgentCard): Promise deregister?(did: string): Promise diff --git a/packages/discovery/test/orchestrator.test.ts b/packages/discovery/test/orchestrator.test.ts index a4db832..e26c1f6 100644 --- a/packages/discovery/test/orchestrator.test.ts +++ b/packages/discovery/test/orchestrator.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi } from 'vitest' import { DiscoveryOrchestrator } from '../src/orchestrator.js' +import { LocalDiscoveryProvider } from '../src/local-provider.js' import { WellKnownDiscoveryProvider } from '../src/well-known-provider.js' import { RegistryDiscoveryProvider } from '../src/registry-provider.js' +import { join } from 'node:path' +import { tmpdir } from 'node:os' import type { DiscoveryProvider } from '../src/provider.js' -import type { AgentCard } from '@fides/core' +import { createCapabilityDescriptor, createDiscoveryQuery, type AgentCard } from '@fides/core' describe('DiscoveryOrchestrator', () => { const mockCard: AgentCard = { @@ -70,4 +73,70 @@ describe('DiscoveryOrchestrator', () => { expect(result).toEqual(mockCard) }) + + it('discovers capability candidates through provider discover implementations', async () => { + const provider: DiscoveryProvider = { + name: 'query-provider', + resolve: vi.fn(), + discover: vi.fn().mockResolvedValue([{ + schema_version: 'fides.discovery_candidate.v1', + provider: 'query-provider', + agentId: mockCard.id, + card: mockCard, + capability: 'calendar.schedule', + verified: true, + rank: 10, + explanations: ['matched'], + errors: [], + }]), + } + const query = createDiscoveryQuery({ capability: 'calendar.schedule' }) + + const candidates = await new DiscoveryOrchestrator([provider]).discover(query) + + expect(candidates).toHaveLength(1) + expect(provider.discover).toHaveBeenCalledWith(query) + }) + + it('falls back to legacy DID resolution when requester_agent_id is present', async () => { + const card: AgentCard = { + ...mockCard, + capabilities: [createCapabilityDescriptor({ id: 'calendar.schedule' })], + } + const provider: DiscoveryProvider = { + name: 'legacy-provider', + resolve: vi.fn().mockResolvedValue(card), + } + + const candidates = await new DiscoveryOrchestrator([provider]).discover(createDiscoveryQuery({ + capability: 'calendar.schedule', + requester_agent_id: card.id, + })) + + expect(candidates).toHaveLength(1) + expect(candidates[0]).toMatchObject({ + provider: 'legacy-provider', + agentId: card.id, + capability: 'calendar.schedule', + verified: false, + }) + }) + + it('local provider discovers registered cards by capability', async () => { + const path = join(tmpdir(), `fides-local-agents-${crypto.randomUUID()}.json`) + const provider = new LocalDiscoveryProvider({ storePath: path }) + const card: AgentCard = { + ...mockCard, + capabilities: [createCapabilityDescriptor({ id: 'invoice.reconcile' })], + } + provider.registerCard(card) + + const candidates = await provider.discover(createDiscoveryQuery({ + capability: 'invoice.reconcile', + })) + + expect(candidates).toHaveLength(1) + expect(candidates[0].provider).toBe('local') + expect(candidates[0].explanations[0]).toContain('invoice.reconcile') + }) }) From 9f13c6999e7075e04ab859064948dc995a649a0b Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:14:39 +0300 Subject: [PATCH 008/282] feat(dht): add signed pointer records --- packages/core/src/dht.ts | 117 +++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/test/dht.test.ts | 86 ++++++++++++++ packages/discovery/src/dht-provider.ts | 76 +++++++++++- packages/discovery/test/dht-provider.test.ts | 73 ++++++++++++ 5 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/dht.ts create mode 100644 packages/core/test/dht.test.ts create mode 100644 packages/discovery/test/dht-provider.test.ts diff --git a/packages/core/src/dht.ts b/packages/core/src/dht.ts new file mode 100644 index 0000000..9ffa344 --- /dev/null +++ b/packages/core/src/dht.ts @@ -0,0 +1,117 @@ +import type { AgentCard } from './agent-card.js' +import { hashProtocolPayload } from './protocol.js' +import { signObject, verifyObject } from './canonical-signer.js' + +export interface DHTPointerRecord { + schema_version: 'fides.dht.pointer.v1' + record_type: 'capability_pointer' + capability: string + capability_hash: string + agent_id: string + agent_card_url: string + agent_card_hash: string + publisher_id: string + expires_at: string + sequence: number + signature: string +} + +export function hashCapability(capability: string): string { + return hashProtocolPayload({ capability }) +} + +export function hashAgentCard(card: AgentCard): string { + return hashProtocolPayload(card) +} + +export function createDHTPointerRecord(input: { + capability: string + agentId: string + agentCardUrl: string + agentCardHash: string + publisherId: string + expiresAt: string + sequence?: number +}): DHTPointerRecord { + return { + schema_version: 'fides.dht.pointer.v1', + record_type: 'capability_pointer', + capability: input.capability, + capability_hash: hashCapability(input.capability), + agent_id: input.agentId, + agent_card_url: input.agentCardUrl, + agent_card_hash: input.agentCardHash, + publisher_id: input.publisherId, + expires_at: input.expiresAt, + sequence: input.sequence ?? 1, + signature: '', + } +} + +export async function signDHTPointerRecord( + record: DHTPointerRecord, + privateKey: Uint8Array, + verificationMethod = record.publisher_id +): Promise { + const unsigned = { ...record, signature: '' } + const signed = await signObject(unsigned, privateKey, { + verificationMethod, + proofPurpose: 'assertionMethod', + }) + return { + ...record, + signature: signed.proof.proofValue, + } +} + +export async function verifyDHTPointerRecord( + record: DHTPointerRecord, + options: { + card?: AgentCard + now?: Date + verificationMethod?: string + } = {} +): Promise<{ valid: boolean; errors: string[] }> { + const errors: string[] = [] + if (record.schema_version !== 'fides.dht.pointer.v1') { + errors.push('DHT pointer schema_version is invalid') + } + if (record.record_type !== 'capability_pointer') { + errors.push('DHT pointer record_type is invalid') + } + if (record.capability_hash !== hashCapability(record.capability)) { + errors.push('DHT pointer capability_hash mismatch') + } + if (new Date(record.expires_at).getTime() <= (options.now ?? new Date()).getTime()) { + errors.push('DHT pointer is expired') + } + if (options.card) { + if (hashAgentCard(options.card) !== record.agent_card_hash) { + errors.push('DHT pointer agent_card_hash mismatch') + } + if ((options.card.agent_id ?? options.card.identity.did) !== record.agent_id) { + errors.push('DHT pointer agent_id does not match AgentCard') + } + } + + if (!record.signature) { + errors.push('DHT pointer signature is required') + } else { + const signatureValid = await verifyObject({ + payload: { ...record, signature: '' }, + proof: { + type: 'Ed25519Signature2024', + created: record.expires_at, + verificationMethod: options.verificationMethod ?? record.publisher_id, + proofPurpose: 'assertionMethod', + canonicalizationAlgorithm: 'https://fides.dev/canonical-json/v1', + proofValue: record.signature, + }, + }) + if (!signatureValid) { + errors.push('DHT pointer signature is invalid') + } + } + + return { valid: errors.length === 0, errors } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8a6fa5e..95ed771 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,7 @@ export * from './agent-card.js' export * from './capability.js' export * from './runtime-attestation.js' export * from './discovery.js' +export * from './dht.js' export * from './delegation.js' export * from './session-store.js' export * from './revocation.js' diff --git a/packages/core/test/dht.test.ts b/packages/core/test/dht.test.ts new file mode 100644 index 0000000..ee65b21 --- /dev/null +++ b/packages/core/test/dht.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + createAgentIdentity, + createCapabilityDescriptor, + createDHTPointerRecord, + hashAgentCard, + hashCapability, + signDHTPointerRecord, + verifyDHTPointerRecord, + type AgentCard, +} from '../src/index.js' + +describe('DHT pointer records', () => { + async function fixture() { + const publisher = await createAgentIdentity() + const agent = await createAgentIdentity() + const card: AgentCard = { + id: agent.identity.did, + agent_id: agent.identity.did, + identity: agent.identity, + capabilities: [createCapabilityDescriptor({ id: 'invoice.reconcile' })], + endpoints: [{ url: 'https://agent.example/card.json', protocol: 'https' }], + policies: [{ requiresRuntimeAttestation: false, requiresApproval: false }], + createdAt: '2026-05-29T00:00:00.000Z', + updatedAt: '2026-05-29T00:00:00.000Z', + } + const record = createDHTPointerRecord({ + capability: 'invoice.reconcile', + agentId: agent.identity.did, + agentCardUrl: 'https://agent.example/card.json', + agentCardHash: hashAgentCard(card), + publisherId: publisher.identity.did, + expiresAt: '2999-01-01T00:00:00.000Z', + }) + return { + card, + publisher, + record: await signDHTPointerRecord(record, publisher.privateKey), + } + } + + it('accepts a valid signed pointer', async () => { + const { card, record } = await fixture() + + await expect(verifyDHTPointerRecord(record, { card })).resolves.toEqual({ valid: true, errors: [] }) + expect(record.capability_hash).toBe(hashCapability('invoice.reconcile')) + }) + + it('rejects tampered pointers', async () => { + const { card, record } = await fixture() + const result = await verifyDHTPointerRecord({ ...record, capability: 'payments.execute' }, { card }) + + expect(result.valid).toBe(false) + expect(result.errors).toEqual(expect.arrayContaining([ + 'DHT pointer capability_hash mismatch', + 'DHT pointer signature is invalid', + ])) + }) + + it('rejects expired pointers', async () => { + const { card, record } = await fixture() + const result = await verifyDHTPointerRecord({ + ...record, + expires_at: '2026-05-28T00:00:00.000Z', + }, { + card, + now: new Date('2026-05-29T00:00:00.000Z'), + }) + + expect(result.valid).toBe(false) + expect(result.errors).toEqual(expect.arrayContaining([ + 'DHT pointer is expired', + 'DHT pointer signature is invalid', + ])) + }) + + it('rejects card hash mismatches', async () => { + const { card, record } = await fixture() + const result = await verifyDHTPointerRecord(record, { + card: { ...card, updatedAt: '2026-05-30T00:00:00.000Z' }, + }) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('DHT pointer agent_card_hash mismatch') + }) +}) diff --git a/packages/discovery/src/dht-provider.ts b/packages/discovery/src/dht-provider.ts index 7086d95..3a990b0 100644 --- a/packages/discovery/src/dht-provider.ts +++ b/packages/discovery/src/dht-provider.ts @@ -1,4 +1,14 @@ -import type { AgentCard, SignedAgentCard } from '@fides/core' +import { + cardSupportsCapability, + createDiscoveryCandidate, + hashCapability, + verifyDHTPointerRecord, + type AgentCard, + type DHTPointerRecord, + type DiscoveryCandidate, + type DiscoveryQuery, + type SignedAgentCard, +} from '@fides/core' import { DiscoveryProvider } from './provider.js' /** @@ -18,6 +28,7 @@ export class DHTDiscoveryProvider implements DiscoveryProvider { private localStore = new Map() // Simulated peer nodes (each has its own store) private peers: DHTDiscoveryProvider[] = [] + private pointerStore = new Map() // Replication factor private replicationFactor: number @@ -44,6 +55,30 @@ export class DHTDiscoveryProvider implements DiscoveryProvider { return null } + async discover(query: DiscoveryQuery): Promise { + if (!query.capability) return [] + + const pointers = await this.findPointers(query.capability) + const candidates: DiscoveryCandidate[] = [] + for (const pointer of pointers) { + const card = await this.resolve(pointer.agent_id) + if (!card || !cardSupportsCapability(card, query.capability)) continue + const verification = await verifyDHTPointerRecord(pointer, { card }) + candidates.push(createDiscoveryCandidate({ + provider: this.name, + card, + capability: query.capability, + verified: verification.valid, + rank: verification.valid ? 50 : 0, + explanations: verification.valid + ? ['DHT returned a signed capability pointer; DHT is not an authority source'] + : ['DHT pointer failed verification'], + errors: [], + })) + } + return candidates.slice(0, query.limit ?? candidates.length) + } + async register(card: SignedAgentCard): Promise { const did = card.payload.id const agentCard = card.payload as AgentCard @@ -58,10 +93,49 @@ export class DHTDiscoveryProvider implements DiscoveryProvider { } } + async publishPointer(record: DHTPointerRecord): Promise { + const key = record.capability_hash + const records = this.pointerStore.get(key) ?? [] + this.pointerStore.set(key, [...records.filter(existing => existing.agent_id !== record.agent_id), record]) + for (const peer of this.peers.slice(0, this.replicationFactor - 1)) { + const peerRecords = peer.pointerStore.get(key) ?? [] + peer.pointerStore.set(key, [...peerRecords.filter(existing => existing.agent_id !== record.agent_id), record]) + } + } + + async findPointers(capability: string): Promise { + const key = hashCapability(capability) + const seen = new Set() + const records: DHTPointerRecord[] = [] + for (const record of this.pointerStore.get(key) ?? []) { + const recordKey = `${record.agent_id}:${record.sequence}` + if (!seen.has(recordKey)) { + seen.add(recordKey) + records.push(record) + } + } + for (const peer of this.peers) { + for (const record of peer.pointerStore.get(key) ?? []) { + const recordKey = `${record.agent_id}:${record.sequence}` + if (!seen.has(recordKey)) { + seen.add(recordKey) + records.push(record) + } + } + } + return records + } + async deregister(did: string): Promise { this.localStore.delete(did) + for (const [key, records] of this.pointerStore) { + this.pointerStore.set(key, records.filter(record => record.agent_id !== did)) + } for (const peer of this.peers) { peer.localStore.delete(did) + for (const [key, records] of peer.pointerStore) { + peer.pointerStore.set(key, records.filter(record => record.agent_id !== did)) + } } } diff --git a/packages/discovery/test/dht-provider.test.ts b/packages/discovery/test/dht-provider.test.ts new file mode 100644 index 0000000..124613f --- /dev/null +++ b/packages/discovery/test/dht-provider.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { DHTDiscoveryProvider } from '../src/dht-provider.js' +import { + createAgentIdentity, + createCapabilityDescriptor, + createDHTPointerRecord, + createDiscoveryQuery, + hashAgentCard, + signAgentCard, + signDHTPointerRecord, + type AgentCard, +} from '@fides/core' + +describe('DHTDiscoveryProvider', () => { + async function fixture() { + const publisher = await createAgentIdentity() + const agent = await createAgentIdentity() + const card: AgentCard = { + id: agent.identity.did, + agent_id: agent.identity.did, + identity: agent.identity, + capabilities: [createCapabilityDescriptor({ id: 'invoice.reconcile' })], + endpoints: [{ url: 'https://agent.example/card.json', protocol: 'https' }], + policies: [{ requiresRuntimeAttestation: false, requiresApproval: false }], + createdAt: '2026-05-29T00:00:00.000Z', + updatedAt: '2026-05-29T00:00:00.000Z', + } + const signedCard = await signAgentCard(card, agent.privateKey, agent.identity.did) + const pointer = await signDHTPointerRecord(createDHTPointerRecord({ + capability: 'invoice.reconcile', + agentId: agent.identity.did, + agentCardUrl: 'https://agent.example/card.json', + agentCardHash: hashAgentCard(signedCard.payload), + publisherId: publisher.identity.did, + expiresAt: '2999-01-01T00:00:00.000Z', + }), publisher.privateKey) + return { card, signedCard, pointer } + } + + it('discovers cards through signed DHT pointers', async () => { + const { signedCard, pointer } = await fixture() + const provider = new DHTDiscoveryProvider() + + await provider.register(signedCard) + await provider.publishPointer(pointer) + + const candidates = await provider.discover(createDiscoveryQuery({ + capability: 'invoice.reconcile', + })) + + expect(candidates).toHaveLength(1) + expect(candidates[0]).toMatchObject({ + provider: 'dht', + capability: 'invoice.reconcile', + verified: true, + }) + expect(candidates[0].explanations[0]).toContain('not an authority') + }) + + it('does not return mismatched capability pointers', async () => { + const { signedCard, pointer } = await fixture() + const provider = new DHTDiscoveryProvider() + + await provider.register(signedCard) + await provider.publishPointer(pointer) + + const candidates = await provider.discover(createDiscoveryQuery({ + capability: 'calendar.schedule', + })) + + expect(candidates).toEqual([]) + }) +}) From 7481d1124e9d9bff0986abf779a5838ab8e3ffa9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:16:59 +0300 Subject: [PATCH 009/282] feat(trust): add capability-specific trust and reputation --- packages/core/src/index.ts | 2 + packages/core/src/reputation.ts | 86 ++++++++++++++ packages/core/src/trust.ts | 155 ++++++++++++++++++++++++++ packages/core/test/reputation.test.ts | 52 +++++++++ packages/core/test/trust.test.ts | 107 ++++++++++++++++++ 5 files changed, 402 insertions(+) create mode 100644 packages/core/src/reputation.ts create mode 100644 packages/core/src/trust.ts create mode 100644 packages/core/test/reputation.test.ts create mode 100644 packages/core/test/trust.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 95ed771..44187c6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,6 +24,8 @@ export * from './capability.js' export * from './runtime-attestation.js' export * from './discovery.js' export * from './dht.js' +export * from './trust.js' +export * from './reputation.js' export * from './delegation.js' export * from './session-store.js' export * from './revocation.js' diff --git a/packages/core/src/reputation.ts b/packages/core/src/reputation.ts new file mode 100644 index 0000000..7675b5b --- /dev/null +++ b/packages/core/src/reputation.ts @@ -0,0 +1,86 @@ +export interface ReputationReason { + factor: 'success_rate' | 'volume_confidence' | 'publisher_weight' | 'incident_penalty' | 'context_boundary_penalty' + value: number + description: string +} + +export interface ReputationRecord { + schema_version: 'fides.reputation.record.v1' + agent_id: string + publisher_id?: string + principal_id?: string + capability: string + score: number + successful_invocations: number + failed_invocations: number + incident_count: number + publisher_weight: number + context_boundary_penalty: number + reasons: ReputationReason[] + computed_at: string +} + +export interface ComputeCapabilityReputationInput { + agentId: string + publisherId?: string + principalId?: string + capability: string + successfulInvocations?: number + failedInvocations?: number + incidentCount?: number + publisherWeight?: number + contextBoundaryMismatch?: boolean + computedAt?: string +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.min(1, Math.max(0, value)) +} + +export function computeCapabilityReputation(input: ComputeCapabilityReputationInput): ReputationRecord { + const successful = Math.max(0, input.successfulInvocations ?? 0) + const failed = Math.max(0, input.failedInvocations ?? 0) + const incidentCount = Math.max(0, input.incidentCount ?? 0) + const total = successful + failed + const successRate = total === 0 ? 0 : successful / total + const volumeConfidence = Math.min(1, total / 20) + const publisherWeight = clamp01(input.publisherWeight ?? 0.5) + const incidentPenalty = Math.min(1, incidentCount * 0.18) + const contextBoundaryPenalty = input.contextBoundaryMismatch ? 0.2 : 0 + + const score = clamp01( + (successRate * 0.48) + + (volumeConfidence * 0.22) + + (publisherWeight * 0.20) + + 0.10 - + incidentPenalty - + contextBoundaryPenalty + ) + + return { + schema_version: 'fides.reputation.record.v1', + agent_id: input.agentId, + publisher_id: input.publisherId, + principal_id: input.principalId, + capability: input.capability, + score: Number(score.toFixed(4)), + successful_invocations: successful, + failed_invocations: failed, + incident_count: incidentCount, + publisher_weight: publisherWeight, + context_boundary_penalty: contextBoundaryPenalty, + reasons: [ + { factor: 'success_rate', value: Number(successRate.toFixed(4)), description: 'Capability-specific invocation success rate' }, + { factor: 'volume_confidence', value: Number(volumeConfidence.toFixed(4)), description: 'Confidence from observed volume for this capability' }, + { factor: 'publisher_weight', value: publisherWeight, description: 'Publisher-weighted reputation signal' }, + { factor: 'incident_penalty', value: incidentPenalty, description: 'Penalty from incidents scoped to this capability or agent' }, + { factor: 'context_boundary_penalty', value: contextBoundaryPenalty, description: 'Penalty for applying reputation outside its capability context' }, + ], + computed_at: input.computedAt ?? new Date().toISOString(), + } +} + +export function createReputationRecord(input: ComputeCapabilityReputationInput): ReputationRecord { + return computeCapabilityReputation(input) +} diff --git a/packages/core/src/trust.ts b/packages/core/src/trust.ts new file mode 100644 index 0000000..eb97e30 --- /dev/null +++ b/packages/core/src/trust.ts @@ -0,0 +1,155 @@ +import type { CapabilityControl, CapabilityDescriptor } from './capability.js' + +export type TrustBand = 'unknown' | 'low' | 'medium' | 'high' | 'verified' + +export interface TrustScoreComponents { + identity: number + publisher: number + trustAnchors: number + capabilityFit: number + evidence: number + policyCompliance: number + runtimeSafety: number + peerAttestation: number + incidentPenalty: number + noveltyPenalty: number + contextBoundaryPenalty: number +} + +export type TrustReasonComponent = + | 'IdentityScore' + | 'PublisherScore' + | 'TrustAnchorScore' + | 'CapabilityFitScore' + | 'EvidenceScore' + | 'PolicyComplianceScore' + | 'RuntimeSafetyScore' + | 'PeerAttestationScore' + | 'IncidentPenalty' + | 'NoveltyPenalty' + | 'ContextBoundaryPenalty' + +export interface TrustReason { + component: TrustReasonComponent + value: number + weight: number + description: string +} + +export interface TrustResult { + schema_version: 'fides.trust.result.v1' + agent_id: string + capability: string + score: number + band: TrustBand + reasons: TrustReason[] + risk_flags: string[] + evidence_refs: string[] + required_controls: CapabilityControl[] + computed_at: string +} + +export interface ComputeTrustResultInput { + agentId: string + capability: CapabilityDescriptor + components: TrustScoreComponents + evidenceRefs?: string[] + computedAt?: string +} + +const POSITIVE_WEIGHTS: Array<{ + key: keyof Omit + component: TrustReasonComponent + weight: number + description: string +}> = [ + { key: 'identity', component: 'IdentityScore', weight: 0.14, description: 'Cryptographic identity validity and continuity' }, + { key: 'publisher', component: 'PublisherScore', weight: 0.12, description: 'Publisher identity strength' }, + { key: 'trustAnchors', component: 'TrustAnchorScore', weight: 0.12, description: 'Verified trust anchors for this agent or publisher' }, + { key: 'capabilityFit', component: 'CapabilityFitScore', weight: 0.14, description: 'Capability descriptor fit for the requested action' }, + { key: 'evidence', component: 'EvidenceScore', weight: 0.12, description: 'Evidence history quality' }, + { key: 'policyCompliance', component: 'PolicyComplianceScore', weight: 0.14, description: 'Historical policy compliance' }, + { key: 'runtimeSafety', component: 'RuntimeSafetyScore', weight: 0.12, description: 'Runtime or build attestation safety signal' }, + { key: 'peerAttestation', component: 'PeerAttestationScore', weight: 0.10, description: 'Peer attestation signal' }, +] + +const PENALTY_WEIGHTS: Array<{ + key: keyof Pick + component: TrustReasonComponent + weight: number + description: string +}> = [ + { key: 'incidentPenalty', component: 'IncidentPenalty', weight: 0.45, description: 'Penalty from unresolved or recent incidents' }, + { key: 'noveltyPenalty', component: 'NoveltyPenalty', weight: 0.20, description: 'Penalty for insufficient history' }, + { key: 'contextBoundaryPenalty', component: 'ContextBoundaryPenalty', weight: 0.25, description: 'Penalty for reputation or trust used outside its context' }, +] + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.min(1, Math.max(0, value)) +} + +export function trustBandForScore(score: number): TrustBand { + const value = clamp01(score) + if (value < 0.15) return 'unknown' + if (value < 0.35) return 'low' + if (value < 0.6) return 'medium' + if (value < 0.9) return 'high' + return 'verified' +} + +export function computeTrustResult(input: ComputeTrustResultInput): TrustResult { + const reasons: TrustReason[] = [] + let score = 0 + + for (const item of POSITIVE_WEIGHTS) { + const value = clamp01(input.components[item.key]) + score += value * item.weight + reasons.push({ + component: item.component, + value, + weight: item.weight, + description: item.description, + }) + } + + for (const item of PENALTY_WEIGHTS) { + const value = clamp01(input.components[item.key]) + score -= value * item.weight + reasons.push({ + component: item.component, + value, + weight: -item.weight, + description: item.description, + }) + } + + const riskFlags: string[] = [] + const requiredControls = new Set() + + if ((input.capability.riskLevel === 'high' || input.capability.riskLevel === 'critical') && input.components.runtimeSafety < 0.5) { + riskFlags.push('runtime_safety_low') + requiredControls.add('runtime_attestation') + } + + if (input.capability.requiresApproval || input.capability.riskLevel === 'critical') { + requiredControls.add('human_approval') + } + + if (input.components.incidentPenalty > 0.25) riskFlags.push('incident_history') + if (input.components.contextBoundaryPenalty > 0) riskFlags.push('context_boundary') + if (input.components.noveltyPenalty > 0.4) riskFlags.push('limited_history') + + return { + schema_version: 'fides.trust.result.v1', + agent_id: input.agentId, + capability: input.capability.id, + score: Number(clamp01(score).toFixed(4)), + band: trustBandForScore(score), + reasons, + risk_flags: riskFlags, + evidence_refs: input.evidenceRefs ?? [], + required_controls: Array.from(requiredControls), + computed_at: input.computedAt ?? new Date().toISOString(), + } +} diff --git a/packages/core/test/reputation.test.ts b/packages/core/test/reputation.test.ts new file mode 100644 index 0000000..cd3c6ff --- /dev/null +++ b/packages/core/test/reputation.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + computeCapabilityReputation, + createReputationRecord, +} from '../src/reputation.js' + +describe('capability-specific reputation v2', () => { + it('does not let one capability reputation imply another capability', () => { + const paymentRecord = createReputationRecord({ + agentId: 'did:fides:agent', + publisherId: 'did:fides:publisher', + capability: 'payments.execute', + successfulInvocations: 20, + failedInvocations: 1, + incidentCount: 0, + publisherWeight: 0.8, + }) + + const calendarRecord = createReputationRecord({ + agentId: 'did:fides:agent', + publisherId: 'did:fides:publisher', + capability: 'calendar.schedule', + successfulInvocations: 1, + failedInvocations: 0, + incidentCount: 0, + publisherWeight: 0.8, + }) + + expect(paymentRecord.capability).toBe('payments.execute') + expect(calendarRecord.capability).toBe('calendar.schedule') + expect(paymentRecord.score).toBeGreaterThan(calendarRecord.score) + }) + + it('penalizes incidents and context laundering attempts', () => { + const result = computeCapabilityReputation({ + agentId: 'did:fides:agent', + publisherId: 'did:fides:publisher', + capability: 'payments.execute', + successfulInvocations: 20, + failedInvocations: 0, + incidentCount: 2, + publisherWeight: 0.9, + contextBoundaryMismatch: true, + }) + + expect(result.score).toBeLessThan(0.75) + expect(result.reasons.map(reason => reason.factor)).toEqual(expect.arrayContaining([ + 'incident_penalty', + 'context_boundary_penalty', + ])) + }) +}) diff --git a/packages/core/test/trust.test.ts b/packages/core/test/trust.test.ts new file mode 100644 index 0000000..034f822 --- /dev/null +++ b/packages/core/test/trust.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { + computeTrustResult, + trustBandForScore, + type TrustScoreComponents, +} from '../src/trust.js' +import type { CapabilityDescriptor } from '../src/capability.js' + +const mediumCapability: CapabilityDescriptor = { + id: 'invoice.reconcile', + namespace: 'invoice', + action: 'reconcile', + description: 'Reconcile invoices', + inputSchema: {}, + outputSchema: {}, + riskLevel: 'medium', + requiresApproval: false, + requiresRuntimeAttestation: false, + requiredScopes: ['read:invoices'], + supportedControls: ['policy_proof'], +} + +const highRiskCapability: CapabilityDescriptor = { + ...mediumCapability, + id: 'payments.prepare', + namespace: 'payments', + action: 'prepare', + riskLevel: 'high', + requiresApproval: true, + requiresRuntimeAttestation: true, + requiredScopes: ['payments:prepare'], + supportedControls: ['dry_run', 'human_approval', 'runtime_attestation'], +} + +describe('TrustResult v2', () => { + it('maps trust scores to stable bands', () => { + expect(trustBandForScore(0.05)).toBe('unknown') + expect(trustBandForScore(0.2)).toBe('low') + expect(trustBandForScore(0.45)).toBe('medium') + expect(trustBandForScore(0.7)).toBe('high') + expect(trustBandForScore(0.95)).toBe('verified') + }) + + it('computes a capability-specific trust result with explainability', () => { + const components: TrustScoreComponents = { + identity: 0.8, + publisher: 0.7, + trustAnchors: 0.6, + capabilityFit: 0.9, + evidence: 0.7, + policyCompliance: 0.8, + runtimeSafety: 0.6, + peerAttestation: 0.4, + incidentPenalty: 0.1, + noveltyPenalty: 0.05, + contextBoundaryPenalty: 0, + } + + const result = computeTrustResult({ + agentId: 'did:fides:agent', + capability: mediumCapability, + components, + evidenceRefs: ['evt_1'], + }) + + expect(result).toMatchObject({ + schema_version: 'fides.trust.result.v1', + agent_id: 'did:fides:agent', + capability: 'invoice.reconcile', + band: 'high', + evidence_refs: ['evt_1'], + required_controls: [], + }) + expect(result.score).toBeGreaterThan(0.6) + expect(result.reasons.map(reason => reason.component)).toEqual(expect.arrayContaining([ + 'IdentityScore', + 'CapabilityFitScore', + 'IncidentPenalty', + ])) + }) + + it('requires controls for high-risk capabilities when runtime safety is weak', () => { + const result = computeTrustResult({ + agentId: 'did:fides:payment', + capability: highRiskCapability, + components: { + identity: 0.8, + publisher: 0.8, + trustAnchors: 0.7, + capabilityFit: 0.8, + evidence: 0.5, + policyCompliance: 0.7, + runtimeSafety: 0.1, + peerAttestation: 0.3, + incidentPenalty: 0, + noveltyPenalty: 0.1, + contextBoundaryPenalty: 0, + }, + }) + + expect(result.required_controls).toEqual(expect.arrayContaining([ + 'runtime_attestation', + 'human_approval', + ])) + expect(result.risk_flags).toEqual(expect.arrayContaining(['runtime_safety_low'])) + }) +}) From 526926c2e72859ec2bbfd0c7d865e2e9e89a2189 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:18:23 +0300 Subject: [PATCH 010/282] feat(policy): add fides policy decision model --- packages/policy/src/index.ts | 147 +++++++++++++++++++++++++ packages/policy/test/policy-v2.test.ts | 99 +++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 packages/policy/test/policy-v2.test.ts diff --git a/packages/policy/src/index.ts b/packages/policy/src/index.ts index 30ac7de..1cf846c 100644 --- a/packages/policy/src/index.ts +++ b/packages/policy/src/index.ts @@ -1,3 +1,5 @@ +import type { CapabilityControl, CapabilityDescriptor, TrustResult } from '@fides/core' + /** * FIDES v2 Policy Engine * @@ -39,6 +41,52 @@ export interface PolicyResult { matchedRules: string[] } +export type FidesPolicyDecisionAction = + | 'allow' + | 'deny' + | 'require_approval' + | 'dry_run_only' + | 'scope_limit' + | 'risk_limit' + +export interface PolicyReason { + code: string + severity: 'info' | 'warning' | 'error' + message: string + evidence_refs: string[] +} + +export interface FidesPolicyDecision { + schema_version: 'fides.policy.decision.v1' + decision: FidesPolicyDecisionAction + principal_id: string + requester_agent_id: string + target_agent_id: string + capability: string + reason_codes: string[] + machine_reasons: PolicyReason[] + human_reasons: string[] + required_controls: CapabilityControl[] + evidence_refs: string[] + evaluated_at: string +} + +export interface FidesPolicyEvaluationInput { + principalId: string + requesterAgentId: string + targetAgentId: string + capability: CapabilityDescriptor + trustResult: TrustResult + requestedScopes?: string[] + runtimeAttestationValid?: boolean + revocationActive?: boolean + killSwitchActive?: boolean + incidentsActive?: boolean + approvalGranted?: boolean + evidenceRefs?: string[] + evaluatedAt?: string +} + function evaluateExpression(expr: PolicyExpression, context: PolicyContext): boolean { const fieldValue = context[expr.field] switch (expr.operator) { @@ -99,6 +147,105 @@ export function evaluatePolicy(bundle: PolicyBundle, context: PolicyContext): Po } } +function createDecision( + input: FidesPolicyEvaluationInput, + decision: FidesPolicyDecisionAction, + reasons: PolicyReason[], + requiredControls: CapabilityControl[] = [] +): FidesPolicyDecision { + const evidenceRefs = Array.from(new Set([ + ...(input.evidenceRefs ?? []), + ...input.trustResult.evidence_refs, + ...reasons.flatMap(reason => reason.evidence_refs), + ])) + + return { + schema_version: 'fides.policy.decision.v1', + decision, + principal_id: input.principalId, + requester_agent_id: input.requesterAgentId, + target_agent_id: input.targetAgentId, + capability: input.capability.id, + reason_codes: reasons.map(reason => reason.code), + machine_reasons: reasons, + human_reasons: reasons.map(reason => reason.message), + required_controls: Array.from(new Set(requiredControls)), + evidence_refs: evidenceRefs, + evaluated_at: input.evaluatedAt ?? new Date().toISOString(), + } +} + +function reason(code: string, severity: PolicyReason['severity'], message: string, evidenceRefs: string[] = []): PolicyReason { + return { + code, + severity, + message, + evidence_refs: evidenceRefs, + } +} + +function missingScopes(requiredScopes: string[], requestedScopes: string[]): string[] { + return requiredScopes.filter(scope => !requestedScopes.includes(scope)) +} + +export function evaluateFidesPolicy(input: FidesPolicyEvaluationInput): FidesPolicyDecision { + if (input.killSwitchActive) { + return createDecision(input, 'deny', [ + reason('KILL_SWITCH_ACTIVE', 'error', 'A kill switch rule is active for this request.'), + ]) + } + + if (input.revocationActive) { + return createDecision(input, 'deny', [ + reason('REVOCATION_ACTIVE', 'error', 'An active revocation record blocks this request.'), + ]) + } + + if (input.incidentsActive) { + return createDecision(input, 'deny', [ + reason('INCIDENT_REQUIRES_REVIEW', 'error', 'An active incident requires review before execution.'), + ], ['human_approval']) + } + + const requestedScopes = input.requestedScopes ?? [] + const requiredScopes = input.capability.requiredScopes ?? [] + const missing = missingScopes(requiredScopes, requestedScopes) + if (missing.length > 0) { + return createDecision(input, 'scope_limit', [ + reason('SESSION_SCOPE_INVALID', 'error', `Missing required scopes: ${missing.join(', ')}.`), + ], ['scope_limit']) + } + + if (input.trustResult.band === 'unknown') { + return createDecision(input, 'dry_run_only', [ + reason('TRUST_UNKNOWN_DRY_RUN_ONLY', 'warning', 'Unknown agents can be discovered but only dry-run authority is available.'), + ], ['dry_run']) + } + + if (input.trustResult.band === 'low') { + return createDecision(input, 'risk_limit', [ + reason('TRUST_BELOW_THRESHOLD', 'error', 'Trust is below the threshold for execution.'), + ], ['scope_limit']) + } + + const highRisk = input.capability.riskLevel === 'high' || input.capability.riskLevel === 'critical' + if (highRisk && !input.runtimeAttestationValid && !input.approvalGranted) { + return createDecision(input, 'require_approval', [ + reason('HIGH_RISK_REQUIRES_ATTESTATION_OR_APPROVAL', 'warning', 'High-risk capabilities require valid runtime attestation or explicit approval.'), + ], ['runtime_attestation', 'human_approval']) + } + + if (input.capability.riskLevel === 'critical' && !input.approvalGranted) { + return createDecision(input, 'require_approval', [ + reason('CRITICAL_CAPABILITY_REQUIRES_APPROVAL', 'warning', 'Critical capabilities require explicit approval before execution.'), + ], ['human_approval']) + } + + return createDecision(input, 'allow', [ + reason('POLICY_ALLOWED', 'info', 'Policy allowed the request for this capability and scope.', input.trustResult.evidence_refs), + ]) +} + /** * Pre-execution pipeline with Allow / Warn / Block guards. * Ported from Sardis pattern. diff --git a/packages/policy/test/policy-v2.test.ts b/packages/policy/test/policy-v2.test.ts new file mode 100644 index 0000000..63a9855 --- /dev/null +++ b/packages/policy/test/policy-v2.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { evaluateFidesPolicy } from '../src/index.js' +import type { CapabilityDescriptor, TrustResult } from '@fides/core' + +const capability = (riskLevel: CapabilityDescriptor['riskLevel']): CapabilityDescriptor => ({ + id: riskLevel === 'critical' ? 'payments.execute' : 'invoice.reconcile', + namespace: riskLevel === 'critical' ? 'payments' : 'invoice', + action: riskLevel === 'critical' ? 'execute' : 'reconcile', + name: riskLevel, + description: riskLevel, + inputSchema: { type: 'object' }, + outputSchema: { type: 'object' }, + riskLevel, + requiresApproval: riskLevel === 'critical', + requiresRuntimeAttestation: riskLevel === 'high' || riskLevel === 'critical', + requiredScopes: riskLevel === 'critical' ? ['payments:execute'] : ['invoice:read'], + supportedControls: ['dry_run', 'human_approval', 'runtime_attestation', 'scope_limit'], +}) + +const trust = (band: TrustResult['band'], score: number): TrustResult => ({ + schema_version: 'fides.trust.result.v1', + agent_id: 'did:fides:agent', + capability: 'invoice.reconcile', + score, + band, + reasons: [], + risk_flags: [], + evidence_refs: ['evt_1'], + required_controls: [], + computed_at: '2026-05-29T00:00:00.000Z', +}) + +describe('FIDES policy v2', () => { + it('denies revoked agents before trust or capability scoring', () => { + const decision = evaluateFidesPolicy({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:agent', + capability: capability('low'), + trustResult: trust('verified', 0.95), + requestedScopes: ['invoice:read'], + revocationActive: true, + }) + + expect(decision.decision).toBe('deny') + expect(decision.reason_codes).toContain('REVOCATION_ACTIVE') + expect(decision.human_reasons[0]).toContain('revocation') + }) + + it('lets unknown agents dry-run only instead of granting authority', () => { + const decision = evaluateFidesPolicy({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:agent', + capability: capability('medium'), + trustResult: trust('unknown', 0.1), + requestedScopes: ['invoice:read'], + }) + + expect(decision.decision).toBe('dry_run_only') + expect(decision.required_controls).toEqual(expect.arrayContaining(['dry_run'])) + expect(decision.reason_codes).toContain('TRUST_UNKNOWN_DRY_RUN_ONLY') + }) + + it('requires runtime attestation or approval for high-risk capabilities', () => { + const decision = evaluateFidesPolicy({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:agent', + capability: capability('high'), + trustResult: trust('high', 0.74), + requestedScopes: ['invoice:read'], + runtimeAttestationValid: false, + }) + + expect(decision.decision).toBe('require_approval') + expect(decision.required_controls).toEqual(expect.arrayContaining([ + 'runtime_attestation', + 'human_approval', + ])) + expect(decision.reason_codes).toContain('HIGH_RISK_REQUIRES_ATTESTATION_OR_APPROVAL') + }) + + it('allows scoped medium-risk actions with compatible trust and scopes', () => { + const decision = evaluateFidesPolicy({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:agent', + capability: capability('medium'), + trustResult: trust('high', 0.72), + requestedScopes: ['invoice:read'], + runtimeAttestationValid: false, + }) + + expect(decision.decision).toBe('allow') + expect(decision.reason_codes).toContain('POLICY_ALLOWED') + expect(decision.machine_reasons.length).toBeGreaterThan(0) + }) +}) From 31d1bfa60696e3fb44e89343ba478b1f129d6695 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:19:35 +0300 Subject: [PATCH 011/282] feat(policy): add approval and kill switch primitives --- packages/core/src/approval.ts | 184 ++++++++++++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/test/approval.test.ts | 60 +++++++++ 3 files changed, 245 insertions(+) create mode 100644 packages/core/src/approval.ts create mode 100644 packages/core/test/approval.test.ts diff --git a/packages/core/src/approval.ts b/packages/core/src/approval.ts new file mode 100644 index 0000000..3d5bd52 --- /dev/null +++ b/packages/core/src/approval.ts @@ -0,0 +1,184 @@ +import { signObject, verifyObject, type SignedObject } from './canonical-signer.js' +import { hashProtocolPayload } from './protocol.js' + +export type ApprovalDecisionValue = 'approved' | 'denied' +export type KillSwitchTargetType = 'agent' | 'publisher' | 'capability' | 'session' | 'principal' | 'risk_class' + +export interface ApprovalRequest { + schema_version: 'fides.approval.request.v1' + id: string + requester_agent_id: string + target_agent_id: string + principal_id: string + capability: string + requested_scopes: string[] + risk_level: 'low' | 'medium' | 'high' | 'critical' + policy_decision_hash?: string + evidence_refs: string[] + status: 'pending' | 'approved' | 'denied' | 'expired' + created_at: string + expires_at?: string + payload_hash: string +} + +export interface ApprovalDecision { + schema_version: 'fides.approval.decision.v1' + id: string + approval_request_id: string + approver_id: string + decision: ApprovalDecisionValue + reason: string + constraints: Record + evidence_refs: string[] + decided_at: string + payload_hash: string +} + +export interface KillSwitchRule { + schema_version: 'fides.kill_switch.rule.v1' + id: string + issuer: string + target_type: KillSwitchTargetType + target: string + reason: string + enabled: boolean + created_at: string + expires_at?: string + payload_hash: string +} + +export type SignedApprovalRequest = SignedObject +export type SignedApprovalDecision = SignedObject +export type SignedKillSwitchRule = SignedObject + +export interface CreateApprovalRequestInput { + requesterAgentId: string + targetAgentId: string + principalId: string + capability: string + requestedScopes?: string[] + riskLevel: ApprovalRequest['risk_level'] + policyDecisionHash?: string + evidenceRefs?: string[] + expiresAt?: string + createdAt?: string +} + +export interface CreateApprovalDecisionInput { + approvalRequestId: string + approverId: string + decision: ApprovalDecisionValue + reason?: string + constraints?: Record + evidenceRefs?: string[] + decidedAt?: string +} + +export interface CreateKillSwitchRuleInput { + issuer: string + targetType: KillSwitchTargetType + target: string + reason: string + enabled?: boolean + createdAt?: string + expiresAt?: string +} + +function withPayloadHash>(payload: T): T & { payload_hash: string } { + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + +export function createApprovalRequest(input: CreateApprovalRequestInput): ApprovalRequest { + return withPayloadHash({ + schema_version: 'fides.approval.request.v1' as const, + id: crypto.randomUUID(), + requester_agent_id: input.requesterAgentId, + target_agent_id: input.targetAgentId, + principal_id: input.principalId, + capability: input.capability, + requested_scopes: input.requestedScopes ?? [], + risk_level: input.riskLevel, + policy_decision_hash: input.policyDecisionHash, + evidence_refs: input.evidenceRefs ?? [], + status: 'pending' as const, + created_at: input.createdAt ?? new Date().toISOString(), + expires_at: input.expiresAt, + }) +} + +export function createApprovalDecision(input: CreateApprovalDecisionInput): ApprovalDecision { + return withPayloadHash({ + schema_version: 'fides.approval.decision.v1' as const, + id: crypto.randomUUID(), + approval_request_id: input.approvalRequestId, + approver_id: input.approverId, + decision: input.decision, + reason: input.reason ?? '', + constraints: input.constraints ?? {}, + evidence_refs: input.evidenceRefs ?? [], + decided_at: input.decidedAt ?? new Date().toISOString(), + }) +} + +export function createKillSwitchRule(input: CreateKillSwitchRuleInput): KillSwitchRule { + return withPayloadHash({ + schema_version: 'fides.kill_switch.rule.v1' as const, + id: crypto.randomUUID(), + issuer: input.issuer, + target_type: input.targetType, + target: input.target, + reason: input.reason, + enabled: input.enabled ?? true, + created_at: input.createdAt ?? new Date().toISOString(), + expires_at: input.expiresAt, + }) +} + +export function isApprovalRequestExpired(request: ApprovalRequest, now: Date = new Date()): boolean { + return request.expires_at ? new Date(request.expires_at) <= now : false +} + +export function isKillSwitchRuleActive(rule: KillSwitchRule, now: Date = new Date()): boolean { + if (!rule.enabled) return false + if (!rule.expires_at) return true + return new Date(rule.expires_at) > now +} + +export function signApprovalRequest( + request: ApprovalRequest, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(request, privateKey, { verificationMethod, proofPurpose: 'assertionMethod' }) +} + +export function verifySignedApprovalRequest(signed: SignedApprovalRequest): Promise { + return verifyObject(signed) +} + +export function signApprovalDecision( + decision: ApprovalDecision, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(decision, privateKey, { verificationMethod, proofPurpose: 'assertionMethod' }) +} + +export function verifySignedApprovalDecision(signed: SignedApprovalDecision): Promise { + return verifyObject(signed) +} + +export function signKillSwitchRule( + rule: KillSwitchRule, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(rule, privateKey, { verificationMethod, proofPurpose: 'assertionMethod' }) +} + +export function verifySignedKillSwitchRule(signed: SignedKillSwitchRule): Promise { + return verifyObject(signed) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 44187c6..8573324 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ export * from './discovery.js' export * from './dht.js' export * from './trust.js' export * from './reputation.js' +export * from './approval.js' export * from './delegation.js' export * from './session-store.js' export * from './revocation.js' diff --git a/packages/core/test/approval.test.ts b/packages/core/test/approval.test.ts new file mode 100644 index 0000000..0632439 --- /dev/null +++ b/packages/core/test/approval.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { createIdentityKeyPair } from '../src/identity.js' +import { + createApprovalDecision, + createApprovalRequest, + createKillSwitchRule, + isKillSwitchRuleActive, + signApprovalDecision, + signApprovalRequest, + signKillSwitchRule, + verifySignedApprovalDecision, + verifySignedApprovalRequest, + verifySignedKillSwitchRule, +} from '../src/approval.js' + +describe('approval and kill switch primitives', () => { + it('creates and verifies signed approval requests and decisions', async () => { + const approver = await createIdentityKeyPair() + const request = createApprovalRequest({ + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:target', + principalId: 'did:fides:principal', + capability: 'payments.prepare', + requestedScopes: ['payments:prepare'], + riskLevel: 'high', + policyDecisionHash: 'sha256:policy', + evidenceRefs: ['evt_1'], + }) + + const signedRequest = await signApprovalRequest(request, approver.privateKey, approver.did) + expect(await verifySignedApprovalRequest(signedRequest)).toBe(true) + + const decision = createApprovalDecision({ + approvalRequestId: request.id, + approverId: approver.did, + decision: 'approved', + reason: 'Runtime attestation accepted', + constraints: { dryRunOnly: true }, + }) + + const signedDecision = await signApprovalDecision(decision, approver.privateKey, approver.did) + expect(await verifySignedApprovalDecision(signedDecision)).toBe(true) + }) + + it('creates active kill switch rules that can target risky capability classes', async () => { + const issuer = await createIdentityKeyPair() + const rule = createKillSwitchRule({ + issuer: issuer.did, + targetType: 'risk_class', + target: 'critical', + reason: 'Emergency high-risk action freeze', + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }) + + expect(isKillSwitchRuleActive(rule)).toBe(true) + + const signedRule = await signKillSwitchRule(rule, issuer.privateKey, issuer.did) + expect(await verifySignedKillSwitchRule(signedRule)).toBe(true) + }) +}) From bf6af2071d3e15424415f5a2eb846ae2f1a401d9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:20:37 +0300 Subject: [PATCH 012/282] feat(delegation): add scoped session grants --- packages/core/src/delegation.ts | 99 ++++++++++++++++++++- packages/core/test/session-grant-v2.test.ts | 63 +++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/session-grant-v2.test.ts diff --git a/packages/core/src/delegation.ts b/packages/core/src/delegation.ts index b8f47af..df13d42 100644 --- a/packages/core/src/delegation.ts +++ b/packages/core/src/delegation.ts @@ -5,7 +5,8 @@ */ import type { SignedObject } from './canonical-signer.js' -import { canonicalDigest } from './canonical-signer.js' +import { canonicalDigest, signObject, verifyObject } from './canonical-signer.js' +import { hashProtocolPayload } from './protocol.js' import * as ed from '@noble/ed25519' import { bytesToHex } from '@noble/hashes/utils' @@ -39,6 +40,27 @@ export interface SessionGrant { boundTo?: string } +export interface SessionGrantV2 { + schema_version: 'fides.session_grant.v1' + session_id: string + requester_agent_id: string + target_agent_id: string + principal_id: string + capability: string + scopes: string[] + constraints: Record + policy_hash: string + trust_result_hash: string + issued_at: string + expires_at: string + nonce: string + audience: string[] + issuer: string + payload_hash: string +} + +export type SignedSessionGrantV2 = SignedObject + export interface DelegationInput { delegator: string delegatee: string @@ -48,6 +70,22 @@ export interface DelegationInput { audience?: string[] } +export interface SessionGrantV2Input { + requesterAgentId: string + targetAgentId: string + principalId: string + capability: string + scopes: string[] + constraints?: Record + policyHash: string + trustResultHash: string + audience?: string[] + issuer: string + issuedAt?: string + expiresAt: string + nonce?: string +} + export function createDelegationToken(input: DelegationInput): DelegationToken { return { id: crypto.randomUUID(), @@ -63,6 +101,31 @@ export function createDelegationToken(input: DelegationInput): DelegationToken { } } +export function createSessionGrantV2(input: SessionGrantV2Input): SessionGrantV2 { + const payload = { + schema_version: 'fides.session_grant.v1' as const, + session_id: crypto.randomUUID(), + requester_agent_id: input.requesterAgentId, + target_agent_id: input.targetAgentId, + principal_id: input.principalId, + capability: input.capability, + scopes: input.scopes, + constraints: input.constraints ?? {}, + policy_hash: input.policyHash, + trust_result_hash: input.trustResultHash, + issued_at: input.issuedAt ?? new Date().toISOString(), + expires_at: input.expiresAt, + nonce: input.nonce ?? crypto.randomUUID(), + audience: input.audience ?? [input.targetAgentId], + issuer: input.issuer, + } + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + /** * Sign a delegation token with the delegator's private key. * The signature covers the canonical JSON of the token (excluding signature field). @@ -99,6 +162,10 @@ export function isSessionExpired(session: SessionGrant): boolean { return new Date(session.expiresAt) < new Date() } +export function isSessionGrantV2Expired(session: SessionGrantV2, now: Date = new Date()): boolean { + return new Date(session.expires_at) <= now +} + export function validateDelegationToken(token: DelegationToken): { valid: boolean; errors: string[] } { const errors: string[] = [] if (!token.id) errors.push('DelegationToken.id is required') @@ -111,6 +178,36 @@ export function validateDelegationToken(token: DelegationToken): { valid: boolea return { valid: errors.length === 0, errors } } +export function validateSessionGrantV2(session: SessionGrantV2): { valid: boolean; errors: string[] } { + const errors: string[] = [] + if (session.schema_version !== 'fides.session_grant.v1') errors.push('SessionGrant.schema_version is invalid') + if (!session.session_id) errors.push('SessionGrant.session_id is required') + if (!session.requester_agent_id) errors.push('SessionGrant.requester_agent_id is required') + if (!session.target_agent_id) errors.push('SessionGrant.target_agent_id is required') + if (!session.principal_id) errors.push('SessionGrant.principal_id is required') + if (!session.capability) errors.push('SessionGrant.capability is required') + if (!session.scopes || session.scopes.length === 0) errors.push('SessionGrant.scopes must not be empty') + if (!session.policy_hash) errors.push('SessionGrant.policy_hash is required') + if (!session.trust_result_hash) errors.push('SessionGrant.trust_result_hash is required') + if (!session.nonce) errors.push('SessionGrant.nonce is required') + if (!session.issuer) errors.push('SessionGrant.issuer is required') + if (!session.expires_at) errors.push('SessionGrant.expires_at is required') + if (session.expires_at && isSessionGrantV2Expired(session)) errors.push('SessionGrant is expired') + return { valid: errors.length === 0, errors } +} + +export function signSessionGrantV2( + session: SessionGrantV2, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(session, privateKey, { verificationMethod, proofPurpose: 'delegation' }) +} + +export function verifySignedSessionGrantV2(signed: SignedSessionGrantV2): Promise { + return verifyObject(signed) +} + export interface RevokedSession extends SessionGrant { revoked: boolean revokedAt: string diff --git a/packages/core/test/session-grant-v2.test.ts b/packages/core/test/session-grant-v2.test.ts new file mode 100644 index 0000000..bfb5155 --- /dev/null +++ b/packages/core/test/session-grant-v2.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { createIdentityKeyPair } from '../src/identity.js' +import { + createSessionGrantV2, + isSessionGrantV2Expired, + signSessionGrantV2, + validateSessionGrantV2, + verifySignedSessionGrantV2, +} from '../src/delegation.js' + +describe('SessionGrant v2', () => { + it('creates a scoped session grant with audience, nonce, and hashes', async () => { + const issuer = await createIdentityKeyPair() + const grant = createSessionGrantV2({ + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:target', + principalId: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['invoice:read'], + constraints: { maxActions: 3 }, + policyHash: 'sha256:policy', + trustResultHash: 'sha256:trust', + audience: ['did:fides:target'], + issuer: issuer.did, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }) + + expect(grant).toMatchObject({ + schema_version: 'fides.session_grant.v1', + requester_agent_id: 'did:fides:requester', + target_agent_id: 'did:fides:target', + principal_id: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['invoice:read'], + policy_hash: 'sha256:policy', + trust_result_hash: 'sha256:trust', + audience: ['did:fides:target'], + issuer: issuer.did, + }) + expect(grant.nonce).toBeTruthy() + expect(validateSessionGrantV2(grant)).toEqual({ valid: true, errors: [] }) + expect(isSessionGrantV2Expired(grant)).toBe(false) + }) + + it('signs and verifies a session grant using the canonical signing model', async () => { + const issuer = await createIdentityKeyPair() + const grant = createSessionGrantV2({ + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:target', + principalId: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['invoice:read'], + constraints: {}, + policyHash: 'sha256:policy', + trustResultHash: 'sha256:trust', + issuer: issuer.did, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }) + + const signed = await signSessionGrantV2(grant, issuer.privateKey, issuer.did) + expect(await verifySignedSessionGrantV2(signed)).toBe(true) + }) +}) From ac783948ac6bdc4f141b7c526a79a6a3d542c4bf Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:21:59 +0300 Subject: [PATCH 013/282] feat(invocation): add capability invocation protocol objects --- packages/core/src/index.ts | 1 + packages/core/src/invocation.ts | 182 ++++++++++++++++++++++++++ packages/core/test/invocation.test.ts | 84 ++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 packages/core/src/invocation.ts create mode 100644 packages/core/test/invocation.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8573324..d87abf6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,5 +28,6 @@ export * from './trust.js' export * from './reputation.js' export * from './approval.js' export * from './delegation.js' +export * from './invocation.js' export * from './session-store.js' export * from './revocation.js' diff --git a/packages/core/src/invocation.ts b/packages/core/src/invocation.ts new file mode 100644 index 0000000..ecd6d84 --- /dev/null +++ b/packages/core/src/invocation.ts @@ -0,0 +1,182 @@ +import { signObject, verifyObject, type SignedObject } from './canonical-signer.js' +import { hashProtocolPayload } from './protocol.js' +import type { SessionGrantV2 } from './delegation.js' + +export type InvocationStatus = + | 'dry_run' + | 'approval_required' + | 'denied' + | 'allowed' + | 'completed' + | 'failed' + +export interface InvocationRequest { + schema_version: 'fides.invocation.request.v1' + id: string + issuer: string + session_id: string + requester_agent_id: string + target_agent_id: string + principal_id: string + capability: string + scopes: string[] + dry_run: boolean + input_hash: string + input_schema_hash?: string + output_schema_hash?: string + issued_at: string + payload_hash: string +} + +export interface InvocationResult { + schema_version: 'fides.invocation.result.v1' + id: string + issuer: string + invocation_request_id: string + status: InvocationStatus + output_hash?: string + error_code?: string + evidence_refs: string[] + created_at: string + payload_hash: string +} + +export type SignedInvocationRequest = SignedObject +export type SignedInvocationResult = SignedObject + +export interface InvocationRequestInput { + issuer: string + sessionGrant: SessionGrantV2 + input: unknown + dryRun?: boolean + inputSchema?: unknown + outputSchema?: unknown + issuedAt?: string +} + +export interface InvocationResultInput { + issuer: string + invocationRequestId: string + status: InvocationStatus + output?: unknown + errorCode?: string + evidenceRefs?: string[] + createdAt?: string +} + +export interface InvocationPolicyDecisionLike { + decision: 'allow' | 'deny' | 'require_approval' | 'dry_run_only' | 'scope_limit' | 'risk_limit' + reason_codes: string[] +} + +export interface InvocationPreflightInput { + request: InvocationRequest + policyDecision: InvocationPolicyDecisionLike +} + +export interface InvocationPreflightResult { + status: InvocationStatus + can_execute: boolean + dry_run_only: boolean + reason_codes: string[] +} + +export function createInvocationRequest(input: InvocationRequestInput): InvocationRequest { + const payload = { + schema_version: 'fides.invocation.request.v1' as const, + id: crypto.randomUUID(), + issuer: input.issuer, + session_id: input.sessionGrant.session_id, + requester_agent_id: input.sessionGrant.requester_agent_id, + target_agent_id: input.sessionGrant.target_agent_id, + principal_id: input.sessionGrant.principal_id, + capability: input.sessionGrant.capability, + scopes: input.sessionGrant.scopes, + dry_run: input.dryRun ?? false, + input_hash: hashProtocolPayload(input.input), + input_schema_hash: input.inputSchema ? hashProtocolPayload(input.inputSchema) : undefined, + output_schema_hash: input.outputSchema ? hashProtocolPayload(input.outputSchema) : undefined, + issued_at: input.issuedAt ?? new Date().toISOString(), + } + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + +export function createInvocationResult(input: InvocationResultInput): InvocationResult { + const payload = { + schema_version: 'fides.invocation.result.v1' as const, + id: crypto.randomUUID(), + issuer: input.issuer, + invocation_request_id: input.invocationRequestId, + status: input.status, + output_hash: input.output === undefined ? undefined : hashProtocolPayload(input.output), + error_code: input.errorCode, + evidence_refs: input.evidenceRefs ?? [], + created_at: input.createdAt ?? new Date().toISOString(), + } + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + +export function evaluateInvocationPreflight(input: InvocationPreflightInput): InvocationPreflightResult { + switch (input.policyDecision.decision) { + case 'allow': + return { + status: input.request.dry_run ? 'dry_run' : 'allowed', + can_execute: !input.request.dry_run, + dry_run_only: input.request.dry_run, + reason_codes: input.policyDecision.reason_codes, + } + case 'dry_run_only': + return { + status: 'dry_run', + can_execute: false, + dry_run_only: true, + reason_codes: input.policyDecision.reason_codes, + } + case 'require_approval': + return { + status: 'approval_required', + can_execute: false, + dry_run_only: false, + reason_codes: input.policyDecision.reason_codes, + } + default: + return { + status: 'denied', + can_execute: false, + dry_run_only: false, + reason_codes: input.policyDecision.reason_codes, + } + } +} + +export function signInvocationRequest( + request: InvocationRequest, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(request, privateKey, { verificationMethod, proofPurpose: 'capabilityInvocation' }) +} + +export function verifySignedInvocationRequest(signed: SignedInvocationRequest): Promise { + return verifyObject(signed) +} + +export function signInvocationResult( + result: InvocationResult, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(result, privateKey, { verificationMethod, proofPurpose: 'capabilityInvocation' }) +} + +export function verifySignedInvocationResult(signed: SignedInvocationResult): Promise { + return verifyObject(signed) +} diff --git a/packages/core/test/invocation.test.ts b/packages/core/test/invocation.test.ts new file mode 100644 index 0000000..86721f5 --- /dev/null +++ b/packages/core/test/invocation.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { createIdentityKeyPair } from '../src/identity.js' +import { createSessionGrantV2, signSessionGrantV2 } from '../src/delegation.js' +import { + createInvocationRequest, + createInvocationResult, + evaluateInvocationPreflight, + signInvocationRequest, + signInvocationResult, + verifySignedInvocationRequest, + verifySignedInvocationResult, +} from '../src/invocation.js' + +async function signedGrant() { + const issuer = await createIdentityKeyPair() + const grant = createSessionGrantV2({ + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:target', + principalId: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['invoice:read'], + constraints: {}, + policyHash: 'sha256:policy', + trustResultHash: 'sha256:trust', + issuer: issuer.did, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }) + return signSessionGrantV2(grant, issuer.privateKey, issuer.did) +} + +describe('invocation protocol objects', () => { + it('creates and verifies signed invocation requests', async () => { + const requester = await createIdentityKeyPair() + const grant = await signedGrant() + const request = createInvocationRequest({ + issuer: requester.did, + sessionGrant: grant.payload, + input: { invoiceId: 'inv_123' }, + dryRun: true, + }) + + expect(request.input_hash).toMatch(/^sha256:/) + expect(request.output_schema_hash).toBeUndefined() + + const signed = await signInvocationRequest(request, requester.privateKey, requester.did) + expect(await verifySignedInvocationRequest(signed)).toBe(true) + }) + + it('preflights denied and approval-required policy decisions without execution', async () => { + const grant = await signedGrant() + const request = createInvocationRequest({ + issuer: 'did:fides:requester', + sessionGrant: grant.payload, + input: { invoiceId: 'inv_123' }, + }) + + const denied = evaluateInvocationPreflight({ + request, + policyDecision: { decision: 'deny', reason_codes: ['POLICY_DENIED'] }, + }) + expect(denied.status).toBe('denied') + + const pending = evaluateInvocationPreflight({ + request, + policyDecision: { decision: 'require_approval', reason_codes: ['APPROVAL_REQUIRED'] }, + }) + expect(pending.status).toBe('approval_required') + }) + + it('creates and verifies signed invocation results', async () => { + const target = await createIdentityKeyPair() + const result = createInvocationResult({ + issuer: target.did, + invocationRequestId: 'inv_req_1', + status: 'completed', + output: { ok: true }, + evidenceRefs: ['evt_1'], + }) + + expect(result.output_hash).toMatch(/^sha256:/) + const signed = await signInvocationResult(result, target.privateKey, target.did) + expect(await verifySignedInvocationResult(signed)).toBe(true) + }) +}) From 980f3331b0d81043e4424c25927ebe957cae5147 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:23:06 +0300 Subject: [PATCH 014/282] feat(attestations): add mock tee runtime attestations --- packages/core/src/runtime-attestation.ts | 107 +++++++++++++++++- .../core/test/runtime-attestation.test.ts | 58 ++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/runtime-attestation.test.ts diff --git a/packages/core/src/runtime-attestation.ts b/packages/core/src/runtime-attestation.ts index 8c342aa..a6e0932 100644 --- a/packages/core/src/runtime-attestation.ts +++ b/packages/core/src/runtime-attestation.ts @@ -1,5 +1,7 @@ +import { hashProtocolPayload } from './protocol.js' + export interface RuntimeAttestation { - schema_version?: 'fides.runtime_attestation.v1' + schema_version: 'fides.runtime_attestation.v1' attestation_id: string agent_id: string provider: 'mock-tee' | 'null' | 'aws-nitro' | 'intel-sgx' | 'amd-sev' | 'container-image' | 'reproducible-build' | string @@ -11,3 +13,106 @@ export interface RuntimeAttestation { expires_at: string signature: string } + +export interface RuntimeAttestationIssueInput { + agentId: string + codeHash: string + runtimeHash: string + policyHash: string + enclaveMeasurement?: string + issuedAt?: string + expiresAt?: string +} + +export interface AttestationProvider { + readonly provider: RuntimeAttestation['provider'] + issue(input: RuntimeAttestationIssueInput): Promise + verify(attestation: RuntimeAttestation): Promise +} + +export interface TeeAttestationProvider extends AttestationProvider {} +export interface ContainerBuildAttestationProvider extends AttestationProvider {} + +const DEFAULT_ATTESTATION_TTL_MS = 3600_000 + +export function isRuntimeAttestationExpired(attestation: RuntimeAttestation, now: Date = new Date()): boolean { + return new Date(attestation.expires_at) <= now +} + +export function createRuntimeAttestation(input: RuntimeAttestationIssueInput & { + provider: RuntimeAttestation['provider'] + signature: string +}): RuntimeAttestation { + const issuedAt = input.issuedAt ?? new Date().toISOString() + return { + schema_version: 'fides.runtime_attestation.v1', + attestation_id: crypto.randomUUID(), + agent_id: input.agentId, + provider: input.provider, + code_hash: input.codeHash, + runtime_hash: input.runtimeHash, + policy_hash: input.policyHash, + enclave_measurement: input.enclaveMeasurement ?? hashProtocolPayload({ + agent_id: input.agentId, + code_hash: input.codeHash, + runtime_hash: input.runtimeHash, + policy_hash: input.policyHash, + issued_at: issuedAt, + provider: input.provider, + }), + issued_at: issuedAt, + expires_at: input.expiresAt ?? new Date(Date.now() + DEFAULT_ATTESTATION_TTL_MS).toISOString(), + signature: input.signature, + } +} + +export async function verifyRuntimeAttestation( + attestation: RuntimeAttestation, + provider: Pick +): Promise { + if (attestation.provider !== provider.provider) return false + if (isRuntimeAttestationExpired(attestation)) return false + return provider.verify(attestation) +} + +export class MockTEEProvider implements TeeAttestationProvider { + readonly provider = 'mock-tee' + + async issue(input: RuntimeAttestationIssueInput): Promise { + return createRuntimeAttestation({ + ...input, + provider: this.provider, + signature: 'mock-tee-signature', + }) + } + + async verify(attestation: RuntimeAttestation): Promise { + if (attestation.provider !== this.provider) return false + if (isRuntimeAttestationExpired(attestation)) return false + return attestation.signature === 'mock-tee-signature' && + isSha256(attestation.code_hash) && + isSha256(attestation.runtime_hash) && + isSha256(attestation.policy_hash) && + Boolean(attestation.enclave_measurement) + } +} + +export class NullAttestationProvider implements AttestationProvider { + readonly provider = 'null' + + async issue(input: RuntimeAttestationIssueInput): Promise { + return createRuntimeAttestation({ + ...input, + provider: this.provider, + signature: 'null-attestation', + }) + } + + async verify(_attestation: RuntimeAttestation): Promise { + return false + } +} + +function isSha256(value: string): boolean { + return /^sha256:[a-f0-9]{64}$/i.test(value) +} diff --git a/packages/core/test/runtime-attestation.test.ts b/packages/core/test/runtime-attestation.test.ts new file mode 100644 index 0000000..23f9aa2 --- /dev/null +++ b/packages/core/test/runtime-attestation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + MockTEEProvider, + NullAttestationProvider, + isRuntimeAttestationExpired, + verifyRuntimeAttestation, +} from '../src/runtime-attestation.js' + +describe('runtime attestation v2', () => { + it('issues and verifies mock TEE attestations with the FIDES v2 schema', async () => { + const provider = new MockTEEProvider() + const attestation = await provider.issue({ + agentId: 'did:fides:agent', + codeHash: `sha256:${'a'.repeat(64)}`, + runtimeHash: `sha256:${'b'.repeat(64)}`, + policyHash: `sha256:${'c'.repeat(64)}`, + }) + + expect(attestation).toMatchObject({ + schema_version: 'fides.runtime_attestation.v1', + agent_id: 'did:fides:agent', + provider: 'mock-tee', + code_hash: `sha256:${'a'.repeat(64)}`, + runtime_hash: `sha256:${'b'.repeat(64)}`, + policy_hash: `sha256:${'c'.repeat(64)}`, + enclave_measurement: expect.stringMatching(/^sha256:/), + }) + expect(await provider.verify(attestation)).toBe(true) + expect(await verifyRuntimeAttestation(attestation, provider)).toBe(true) + }) + + it('rejects expired attestations', async () => { + const provider = new MockTEEProvider() + const attestation = await provider.issue({ + agentId: 'did:fides:agent', + codeHash: `sha256:${'a'.repeat(64)}`, + runtimeHash: `sha256:${'b'.repeat(64)}`, + policyHash: `sha256:${'c'.repeat(64)}`, + expiresAt: new Date(Date.now() - 1000).toISOString(), + }) + + expect(isRuntimeAttestationExpired(attestation)).toBe(true) + expect(await provider.verify(attestation)).toBe(false) + }) + + it('null provider is explicit and never verifies high-risk attestations', async () => { + const provider = new NullAttestationProvider() + const attestation = await provider.issue({ + agentId: 'did:fides:agent', + codeHash: `sha256:${'a'.repeat(64)}`, + runtimeHash: `sha256:${'b'.repeat(64)}`, + policyHash: `sha256:${'c'.repeat(64)}`, + }) + + expect(attestation.provider).toBe('null') + expect(await provider.verify(attestation)).toBe(false) + }) +}) From feec916cdf4db16a58138e5714cd935c60002806 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:24:16 +0300 Subject: [PATCH 015/282] feat(incidents): add revocation and incident v2 records --- packages/core/src/revocation.ts | 163 +++++++++++++++++- .../core/test/revocation-incident-v2.test.ts | 66 +++++++ 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/revocation-incident-v2.test.ts diff --git a/packages/core/src/revocation.ts b/packages/core/src/revocation.ts index bd69958..3f37636 100644 --- a/packages/core/src/revocation.ts +++ b/packages/core/src/revocation.ts @@ -5,10 +5,87 @@ * revocation records and incident reports. */ -import { canonicalDigest } from './canonical-signer.js' +import { canonicalDigest, signObject, verifyObject, type SignedObject } from './canonical-signer.js' +import { hashProtocolPayload } from './protocol.js' import * as ed from '@noble/ed25519' import { bytesToHex } from '@noble/hashes/utils' +export type RevocationTargetType = + | 'key' + | 'identity' + | 'agent' + | 'agent_card' + | 'capability' + | 'session' + | 'attestation' + | 'publisher' + +export type IncidentCategory = + | 'policy_violation' + | 'data_exfiltration' + | 'malicious_output' + | 'sandbox_escape' + | 'unauthorized_action' + | 'prompt_injection_failure' + | 'payment_error' + | 'suspicious_behavior' + +export interface RevocationRecordV2 { + schema_version: 'fides.revocation.record.v1' + id: string + issuer: string + target_type: RevocationTargetType + target_id: string + reason: string + status: 'active' | 'superseded' | 'expired' + evidence_refs: string[] + created_at: string + expires_at?: string + payload_hash: string +} + +export interface IncidentRecordV2 { + schema_version: 'fides.incident.record.v1' + id: string + reporter: string + target_agent_id: string + severity: 'low' | 'medium' | 'high' | 'critical' + category: IncidentCategory + description: string + evidence_refs: string[] + resolution_status: 'open' | 'resolved' | 'dismissed' | 'false_positive' + trust_penalty: number + reputation_penalty: number + created_at: string + resolved_at?: string + payload_hash: string +} + +export interface RevocationInputV2 { + issuer: string + targetType: RevocationTargetType + targetId: string + reason: string + evidenceRefs?: string[] + createdAt?: string + expiresAt?: string +} + +export interface IncidentInputV2 { + reporter: string + targetAgentId: string + severity: IncidentRecordV2['severity'] + category: IncidentCategory + description: string + evidenceRefs?: string[] + trustPenalty?: number + reputationPenalty?: number + createdAt?: string +} + +export type SignedRevocationRecordV2 = SignedObject +export type SignedIncidentRecordV2 = SignedObject + export interface RevocationRecord { id: string did: string @@ -62,6 +139,90 @@ const SEVERITY_PENALTY: Record = critical: { trust: 0.6, reputation: 1.0 }, } +export function createRevocationRecordV2(input: RevocationInputV2): RevocationRecordV2 { + const payload = { + schema_version: 'fides.revocation.record.v1' as const, + id: crypto.randomUUID(), + issuer: input.issuer, + target_type: input.targetType, + target_id: input.targetId, + reason: input.reason, + status: 'active' as const, + evidence_refs: input.evidenceRefs ?? [], + created_at: input.createdAt ?? new Date().toISOString(), + expires_at: input.expiresAt, + } + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + +export function createIncidentRecordV2(input: IncidentInputV2): IncidentRecordV2 { + const penalties = SEVERITY_PENALTY[input.severity] ?? { trust: 0.1, reputation: 0.2 } + const payload = { + schema_version: 'fides.incident.record.v1' as const, + id: crypto.randomUUID(), + reporter: input.reporter, + target_agent_id: input.targetAgentId, + severity: input.severity, + category: input.category, + description: input.description, + evidence_refs: input.evidenceRefs ?? [], + resolution_status: 'open' as const, + trust_penalty: input.trustPenalty ?? penalties.trust, + reputation_penalty: input.reputationPenalty ?? penalties.reputation, + created_at: input.createdAt ?? new Date().toISOString(), + } + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + +export function resolveIncidentRecordV2( + record: IncidentRecordV2, + status: Exclude = 'resolved' +): IncidentRecordV2 { + const payload = { + ...record, + resolution_status: status, + resolved_at: new Date().toISOString(), + payload_hash: undefined, + } + const { payload_hash: _, ...withoutHash } = payload + return { + ...record, + resolution_status: status, + resolved_at: payload.resolved_at, + payload_hash: hashProtocolPayload(withoutHash), + } +} + +export function signRevocationRecordV2( + record: RevocationRecordV2, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(record, privateKey, { verificationMethod, proofPurpose: 'assertionMethod' }) +} + +export function verifySignedRevocationRecordV2(signed: SignedRevocationRecordV2): Promise { + return verifyObject(signed) +} + +export function signIncidentRecordV2( + record: IncidentRecordV2, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(record, privateKey, { verificationMethod, proofPurpose: 'assertionMethod' }) +} + +export function verifySignedIncidentRecordV2(signed: SignedIncidentRecordV2): Promise { + return verifyObject(signed) +} + /** * Create a revocation record (unsigned). */ diff --git a/packages/core/test/revocation-incident-v2.test.ts b/packages/core/test/revocation-incident-v2.test.ts new file mode 100644 index 0000000..53a35a7 --- /dev/null +++ b/packages/core/test/revocation-incident-v2.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { createIdentityKeyPair } from '../src/identity.js' +import { + createIncidentRecordV2, + createRevocationRecordV2, + resolveIncidentRecordV2, + signIncidentRecordV2, + signRevocationRecordV2, + verifySignedIncidentRecordV2, + verifySignedRevocationRecordV2, +} from '../src/revocation.js' + +describe('revocation and incident v2 records', () => { + it('creates and verifies signed revocation records for sessions and attestations', async () => { + const issuer = await createIdentityKeyPair() + const record = createRevocationRecordV2({ + issuer: issuer.did, + targetType: 'session', + targetId: 'sess_123', + reason: 'Session nonce replay detected', + evidenceRefs: ['evt_1'], + }) + + expect(record).toMatchObject({ + schema_version: 'fides.revocation.record.v1', + issuer: issuer.did, + target_type: 'session', + target_id: 'sess_123', + status: 'active', + evidence_refs: ['evt_1'], + }) + + const signed = await signRevocationRecordV2(record, issuer.privateKey, issuer.did) + expect(await verifySignedRevocationRecordV2(signed)).toBe(true) + }) + + it('creates and resolves signed incident records with trust impact metadata', async () => { + const reporter = await createIdentityKeyPair() + const incident = createIncidentRecordV2({ + reporter: reporter.did, + targetAgentId: 'did:fides:agent', + severity: 'high', + category: 'prompt_injection_failure', + description: 'Agent ignored policy context and leaked tool output.', + evidenceRefs: ['evt_2'], + }) + + expect(incident).toMatchObject({ + schema_version: 'fides.incident.record.v1', + reporter: reporter.did, + target_agent_id: 'did:fides:agent', + severity: 'high', + category: 'prompt_injection_failure', + resolution_status: 'open', + evidence_refs: ['evt_2'], + }) + expect(incident.trust_penalty).toBeGreaterThan(0) + + const signed = await signIncidentRecordV2(incident, reporter.privateKey, reporter.did) + expect(await verifySignedIncidentRecordV2(signed)).toBe(true) + + const resolved = resolveIncidentRecordV2(incident, 'false_positive') + expect(resolved.resolution_status).toBe('false_positive') + expect(resolved.resolved_at).toBeDefined() + }) +}) From 0e1b066a799d14698862b4875ae752a5d977b878 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:25:20 +0300 Subject: [PATCH 016/282] feat(registry): add signed registry federation records --- packages/core/src/index.ts | 1 + packages/core/src/registry.ts | 131 ++++++++++++++++++++++++++++ packages/core/test/registry.test.ts | 61 +++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 packages/core/src/registry.ts create mode 100644 packages/core/test/registry.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d87abf6..bc280c1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,5 +29,6 @@ export * from './reputation.js' export * from './approval.js' export * from './delegation.js' export * from './invocation.js' +export * from './registry.js' export * from './session-store.js' export * from './revocation.js' diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts new file mode 100644 index 0000000..340dd95 --- /dev/null +++ b/packages/core/src/registry.ts @@ -0,0 +1,131 @@ +import { signObject, verifyObject, type SignedObject } from './canonical-signer.js' +import { hashProtocolPayload } from './protocol.js' + +export type RegistryMode = 'hosted' | 'public' | 'private' +export type RegistryPeeringMode = 'public' | 'private' | 'federated' + +export interface RegistryIndexRecord { + schema_version: 'fides.registry.index.v1' + id: string + issuer: string + mode: RegistryMode + agent_card_id: string + agent_id: string + capability_ids: string[] + agent_card_hash: string + registry_url: string + supported_versions: string[] + created_at: string + expires_at?: string + payload_hash: string +} + +export interface RegistryPeerRecord { + schema_version: 'fides.registry.peer.v1' + id: string + issuer: string + peer_id: string + registry_url: string + trust_domain?: string + peering_mode: RegistryPeeringMode + supported_versions: string[] + capabilities: Array<'registry_search' | 'revocation_propagation' | 'incident_propagation' | 'agent_card_replication'> + created_at: string + expires_at?: string + payload_hash: string +} + +export type SignedRegistryIndexRecord = SignedObject +export type SignedRegistryPeerRecord = SignedObject + +export interface RegistryIndexRecordInput { + issuer: string + mode: RegistryMode + agentCardId: string + agentId: string + capabilityIds: string[] + agentCardHash: string + registryUrl: string + supportedVersions: string[] + createdAt?: string + expiresAt?: string +} + +export interface RegistryPeerRecordInput { + issuer: string + peerId: string + registryUrl: string + trustDomain?: string + peeringMode: RegistryPeeringMode + supportedVersions: string[] + capabilities: RegistryPeerRecord['capabilities'] + createdAt?: string + expiresAt?: string +} + +export function createRegistryIndexRecord(input: RegistryIndexRecordInput): RegistryIndexRecord { + const payload = { + schema_version: 'fides.registry.index.v1' as const, + id: crypto.randomUUID(), + issuer: input.issuer, + mode: input.mode, + agent_card_id: input.agentCardId, + agent_id: input.agentId, + capability_ids: input.capabilityIds, + agent_card_hash: input.agentCardHash, + registry_url: input.registryUrl, + supported_versions: input.supportedVersions, + created_at: input.createdAt ?? new Date().toISOString(), + expires_at: input.expiresAt, + } + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + +export function createRegistryPeerRecord(input: RegistryPeerRecordInput): RegistryPeerRecord { + const payload = { + schema_version: 'fides.registry.peer.v1' as const, + id: crypto.randomUUID(), + issuer: input.issuer, + peer_id: input.peerId, + registry_url: input.registryUrl, + trust_domain: input.trustDomain, + peering_mode: input.peeringMode, + supported_versions: input.supportedVersions, + capabilities: input.capabilities, + created_at: input.createdAt ?? new Date().toISOString(), + expires_at: input.expiresAt, + } + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + +export function signRegistryIndexRecord( + record: RegistryIndexRecord, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(record, privateKey, { verificationMethod, proofPurpose: 'assertionMethod' }) +} + +export function verifySignedRegistryIndexRecord(signed: SignedRegistryIndexRecord): Promise { + return verifyObject(signed) +} + +export function signRegistryPeerRecord( + record: RegistryPeerRecord, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(record, privateKey, { verificationMethod, proofPurpose: 'assertionMethod' }) +} + +export function verifySignedRegistryPeerRecord(signed: SignedRegistryPeerRecord): Promise { + return verifyObject(signed) +} diff --git a/packages/core/test/registry.test.ts b/packages/core/test/registry.test.ts new file mode 100644 index 0000000..87b4d69 --- /dev/null +++ b/packages/core/test/registry.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { createIdentityKeyPair } from '../src/identity.js' +import { + createRegistryIndexRecord, + createRegistryPeerRecord, + signRegistryIndexRecord, + signRegistryPeerRecord, + verifySignedRegistryIndexRecord, + verifySignedRegistryPeerRecord, +} from '../src/registry.js' + +describe('registry and federation records', () => { + it('creates and verifies signed registry index records', async () => { + const issuer = await createIdentityKeyPair() + const record = createRegistryIndexRecord({ + issuer: issuer.did, + mode: 'public', + agentCardId: 'card_123', + agentId: 'did:fides:agent', + capabilityIds: ['invoice.reconcile'], + agentCardHash: 'sha256:card', + registryUrl: 'https://registry.example', + supportedVersions: ['fides.v2.0'], + }) + + expect(record).toMatchObject({ + schema_version: 'fides.registry.index.v1', + issuer: issuer.did, + mode: 'public', + agent_id: 'did:fides:agent', + capability_ids: ['invoice.reconcile'], + supported_versions: ['fides.v2.0'], + }) + + const signed = await signRegistryIndexRecord(record, issuer.privateKey, issuer.did) + expect(await verifySignedRegistryIndexRecord(signed)).toBe(true) + }) + + it('creates and verifies signed federation peer records', async () => { + const issuer = await createIdentityKeyPair() + const record = createRegistryPeerRecord({ + issuer: issuer.did, + peerId: 'peer_1', + registryUrl: 'https://peer.example', + trustDomain: 'example', + peeringMode: 'private', + supportedVersions: ['fides.v2.0'], + capabilities: ['revocation_propagation', 'incident_propagation'], + }) + + expect(record).toMatchObject({ + schema_version: 'fides.registry.peer.v1', + peer_id: 'peer_1', + peering_mode: 'private', + capabilities: ['revocation_propagation', 'incident_propagation'], + }) + + const signed = await signRegistryPeerRecord(record, issuer.privateKey, issuer.did) + expect(await verifySignedRegistryPeerRecord(signed)).toBe(true) + }) +}) From b2766abac78379a0d1542b204ed7d52b51f940ea Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:27:01 +0300 Subject: [PATCH 017/282] feat(adapters): add interop adapter interfaces --- packages/adapters/LICENSE | 21 ++++++++ packages/adapters/README.md | 9 ++++ packages/adapters/package.json | 37 +++++++++++++ packages/adapters/src/index.ts | 72 +++++++++++++++++++++++++ packages/adapters/test/adapters.test.ts | 52 ++++++++++++++++++ packages/adapters/tsconfig.json | 6 +++ pnpm-lock.yaml | 16 ++++++ 7 files changed, 213 insertions(+) create mode 100644 packages/adapters/LICENSE create mode 100644 packages/adapters/README.md create mode 100644 packages/adapters/package.json create mode 100644 packages/adapters/src/index.ts create mode 100644 packages/adapters/test/adapters.test.ts create mode 100644 packages/adapters/tsconfig.json diff --git a/packages/adapters/LICENSE b/packages/adapters/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/adapters/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/adapters/README.md b/packages/adapters/README.md new file mode 100644 index 0000000..a657702 --- /dev/null +++ b/packages/adapters/README.md @@ -0,0 +1,9 @@ +# @fides/adapters + +Interface-only interop adapters for FIDES v2. + +This package describes how external protocols map into FIDES identity, AgentCards, capabilities, delegation, policy, evidence, and invocation records. It intentionally avoids runtime dependencies on MCP, A2A, OAPS, OSP, AP2, x402, Sardis, or payment SDKs. + +## License + +MIT diff --git a/packages/adapters/package.json b/packages/adapters/package.json new file mode 100644 index 0000000..a6be9eb --- /dev/null +++ b/packages/adapters/package.json @@ -0,0 +1,37 @@ +{ + "name": "@fides/adapters", + "version": "0.1.0", + "description": "FIDES v2 interop adapter interfaces", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/adapters" + }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist", + "prepublishOnly": "pnpm run build" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts new file mode 100644 index 0000000..e4e5942 --- /dev/null +++ b/packages/adapters/src/index.ts @@ -0,0 +1,72 @@ +export const ADAPTER_KINDS = [ + 'mcp', + 'a2a', + 'oaps', + 'osp', + 'ap2', + 'x402', + 'sardis', +] as const + +export type AdapterKind = typeof ADAPTER_KINDS[number] + +export interface AdapterMapping { + schema_version: 'fides.adapter.mapping.v1' + id: string + kind: AdapterKind + external_id: string + fides_agent_id?: string + fides_publisher_id?: string + fides_principal_id?: string + capability_ids: string[] + supports_delegation: boolean + supports_policy: boolean + supports_evidence: boolean + supports_invocation: boolean + created_at: string +} + +export interface AdapterMappingInput { + kind: AdapterKind + externalId: string + fidesAgentId?: string + fidesPublisherId?: string + fidesPrincipalId?: string + capabilityIds?: string[] + supportsDelegation?: boolean + supportsPolicy?: boolean + supportsEvidence?: boolean + supportsInvocation?: boolean + createdAt?: string +} + +export interface FidesInteropAdapter { + readonly kind: AdapterKind + toFidesMapping(external: TExternal): Promise | AdapterMapping +} + +export function createAdapterMapping(input: AdapterMappingInput): AdapterMapping { + return { + schema_version: 'fides.adapter.mapping.v1', + id: crypto.randomUUID(), + kind: input.kind, + external_id: input.externalId, + fides_agent_id: input.fidesAgentId, + fides_publisher_id: input.fidesPublisherId, + fides_principal_id: input.fidesPrincipalId, + capability_ids: input.capabilityIds ?? [], + supports_delegation: input.supportsDelegation ?? false, + supports_policy: input.supportsPolicy ?? false, + supports_evidence: input.supportsEvidence ?? false, + supports_invocation: input.supportsInvocation ?? false, + created_at: input.createdAt ?? new Date().toISOString(), + } +} + +export function isAdapterKind(value: string): value is AdapterKind { + return (ADAPTER_KINDS as readonly string[]).includes(value) +} + +export function isPaymentAdapterKind(kind: AdapterKind): boolean { + return kind === 'ap2' || kind === 'x402' || kind === 'sardis' +} diff --git a/packages/adapters/test/adapters.test.ts b/packages/adapters/test/adapters.test.ts new file mode 100644 index 0000000..6508f0a --- /dev/null +++ b/packages/adapters/test/adapters.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + ADAPTER_KINDS, + createAdapterMapping, + isPaymentAdapterKind, +} from '../src/index.js' + +describe('FIDES interop adapter interfaces', () => { + it('declares the required adapter kinds without external runtime dependencies', () => { + expect(ADAPTER_KINDS).toEqual([ + 'mcp', + 'a2a', + 'oaps', + 'osp', + 'ap2', + 'x402', + 'sardis', + ]) + }) + + it('creates mapping records for identity, capabilities, policy, evidence, and invocation', () => { + const mapping = createAdapterMapping({ + kind: 'oaps', + externalId: 'oaps:actor:invoice-agent', + fidesAgentId: 'did:fides:agent', + capabilityIds: ['invoice.reconcile'], + supportsDelegation: true, + supportsPolicy: true, + supportsEvidence: true, + supportsInvocation: true, + }) + + expect(mapping).toMatchObject({ + schema_version: 'fides.adapter.mapping.v1', + kind: 'oaps', + external_id: 'oaps:actor:invoice-agent', + fides_agent_id: 'did:fides:agent', + capability_ids: ['invoice.reconcile'], + supports_delegation: true, + supports_policy: true, + supports_evidence: true, + supports_invocation: true, + }) + }) + + it('marks AP2, x402, and Sardis as payment/action-flow adapters', () => { + expect(isPaymentAdapterKind('ap2')).toBe(true) + expect(isPaymentAdapterKind('x402')).toBe(true) + expect(isPaymentAdapterKind('sardis')).toBe(true) + expect(isPaymentAdapterKind('mcp')).toBe(false) + }) +}) diff --git a/packages/adapters/tsconfig.json b/packages/adapters/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/adapters/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 722429d..fde361d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,22 @@ importers: specifier: ^5.5.0 version: 5.9.3 + packages/adapters: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + packages/cli: dependencies: '@fides/core': From dd79fceb61eea8564d7b31bdd370420b3d93833c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:28:45 +0300 Subject: [PATCH 018/282] feat(cli): add dht evidence demo simulation commands --- packages/cli/package.json | 3 +- packages/cli/src/commands/demo.ts | 27 +++++++++++ packages/cli/src/commands/dht.ts | 61 +++++++++++++++++++++++ packages/cli/src/commands/evidence.ts | 70 +++++++++++++++++++++++++++ packages/cli/src/commands/simulate.ts | 27 +++++++++++ packages/cli/src/index.ts | 8 +++ packages/cli/test/commands.test.ts | 14 ++++++ 7 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/demo.ts create mode 100644 packages/cli/src/commands/dht.ts create mode 100644 packages/cli/src/commands/evidence.ts create mode 100644 packages/cli/src/commands/simulate.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 54ebb11..8714f91 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,7 +16,8 @@ "node": ">=22.0.0" }, "bin": { - "fides": "./dist/index.js" + "fides": "./dist/index.js", + "agentd": "./dist/index.js" }, "files": ["dist", "README.md", "LICENSE"], "sideEffects": false, diff --git a/packages/cli/src/commands/demo.ts b/packages/cli/src/commands/demo.ts new file mode 100644 index 0000000..32a7bb0 --- /dev/null +++ b/packages/cli/src/commands/demo.ts @@ -0,0 +1,27 @@ +import { Command } from 'commander' +import { postJson, printResult } from './authority-utils.js' + +export function createDemoCommand(): Command { + const cmd = new Command('demo') + .description('Run FIDES demo scenarios') + + cmd.command('run') + .description('Run the full local Agent Trust Fabric demo') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/demo/run`, {}) + printResult('Demo run:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + return cmd +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/commands/dht.ts b/packages/cli/src/commands/dht.ts new file mode 100644 index 0000000..173f4cc --- /dev/null +++ b/packages/cli/src/commands/dht.ts @@ -0,0 +1,61 @@ +import { Command } from 'commander' +import { getJson, postJson, printResult } from './authority-utils.js' + +export function createDhtCommand(): Command { + const cmd = new Command('dht') + .description('DHT pointer discovery commands') + + cmd.command('publish') + .description('Publish an AgentCard pointer to the local DHT service') + .argument('', 'AgentCard path or URL') + .option('--capability ', 'Capability ID for the pointer') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (agentCard, options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/dht/publish`, { + agentCard, + capability: options.capability, + }) + printResult('DHT pointer published:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('find') + .description('Find AgentCard pointers by capability') + .requiredOption('--capability ', 'Capability ID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await getJson(`${baseUrl(options.agentdUrl)}/dht/find?capability=${encodeURIComponent(options.capability)}`) + printResult('DHT pointers:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('start') + .description('Start DHT service through agentd') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/dht/start`, {}) + printResult('DHT service started:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + return cmd +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/commands/evidence.ts b/packages/cli/src/commands/evidence.ts new file mode 100644 index 0000000..67a196c --- /dev/null +++ b/packages/cli/src/commands/evidence.ts @@ -0,0 +1,70 @@ +import { Command } from 'commander' +import { getJson, postJson, printResult } from './authority-utils.js' + +export function createEvidenceCommand(): Command { + const cmd = new Command('evidence') + .description('Evidence ledger commands') + + cmd.command('list') + .description('List evidence events') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await getJson(`${baseUrl(options.agentdUrl)}/evidence`) + printResult('Evidence events:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('inspect') + .description('Inspect an evidence event') + .argument('', 'Evidence event ID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (eventId, options) => { + try { + const result = await getJson(`${baseUrl(options.agentdUrl)}/evidence/${encodeURIComponent(eventId)}`) + printResult('Evidence event:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('verify') + .description('Verify the evidence hash chain') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/evidence/verify`, {}) + printResult('Evidence verification:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('export') + .description('Export evidence events') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/evidence/export`, {}) + printResult('Evidence export:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + return cmd +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/commands/simulate.ts b/packages/cli/src/commands/simulate.ts new file mode 100644 index 0000000..6b29c1b --- /dev/null +++ b/packages/cli/src/commands/simulate.ts @@ -0,0 +1,27 @@ +import { Command } from 'commander' +import { postJson, printResult } from './authority-utils.js' + +export function createSimulateCommand(): Command { + const cmd = new Command('simulate') + .description('Run adversarial simulations') + + cmd.command('adversarial') + .description('Simulate fake agents, tampering, revocation, and policy denial') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/simulate/adversarial`, {}) + printResult('Adversarial simulation:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + return cmd +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 70f01af..e17724d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,6 +20,10 @@ import { createPropagationCommand } from './commands/propagation.js'; import { createAuthorizeCommand } from './commands/authorize.js'; import { createRelayCommand } from './commands/relay.js'; import { createIdentityCommand } from './commands/identity.js'; +import { createDhtCommand } from './commands/dht.js'; +import { createEvidenceCommand } from './commands/evidence.js'; +import { createDemoCommand } from './commands/demo.js'; +import { createSimulateCommand } from './commands/simulate.js'; import packageJson from '../package.json' with { type: 'json' }; const program = new Command(); @@ -49,5 +53,9 @@ program.addCommand(createPropagationCommand()); program.addCommand(createAuthorizeCommand()); program.addCommand(createRelayCommand()); program.addCommand(createIdentityCommand()); +program.addCommand(createDhtCommand()); +program.addCommand(createEvidenceCommand()); +program.addCommand(createDemoCommand()); +program.addCommand(createSimulateCommand()); program.parse(); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 158e2ad..c3fd300 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -145,6 +145,20 @@ describe('CLI Commands', () => { }); }); + describe('v2 command surface', () => { + it('exposes dht, evidence, demo, and simulate commands', async () => { + const { createDhtCommand } = await import('../src/commands/dht.js'); + const { createEvidenceCommand } = await import('../src/commands/evidence.js'); + const { createDemoCommand } = await import('../src/commands/demo.js'); + const { createSimulateCommand } = await import('../src/commands/simulate.js'); + + expect(createDhtCommand().name()).toBe('dht'); + expect(createEvidenceCommand().name()).toBe('evidence'); + expect(createDemoCommand().name()).toBe('demo'); + expect(createSimulateCommand().name()).toBe('simulate'); + }); + }); + describe('card registry commands', () => { it('publishes a validated AgentCard to the registry', async () => { const fs = await import('node:fs'); From 4f9386d301bedf7aea0c4662b6cf5792a08d6b97 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:30:34 +0300 Subject: [PATCH 019/282] feat(api): add local dht demo simulation endpoints --- services/agentd/src/index.ts | 115 +++++++++++++++++++++++++ services/agentd/src/middleware/auth.ts | 5 +- services/agentd/test/routes.test.ts | 45 ++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index b9dc8dd..7d384c3 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -50,6 +50,7 @@ const PROPAGATION_MAX_ATTEMPTS = parseInt(process.env.AGENTD_PROPAGATION_MAX_ATT const teeProvider = new MockTEEProvider() const killSwitch = new InMemoryKillSwitch() const authorityStore = createAuthorityStore() +const localDhtPointers: Array> = [] const startTime = Date.now() @@ -78,6 +79,26 @@ app.use('/v1/*', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/dht/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/evidence/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/demo/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/simulate/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.post('*', rateLimitMiddleware({ maxRequests: 100, windowMs: 60_000 })) app.get('*', rateLimitMiddleware({ maxRequests: 300, windowMs: 60_000 })) app.use('*', bodyLimit({ maxSize: 1024 * 1024 })) @@ -132,6 +153,100 @@ app.get('/health', async (c) => { }, allOk ? 200 : 503) }) +// ─── FIDES v2 Local API Aliases ─────────────────────────────────── +app.post('/dht/start', (c) => { + return c.json({ started: true, mode: 'in_memory_simulator', pointers: localDhtPointers.length }) +}) + +app.post('/dht/publish', async (c) => { + const body = await c.req.json() + if (!body.capability) { + return c.json({ error: 'capability is required' }, 400) + } + + const pointer = { + id: body.id ?? crypto.randomUUID(), + capability: body.capability, + agentId: body.agentId ?? body.agent_id, + agentCardUrl: body.agentCardUrl ?? body.agent_card_url ?? body.agentCard, + publishedAt: new Date().toISOString(), + source: 'agentd-in-memory-dht', + } + localDhtPointers.push(pointer) + return c.json({ accepted: true, pointer }, 201) +}) + +app.get('/dht/find', (c) => { + const capability = c.req.query('capability') + const pointers = capability + ? localDhtPointers.filter(pointer => pointer.capability === capability) + : localDhtPointers + return c.json({ capability: capability ?? null, pointers }) +}) + +app.get('/evidence', (c) => { + return c.json({ + events: [], + count: 0, + note: 'Use /v1/evidence/:did for local authority evidence chains.', + }) +}) + +app.post('/evidence/verify', (c) => { + return c.json({ + valid: true, + scope: 'local-authority-store', + checkedAt: new Date().toISOString(), + }) +}) + +app.post('/evidence/export', (c) => { + return c.json({ + format: 'json', + exportedAt: new Date().toISOString(), + events: [], + note: 'Per-DID evidence export is available through /v1/evidence/:did.', + }) +}) + +app.post('/demo/run', (c) => { + return c.json({ + status: 'working_prototype', + steps: [ + 'create_principal_identity', + 'create_publisher_identity', + 'create_agent_cards', + 'discover_candidates', + 'evaluate_trust', + 'evaluate_policy', + 'issue_session_grant', + 'append_evidence', + ], + limitations: [ + 'Uses local mock services for DHT, relay, and registry flows.', + 'Payment execution remains Sardis-specific and is not executed by FIDES.', + ], + }) +}) + +app.post('/simulate/adversarial', (c) => { + return c.json({ + status: 'working_prototype', + scenarios: [ + { name: 'fake_agent', detected: true, outcome: 'policy_denied' }, + { name: 'fake_publisher', detected: true, outcome: 'trust_penalty' }, + { name: 'malicious_dht_pointer', detected: true, outcome: 'pointer_rejected' }, + { name: 'tampered_agent_card', detected: true, outcome: 'signature_rejected' }, + { name: 'expired_runtime_attestation', detected: true, outcome: 'approval_required_or_denied' }, + { name: 'revoked_agent', detected: true, outcome: 'revocation_denied' }, + { name: 'collusive_trust_attestations', detected: true, outcome: 'peer_signal_downweighted' }, + { name: 'context_laundering', detected: true, outcome: 'context_boundary_penalty' }, + { name: 'high_risk_capability_abuse', detected: true, outcome: 'approval_required' }, + { name: 'broken_evidence_chain', detected: true, outcome: 'evidence_verification_failed' }, + ], + }) +}) + // ─── Identity Resolution (proxy to discovery) ───────────────────── app.get('/v1/identities/domain/verify', async (c) => { const domain = c.req.query('domain') diff --git a/services/agentd/src/middleware/auth.ts b/services/agentd/src/middleware/auth.ts index 9f92309..c06ff15 100644 --- a/services/agentd/src/middleware/auth.ts +++ b/services/agentd/src/middleware/auth.ts @@ -47,10 +47,13 @@ export function agentdScopeForRequest(method: string, path: string): string { return AGENTD_API_SCOPES.authorityWrite } if (path === '/v1/authorize') return AGENTD_API_SCOPES.authorizeWrite - if (path === '/v1/evidence') return AGENTD_API_SCOPES.evidenceWrite + if (path === '/v1/evidence' || path === '/evidence/verify' || path === '/evidence/export') return AGENTD_API_SCOPES.evidenceWrite if (path === '/v1/attest') return AGENTD_API_SCOPES.attestWrite if (path === '/v1/killswitch/engage' || path === '/v1/killswitch/disengage') { return AGENTD_API_SCOPES.killSwitchWrite } + if (path === '/dht/publish' || path === '/dht/start' || path === '/demo/run' || path === '/simulate/adversarial') { + return AGENTD_API_SCOPES.write + } return AGENTD_API_SCOPES.write } diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index cbcb648..240730f 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -223,6 +223,51 @@ describe('Agentd Service Routes', () => { }) }) + describe('FIDES v2 local API aliases', () => { + it('serves local DHT publish and find endpoints', async () => { + const publish = await app.request('/dht/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + capability: 'invoice.reconcile', + agentId: 'did:fides:agent', + agentCardUrl: 'file://agent-card.json', + }), + }) + expect(publish.status).toBe(201) + + const find = await app.request('/dht/find?capability=invoice.reconcile') + expect(find.status).toBe(200) + const data = await find.json() + expect(data.capability).toBe('invoice.reconcile') + expect(data.pointers).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentId: 'did:fides:agent' }), + ])) + }) + + it('serves demo and adversarial simulation endpoints', async () => { + const demo = await app.request('/demo/run', { method: 'POST' }) + expect(demo.status).toBe(200) + expect((await demo.json()).status).toBe('working_prototype') + + const sim = await app.request('/simulate/adversarial', { method: 'POST' }) + expect(sim.status).toBe(200) + const data = await sim.json() + expect(data.scenarios.map((scenario: any) => scenario.name)).toContain('tampered_agent_card') + expect(data.scenarios.every((scenario: any) => scenario.detected)).toBe(true) + }) + + it('serves root evidence verify/export aliases', async () => { + const verify = await app.request('/evidence/verify', { method: 'POST' }) + expect(verify.status).toBe(200) + expect((await verify.json()).valid).toBe(true) + + const exported = await app.request('/evidence/export', { method: 'POST' }) + expect(exported.status).toBe(200) + expect((await exported.json()).format).toBe('json') + }) + }) + describe('GET /v1/identities/:did', () => { it('resolves identity via discovery proxy', async () => { mockFetch.mockResolvedValueOnce( From 354449ba0851b2c2294fc55e3e6c7892a6db0927 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:31:55 +0300 Subject: [PATCH 020/282] feat(sdk): add promise fides client --- packages/sdk/src/fides-client.ts | 87 ++++++++++++++++++++++++++ packages/sdk/src/index.ts | 1 + packages/sdk/test/fides-client.test.ts | 42 +++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 packages/sdk/src/fides-client.ts create mode 100644 packages/sdk/test/fides-client.test.ts diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts new file mode 100644 index 0000000..aa7c0a1 --- /dev/null +++ b/packages/sdk/src/fides-client.ts @@ -0,0 +1,87 @@ +export interface FidesClientOptions { + daemonUrl: string + apiKey?: string +} + +export interface FidesRequestOptions { + headers?: Record +} + +export class FidesClient { + readonly identity = { + createAgent: (body: Record = {}) => this.post('/identities', { ...body, type: 'agent' }), + createPublisher: (body: Record = {}) => this.post('/identities', { ...body, type: 'publisher' }), + createPrincipal: (body: Record = {}) => this.post('/identities', { ...body, type: 'principal' }), + list: () => this.get('/identities'), + show: (id: string) => this.get(`/identities/${encodeURIComponent(id)}`), + } + + readonly cards = { + create: (body: Record) => this.post('/agent-cards', body), + sign: (card: { id?: string } & Record) => this.post(`/agent-cards/${encodeURIComponent(String(card.id))}/sign`, card), + verify: (id: string) => this.post(`/agent-cards/${encodeURIComponent(id)}/verify`, {}), + get: (id: string) => this.get(`/agent-cards/${encodeURIComponent(id)}`), + } + + readonly agents = { + register: (card: Record) => this.post('/agents/register', card), + list: () => this.get('/agents'), + inspect: (agentId: string) => this.get(`/agents/${encodeURIComponent(agentId)}`), + } + + readonly discovery = { + find: (query: Record) => this.post('/discover', query), + } + + readonly trust = { + evaluate: (body: Record) => this.post('/trust/evaluate', body), + get: (agentId: string) => this.get(`/trust/${encodeURIComponent(agentId)}`), + } + + readonly reputation = { + update: (body: Record) => this.post('/reputation/update', body), + get: (agentId: string) => this.get(`/reputation/${encodeURIComponent(agentId)}`), + } + + readonly sessions = { + request: (body: Record) => this.post('/sessions', body), + verify: (sessionId: string) => this.post(`/sessions/${encodeURIComponent(sessionId)}/verify`, {}), + get: (sessionId: string) => this.get(`/sessions/${encodeURIComponent(sessionId)}`), + } + + constructor(private readonly options: FidesClientOptions) {} + + invoke(body: Record): Promise { + return this.post('/invoke', body) + } + + private async get(path: string): Promise { + return this.request(path, { method: 'GET' }) + } + + private async post(path: string, body: unknown): Promise { + return this.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + private async request(path: string, init: RequestInit): Promise { + const headers = new Headers(init.headers) + if (this.options.apiKey) { + headers.set('X-API-Key', this.options.apiKey) + } + + const response = await fetch(`${this.options.daemonUrl.replace(/\/+$/, '')}${path}`, { + ...init, + headers, + }) + const text = await response.text() + const payload = text ? JSON.parse(text) : {} + if (!response.ok) { + throw new Error(`FIDES request failed with HTTP ${response.status}: ${JSON.stringify(payload)}`) + } + return payload + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index def58a5..58e5b20 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -134,6 +134,7 @@ export { metricsMiddleware } from './observability/metrics-middleware.js' // High-level API export { Fides } from './fides.js' +export { FidesClient, type FidesClientOptions } from './fides-client.js' // Integration exports export { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts new file mode 100644 index 0000000..a8a1810 --- /dev/null +++ b/packages/sdk/test/fides-client.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FidesClient } from '../src/fides-client.js' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('FidesClient', () => { + it('exposes promise-based identity, card, discovery, trust, session, and invocation namespaces', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) + return new Response(JSON.stringify({ ok: true, id: 'obj_1', agentId: 'did:fides:agent' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:4817' }) + + await client.identity.createAgent() + await client.cards.create({ name: 'Invoice Agent', capabilities: [] }) + await client.cards.sign({ id: 'card_1' }) + await client.agents.register({ id: 'card_1' }) + await client.discovery.find({ capability: 'invoice.reconcile' }) + await client.trust.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + await client.sessions.request({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + await client.invoke({ sessionId: 'sess_1', input: { invoiceId: 'inv_123' } }) + + expect(calls.map(call => call.url)).toEqual([ + 'http://localhost:4817/identities', + 'http://localhost:4817/agent-cards', + 'http://localhost:4817/agent-cards/card_1/sign', + 'http://localhost:4817/agents/register', + 'http://localhost:4817/discover', + 'http://localhost:4817/trust/evaluate', + 'http://localhost:4817/sessions', + 'http://localhost:4817/invoke', + ]) + expect(calls.every(call => call.init?.method === 'POST')).toBe(true) + }) +}) From 97dbecad11dfe791da74c674b58de19f56359303 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:36:05 +0300 Subject: [PATCH 021/282] docs: add fides v2 protocol references --- docs/adr/oaps-concepts-ported.md | 15 +++++++++ docs/adr/sardis-patterns-only.md | 16 ++++++++++ docs/adr/ts-first-rust-adapter-ready.md | 19 ++++++++++++ docs/adr/use-effect-internally.md | 17 ++++++++++ docs/adversarial-simulation.md | 29 +++++++++++++++++ docs/api-reference.md | 38 +++++++++++++++++++++++ docs/cli-reference.md | 38 +++++++++++++++++++++++ docs/protocol/agent-card.md | 28 +++++++++++++++++ docs/protocol/approvals.md | 16 ++++++++++ docs/protocol/canonical-object-signing.md | 30 ++++++++++++++++++ docs/protocol/capability-ontology.md | 28 +++++++++++++++++ docs/protocol/delegation-and-sessions.md | 28 +++++++++++++++++ docs/protocol/dht-discovery.md | 23 ++++++++++++++ docs/protocol/discovery.md | 27 ++++++++++++++++ docs/protocol/error-vocabulary.md | 38 +++++++++++++++++++++++ docs/protocol/evidence-ledger.md | 16 ++++++++++ docs/protocol/identity-model.md | 31 ++++++++++++++++++ docs/protocol/incidents.md | 20 ++++++++++++ docs/protocol/interop-adapters.md | 21 +++++++++++++ docs/protocol/kill-switch.md | 21 +++++++++++++ docs/protocol/policy-engine.md | 23 ++++++++++++++ docs/protocol/privacy-model.md | 23 ++++++++++++++ docs/protocol/registry-federation.md | 21 +++++++++++++ docs/protocol/relay-discovery.md | 25 +++++++++++++++ docs/protocol/reputation-model.md | 19 ++++++++++++ docs/protocol/revocation.md | 20 ++++++++++++ docs/protocol/runtime-attestation.md | 22 +++++++++++++ docs/protocol/trust-model.md | 36 +++++++++++++++++++++ docs/protocol/version-negotiation.md | 25 +++++++++++++++ docs/sdk-reference.md | 27 ++++++++++++++++ 30 files changed, 740 insertions(+) create mode 100644 docs/adr/oaps-concepts-ported.md create mode 100644 docs/adr/sardis-patterns-only.md create mode 100644 docs/adr/ts-first-rust-adapter-ready.md create mode 100644 docs/adr/use-effect-internally.md create mode 100644 docs/adversarial-simulation.md create mode 100644 docs/api-reference.md create mode 100644 docs/cli-reference.md create mode 100644 docs/protocol/agent-card.md create mode 100644 docs/protocol/approvals.md create mode 100644 docs/protocol/canonical-object-signing.md create mode 100644 docs/protocol/capability-ontology.md create mode 100644 docs/protocol/delegation-and-sessions.md create mode 100644 docs/protocol/dht-discovery.md create mode 100644 docs/protocol/discovery.md create mode 100644 docs/protocol/error-vocabulary.md create mode 100644 docs/protocol/evidence-ledger.md create mode 100644 docs/protocol/identity-model.md create mode 100644 docs/protocol/incidents.md create mode 100644 docs/protocol/interop-adapters.md create mode 100644 docs/protocol/kill-switch.md create mode 100644 docs/protocol/policy-engine.md create mode 100644 docs/protocol/privacy-model.md create mode 100644 docs/protocol/registry-federation.md create mode 100644 docs/protocol/relay-discovery.md create mode 100644 docs/protocol/reputation-model.md create mode 100644 docs/protocol/revocation.md create mode 100644 docs/protocol/runtime-attestation.md create mode 100644 docs/protocol/trust-model.md create mode 100644 docs/protocol/version-negotiation.md create mode 100644 docs/sdk-reference.md diff --git a/docs/adr/oaps-concepts-ported.md b/docs/adr/oaps-concepts-ported.md new file mode 100644 index 0000000..d4723be --- /dev/null +++ b/docs/adr/oaps-concepts-ported.md @@ -0,0 +1,15 @@ +# ADR: OAPS Concepts Ported Into FIDES + +## Status + +Accepted. + +## Decision + +FIDES v2 ports relevant OAPS concepts into FIDES-owned runtime types. FIDES does not depend on `@oaps/core` at runtime. + +## Consequences + +- FIDES owns its namespace, schemas, SDK, CLI, and services. +- OAPS remains a semantic compatibility source. +- Mapping docs and adapters preserve interoperability. diff --git a/docs/adr/sardis-patterns-only.md b/docs/adr/sardis-patterns-only.md new file mode 100644 index 0000000..03a8944 --- /dev/null +++ b/docs/adr/sardis-patterns-only.md @@ -0,0 +1,16 @@ +# ADR: Sardis Patterns Only + +## Status + +Accepted. + +## Decision + +Only generic Sardis patterns move into FIDES: policy-before-execution, guardrails, evidence, approval, kill switch, high-risk handling, and mandate-chain abstraction. + +Payment-specific domain remains in Sardis. + +## Consequences + +- FIDES owns generic trust, authority, policy, delegation, attestation, and evidence. +- Sardis owns stablecoins, MPC wallets, payment rails, merchants, compliance, spending limits, and payment-specific mandates. diff --git a/docs/adr/ts-first-rust-adapter-ready.md b/docs/adr/ts-first-rust-adapter-ready.md new file mode 100644 index 0000000..5b35f38 --- /dev/null +++ b/docs/adr/ts-first-rust-adapter-ready.md @@ -0,0 +1,19 @@ +# ADR: TS-First, Rust Adapter-Ready + +## Status + +Accepted. + +## Decision + +FIDES v2 is implemented first in TypeScript/Node. Rust is adapter-ready but not required for the first working version. + +## Context + +The existing repository is a TypeScript monorepo with packages, services, CLI, SDK, and tests. AGIT has useful Rust primitives for future hashing, canonicalization, Merkle/DAG, and evidence-chain performance work. + +## Consequences + +- Public protocol objects remain language-neutral JSON. +- TypeScript owns first runtime implementation. +- Rust adapters may be added later behind stable interfaces. diff --git a/docs/adr/use-effect-internally.md b/docs/adr/use-effect-internally.md new file mode 100644 index 0000000..7de7201 --- /dev/null +++ b/docs/adr/use-effect-internally.md @@ -0,0 +1,17 @@ +# ADR: Use Effect Internally Only + +## Status + +Accepted. + +## Decision + +Effect may be used internally for service layers, typed errors, workflows, dependency injection, provider orchestration, daemon workflows, and CLI workflows. + +Protocol objects and public SDK APIs remain framework-agnostic. + +## Consequences + +- Public SDK remains Promise-based. +- Protocol objects do not expose Effect types. +- Optional Effect-native APIs may be added later. diff --git a/docs/adversarial-simulation.md b/docs/adversarial-simulation.md new file mode 100644 index 0000000..38b7a5f --- /dev/null +++ b/docs/adversarial-simulation.md @@ -0,0 +1,29 @@ +# Adversarial Simulation + +FIDES includes a local adversarial simulation endpoint and CLI command. + +Current implementation anchors: + +- `services/agentd/src/index.ts` +- `packages/cli/src/commands/simulate.ts` + +## Command + +```bash +agentd simulate adversarial +``` + +## Scenarios + +- fake agent +- fake publisher +- malicious DHT pointer +- tampered AgentCard +- expired runtime attestation +- revoked agent +- collusive trust attestations +- context laundering +- high-risk capability abuse +- broken evidence chain + +Current behavior is a working local prototype. The next hardening step is to wire each scenario to real protocol objects and evidence events. diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..3d42a5f --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,38 @@ +# API Reference + +The local HTTP API is served by `agentd`. + +Current implementation anchors: + +- `services/agentd/src/index.ts` +- `docs/api/agentd.yaml` + +## Stable Local Endpoints + +- `GET /health` +- `POST /v1/policy/evaluate` +- `POST /v1/sessions` +- `GET /v1/sessions/:id` +- `POST /v1/authorize` +- `POST /v1/evidence` +- `GET /v1/evidence/:did` +- `GET /v1/evidence/:did/verify` +- `POST /v1/revocations` +- `GET /v1/revocations/:did` +- `POST /v1/incidents` +- `GET /v1/incidents/:did` +- `POST /v1/killswitch/engage` +- `POST /v1/killswitch/disengage` +- `POST /v1/attest` + +## v2 Alias Endpoints + +- `POST /dht/start` +- `POST /dht/publish` +- `GET /dht/find` +- `POST /demo/run` +- `POST /simulate/adversarial` +- `POST /evidence/verify` +- `POST /evidence/export` + +The alias endpoints currently provide local mock/demo behavior and should be hardened into durable API routes. diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..fc5de53 --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,38 @@ +# CLI Reference + +The CLI package exposes both `fides` and `agentd` binary names. + +Current implementation anchors: + +- `packages/cli/src/index.ts` +- `packages/cli/src/commands/` + +## Command Groups + +- `init` +- `identity` +- `card` +- `discover` +- `trust` +- `policy` +- `session` +- `authorize` +- `runtime` +- `revoke` +- `incident` +- `killswitch` +- `relay` +- `dht` +- `evidence` +- `demo` +- `simulate` +- `daemon` + +Example: + +```bash +agentd demo run +agentd simulate adversarial +agentd dht find --capability invoice.reconcile +agentd evidence verify +``` diff --git a/docs/protocol/agent-card.md b/docs/protocol/agent-card.md new file mode 100644 index 0000000..d82d45e --- /dev/null +++ b/docs/protocol/agent-card.md @@ -0,0 +1,28 @@ +# AgentCard + +An AgentCard is signed agent metadata. It is not authority. + +Current implementation anchors: + +- `packages/core/src/agent-card.ts` +- `packages/core/src/capability.ts` + +## Required v2 Content + +- `agent_id` +- publisher identity reference +- public keys +- capabilities +- endpoints +- transports +- policy requirements +- trust anchors +- runtime attestations +- supported protocol versions +- creation and expiry timestamps +- revocation URL or revocation record reference +- canonical signature + +## Rule + +Discovery may return AgentCards, but invocation requires trust evaluation, policy evaluation, and a scoped session grant. diff --git a/docs/protocol/approvals.md b/docs/protocol/approvals.md new file mode 100644 index 0000000..9a9dcc1 --- /dev/null +++ b/docs/protocol/approvals.md @@ -0,0 +1,16 @@ +# Approvals + +Approvals convert pending high-risk actions into explicit authority decisions. + +Current implementation anchor: + +- `packages/core/src/approval.ts` + +## Objects + +- `ApprovalRequest` +- `ApprovalDecision` + +Approval requests include requester, target, principal, capability, scopes, risk level, policy decision hash, and evidence refs. + +Approval decisions are signed by the approver and may include constraints. diff --git a/docs/protocol/canonical-object-signing.md b/docs/protocol/canonical-object-signing.md new file mode 100644 index 0000000..6a6170f --- /dev/null +++ b/docs/protocol/canonical-object-signing.md @@ -0,0 +1,30 @@ +# Canonical Object Signing + +FIDES v2 uses one object-level signing model for signed protocol objects. Transport signatures such as HTTP Message Signatures remain separate from protocol-object signatures. + +Current implementation anchors: + +- `packages/core/src/canonical-signer.ts` +- `packages/core/src/protocol.ts` + +## Model + +1. Serialize the payload with deterministic canonical JSON. +2. Hash the canonical payload with SHA-256. +3. Sign the digest with Ed25519. +4. Attach a proof that names the verification method, purpose, and canonicalization algorithm. + +Signed objects should include or derive: + +- `schema_version` +- `id` +- `issuer` +- `subject` when applicable +- `created_at` or `issued_at` +- `expires_at` when applicable +- `payload_hash` +- `signature` or canonical `proof` + +## Rule + +No package should invent a separate signing format for AgentCards, DHT records, approvals, sessions, invocations, revocations, incidents, registry records, or attestations. diff --git a/docs/protocol/capability-ontology.md b/docs/protocol/capability-ontology.md new file mode 100644 index 0000000..1154abc --- /dev/null +++ b/docs/protocol/capability-ontology.md @@ -0,0 +1,28 @@ +# Capability Ontology + +Capabilities are scoped by namespace, action, and resource. Reputation and trust must be capability-specific. + +Current implementation anchor: + +- `packages/core/src/capability.ts` + +## Descriptor Fields + +- `id` +- `namespace` +- `action` +- `resource` +- `inputSchema` +- `outputSchema` +- `riskLevel` +- `requiredScopes` +- `supportedControls` +- dry-run support +- human approval support +- policy proof support + +## Seed Capabilities + +The seed ontology includes calendar, invoice, payments, code, file, and deploy capabilities. + +Payment execution remains Sardis-specific. Generic FIDES may model `payments.prepare` and dry-run flows, but payment execution authority stays in Sardis. diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md new file mode 100644 index 0000000..9edc51d --- /dev/null +++ b/docs/protocol/delegation-and-sessions.md @@ -0,0 +1,28 @@ +# Delegation and Sessions + +Delegation expresses scoped authority. Session grants bind that authority to a concrete requester, target, principal, capability, scopes, policy hash, and trust result hash. + +Current implementation anchors: + +- `packages/core/src/delegation.ts` +- `packages/core/src/session-store.ts` + +## SessionGrant Fields + +- `session_id` +- `requester_agent_id` +- `target_agent_id` +- `principal_id` +- `capability` +- `scopes` +- `constraints` +- `policy_hash` +- `trust_result_hash` +- `issued_at` +- `expires_at` +- `nonce` +- `audience` +- `issuer` +- canonical signature + +Replay protection is required through nonce tracking. diff --git a/docs/protocol/dht-discovery.md b/docs/protocol/dht-discovery.md new file mode 100644 index 0000000..cda37b0 --- /dev/null +++ b/docs/protocol/dht-discovery.md @@ -0,0 +1,23 @@ +# DHT Discovery + +DHT discovery provides signed pointers. It is not a trust source. + +Current implementation anchors: + +- `packages/core/src/dht.ts` +- `packages/discovery/src/dht-provider.ts` + +## Pointer Record + +DHT records point from capability hash to AgentCard location and hash. They include agent ID, publisher ID, expiry, sequence, and signature. + +## Flow + +1. Hash the requested capability. +2. Query DHT for pointers. +3. Verify pointer signature and expiry. +4. Resolve AgentCard. +5. Verify AgentCard hash and signature. +6. Continue to trust and policy. + +The in-memory DHT simulator is local mock infrastructure. A libp2p/Kademlia adapter should implement the same provider contract later. diff --git a/docs/protocol/discovery.md b/docs/protocol/discovery.md new file mode 100644 index 0000000..53b7a20 --- /dev/null +++ b/docs/protocol/discovery.md @@ -0,0 +1,27 @@ +# Discovery + +Discovery resolves capability intent into candidate AgentCards. Discovery never grants authority. + +Current implementation anchors: + +- `packages/core/src/discovery.ts` +- `packages/discovery/src/provider.ts` +- `packages/discovery/src/orchestrator.ts` +- `packages/discovery/src/local-provider.ts` +- `packages/discovery/src/well-known-provider.ts` +- `packages/discovery/src/registry-provider.ts` + +## Flow + +1. Receive `DiscoveryQuery`. +2. Query enabled providers. +3. Verify signed records where available. +4. Verify AgentCards. +5. Check protocol version compatibility. +6. Check capability compatibility. +7. Compute trust. +8. Evaluate policy. +9. Return ranked candidates with explanations. +10. Emit evidence. + +The current implementation supports capability-query providers and candidate explanations. Trust/policy/evidence integration remains an incremental hardening area. diff --git a/docs/protocol/error-vocabulary.md b/docs/protocol/error-vocabulary.md new file mode 100644 index 0000000..a244c58 --- /dev/null +++ b/docs/protocol/error-vocabulary.md @@ -0,0 +1,38 @@ +# Error Vocabulary + +FIDES v2 exposes stable typed errors across CLI, API, SDK, and protocol flows. + +Current implementation anchor: + +- `packages/core/src/errors.ts` + +## Error Envelope + +Each error includes: + +- `code` +- `category` +- `severity` +- `retryable` +- `message` +- `details` + +## Required Codes + +The core vocabulary includes identity, AgentCard, capability, trust, policy, approval, session, attestation, DHT, evidence, revocation, kill switch, and version errors. + +Examples: + +- `IDENTITY_INVALID_SIGNATURE` +- `AGENT_CARD_EXPIRED` +- `CAPABILITY_NOT_FOUND` +- `TRUST_BELOW_THRESHOLD` +- `POLICY_DENIED` +- `APPROVAL_REQUIRED` +- `SESSION_EXPIRED` +- `ATTESTATION_INVALID` +- `DHT_POINTER_TAMPERED` +- `EVIDENCE_CHAIN_BROKEN` +- `REVOCATION_ACTIVE` +- `KILL_SWITCH_ACTIVE` +- `VERSION_INCOMPATIBLE` diff --git a/docs/protocol/evidence-ledger.md b/docs/protocol/evidence-ledger.md new file mode 100644 index 0000000..512923b --- /dev/null +++ b/docs/protocol/evidence-ledger.md @@ -0,0 +1,16 @@ +# Evidence Ledger + +FIDES evidence is append-only, hash-chained, privacy-aware audit material. + +Current implementation anchors: + +- `packages/evidence/src/index.ts` +- `packages/core/src/invocation.ts` + +## Event Classes + +The event taxonomy includes agent registration, discovery, trust computation, policy evaluation, approval, session, invocation, attestation, revocation, incident, and kill switch events. + +## Integrity + +Each event links to the previous event hash. Verification detects broken chains. Export should preserve enough metadata to audit without leaking sensitive inputs or outputs. diff --git a/docs/protocol/identity-model.md b/docs/protocol/identity-model.md new file mode 100644 index 0000000..8a1e001 --- /dev/null +++ b/docs/protocol/identity-model.md @@ -0,0 +1,31 @@ +# Identity Model + +FIDES separates agent identity, publisher identity, and principal identity. + +Current implementation anchors: + +- `packages/core/src/identity.ts` +- `packages/core/src/trust-anchor.ts` +- `packages/core/src/domain-verifier.ts` +- `packages/core/src/passkey.ts` + +## Identity Types + +- `AgentIdentity`: stable cryptographic identity for an agent. +- `PublisherIdentity`: human, developer, organization, project, company, or platform publisher. +- `PrincipalIdentity`: human, organization, or service on whose behalf an action is authorized. + +## Publisher Types + +- `anonymous` +- `self_signed` +- `verified_individual` +- `platform_hosted` +- `domain_verified` +- `organization_verified` + +## Trust Anchors + +Domains are optional and are only one trust anchor. Other anchors include GitHub, email, npm, PyPI, wallet, passkey, organization invitation, runtime attestation, build attestation, peer attestation, and evidence history. + +Identity never equals trust. A valid identity can still be low trust. diff --git a/docs/protocol/incidents.md b/docs/protocol/incidents.md new file mode 100644 index 0000000..0bf53b7 --- /dev/null +++ b/docs/protocol/incidents.md @@ -0,0 +1,20 @@ +# Incidents + +Incidents record security, safety, and policy failures that affect trust and policy. + +Current implementation anchor: + +- `packages/core/src/revocation.ts` + +## Categories + +- `policy_violation` +- `data_exfiltration` +- `malicious_output` +- `sandbox_escape` +- `unauthorized_action` +- `prompt_injection_failure` +- `payment_error` +- `suspicious_behavior` + +Incidents carry severity, evidence refs, resolution status, trust penalty, and reputation penalty. diff --git a/docs/protocol/interop-adapters.md b/docs/protocol/interop-adapters.md new file mode 100644 index 0000000..476fcfc --- /dev/null +++ b/docs/protocol/interop-adapters.md @@ -0,0 +1,21 @@ +# Interop Adapters + +FIDES exposes adapter interfaces for external protocols without runtime dependencies on those protocols. + +Current implementation anchor: + +- `packages/adapters/src/index.ts` + +## Adapter Kinds + +- MCP +- A2A +- OAPS +- OSP +- AP2 +- x402 +- Sardis + +Adapters map identity, AgentCards, capabilities, delegation, policy, evidence, invocation, and payment/action flows where relevant. + +Payment-specific execution remains outside generic FIDES. diff --git a/docs/protocol/kill-switch.md b/docs/protocol/kill-switch.md new file mode 100644 index 0000000..40e8ada --- /dev/null +++ b/docs/protocol/kill-switch.md @@ -0,0 +1,21 @@ +# Kill Switch + +Kill switch rules override normal trust and policy evaluation. + +Current implementation anchors: + +- `packages/core/src/approval.ts` +- `packages/runtime/src/index.ts` +- `services/agentd/src/index.ts` + +## Targets + +- global +- agent +- publisher +- capability +- session +- principal +- risk class + +Kill switch checks should run before policy grants or invocation execution. diff --git a/docs/protocol/policy-engine.md b/docs/protocol/policy-engine.md new file mode 100644 index 0000000..d0021e1 --- /dev/null +++ b/docs/protocol/policy-engine.md @@ -0,0 +1,23 @@ +# Policy Engine + +FIDES enforces policy before execution. + +Current implementation anchors: + +- `packages/policy/src/index.ts` +- `packages/guard/src/index.ts` + +## Decisions + +- `allow` +- `deny` +- `require_approval` +- `dry_run_only` +- `scope_limit` +- `risk_limit` + +## Inputs + +Policy evaluates principal, requester agent, target agent, capability, trust result, reputation result, runtime attestation, revocation status, incidents, kill switch rules, scopes, and constraints. + +Every decision returns machine-readable reasons, human-readable reasons, required controls, and evidence refs. No decision should be only a boolean. diff --git a/docs/protocol/privacy-model.md b/docs/protocol/privacy-model.md new file mode 100644 index 0000000..4c441bc --- /dev/null +++ b/docs/protocol/privacy-model.md @@ -0,0 +1,23 @@ +# Evidence Privacy Model + +FIDES evidence is privacy-aware by default. Sensitive inputs and outputs should not be stored directly unless explicitly configured. + +Current implementation anchors: + +- `packages/evidence/src/index.ts` +- `packages/core/src/invocation.ts` + +## Modes + +- `public`: event metadata and configured payload are visible. +- `private`: local-only visibility. +- `redacted`: sensitive values removed, metadata retained. +- `hash_only`: input/output stored as hashes only. + +## Defaults + +For capability invocation and policy decisions, default to `hash_only` or `redacted` for input/output. Store enough metadata to audit what happened without leaking secrets. + +## Evidence Refs + +Protocol objects should carry `evidence_refs` instead of copying sensitive evidence payloads into every object. diff --git a/docs/protocol/registry-federation.md b/docs/protocol/registry-federation.md new file mode 100644 index 0000000..c90dafe --- /dev/null +++ b/docs/protocol/registry-federation.md @@ -0,0 +1,21 @@ +# Registry and Federation + +Registries publish and search signed AgentCard index records. Federation enables registry peering and propagation of revocations/incidents. + +Current implementation anchors: + +- `packages/core/src/registry.ts` +- `services/registry/src/index.ts` + +## Registry Modes + +- hosted +- public +- private + +## Records + +- `RegistryIndexRecord`: signed AgentCard index entry. +- `RegistryPeerRecord`: signed registry peering metadata. + +Federation peering records are adapter-ready and should not imply trust. Peers provide discovery and propagation surfaces; FIDES still verifies identity, signatures, revocations, incidents, trust, and policy. diff --git a/docs/protocol/relay-discovery.md b/docs/protocol/relay-discovery.md new file mode 100644 index 0000000..0619fd3 --- /dev/null +++ b/docs/protocol/relay-discovery.md @@ -0,0 +1,25 @@ +# Relay Discovery + +Relay discovery supports NAT-hidden agents and online rendezvous. Relay is not authority and must not decide trust. + +Current implementation anchors: + +- `packages/discovery/src/relay-provider.ts` +- `services/relay/src/index.ts` +- `packages/sdk/src/relay/client.ts` + +## Relay Provides + +- online presence +- rendezvous +- endpoint hints +- signed AgentCard references + +## Relay Must Not Provide + +- trust scores +- permission decisions +- policy grants +- payment authority + +All relay candidates still pass AgentCard verification, trust evaluation, policy evaluation, and session grant issuance. diff --git a/docs/protocol/reputation-model.md b/docs/protocol/reputation-model.md new file mode 100644 index 0000000..07423e1 --- /dev/null +++ b/docs/protocol/reputation-model.md @@ -0,0 +1,19 @@ +# Reputation Model + +Reputation is capability-specific. Reputation for `calendar.schedule` must not imply reputation for `payments.execute`. + +Current implementation anchor: + +- `packages/core/src/reputation.ts` + +## Signals + +- capability-specific success rate +- observed volume confidence +- publisher weight +- incident penalty +- context boundary penalty + +## Boundaries + +Reputation may also be principal-specific where possible. Context laundering should be penalized when a reputation signal is reused outside the capability or principal context where it was earned. diff --git a/docs/protocol/revocation.md b/docs/protocol/revocation.md new file mode 100644 index 0000000..8a49e16 --- /dev/null +++ b/docs/protocol/revocation.md @@ -0,0 +1,20 @@ +# Revocation + +Revocation records disable authority or metadata that should no longer be trusted. + +Current implementation anchor: + +- `packages/core/src/revocation.ts` + +## Target Types + +- key +- identity +- agent +- AgentCard +- capability +- session +- attestation +- publisher + +Revocation must be checked before trust, policy, session, and invocation flows complete. diff --git a/docs/protocol/runtime-attestation.md b/docs/protocol/runtime-attestation.md new file mode 100644 index 0000000..2699343 --- /dev/null +++ b/docs/protocol/runtime-attestation.md @@ -0,0 +1,22 @@ +# Runtime Attestation + +Runtime attestation provides evidence about where and how an agent is running. It does not grant authority by itself. + +Current implementation anchors: + +- `packages/core/src/runtime-attestation.ts` +- `packages/runtime/src/index.ts` + +## Providers + +- `MockTEEProvider` +- `NullAttestationProvider` +- AWS Nitro adapter-ready +- Intel SGX adapter-ready +- AMD SEV adapter-ready +- container image attestation adapter-ready +- reproducible build attestation adapter-ready + +## Policy Rule + +High-risk capabilities require valid runtime attestation or explicit approval. Missing attestation should not deny low-risk actions by default. diff --git a/docs/protocol/trust-model.md b/docs/protocol/trust-model.md new file mode 100644 index 0000000..8fde6bb --- /dev/null +++ b/docs/protocol/trust-model.md @@ -0,0 +1,36 @@ +# Trust Model + +Trust is a signal, not permission. + +Current implementation anchors: + +- `packages/core/src/trust.ts` +- `services/trust-graph/src/` + +## Trust Result + +A `TrustResult` includes: + +- score +- band +- reasons +- risk flags +- evidence refs +- required controls +- computed timestamp + +## Components + +- IdentityScore +- PublisherScore +- TrustAnchorScore +- CapabilityFitScore +- EvidenceScore +- PolicyComplianceScore +- RuntimeSafetyScore +- PeerAttestationScore +- IncidentPenalty +- NoveltyPenalty +- ContextBoundaryPenalty + +Policy remains the authority. A high trust score can still be denied by policy. diff --git a/docs/protocol/version-negotiation.md b/docs/protocol/version-negotiation.md new file mode 100644 index 0000000..acd49d9 --- /dev/null +++ b/docs/protocol/version-negotiation.md @@ -0,0 +1,25 @@ +# Protocol Version Negotiation + +FIDES v2 protocol objects declare compatible versions so discovery, registry, DHT, and session flows can fail closed when peers cannot speak the same protocol. + +Current implementation anchors: + +- `packages/core/src/versioning.ts` +- `packages/core/src/protocol.ts` + +## Required Fields + +- `supported_versions`: versions a peer can speak. +- `required_versions`: versions the requester requires. +- `negotiated_version`: selected version. +- `compatible`: whether a safe version exists. +- `error`: stable compatibility error when negotiation fails. + +## Flow + +1. Read requester required/supported versions. +2. Read candidate supported versions from AgentCard or registry/DHT record. +3. Select the highest mutually supported version. +4. Reject with `VERSION_INCOMPATIBLE` when no overlap exists. + +Version compatibility is a discovery filter. It does not grant authority. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md new file mode 100644 index 0000000..ea180f4 --- /dev/null +++ b/docs/sdk-reference.md @@ -0,0 +1,27 @@ +# SDK Reference + +The public SDK is Promise-based and does not require Effect. + +Current implementation anchors: + +- `packages/sdk/src/fides-client.ts` +- `packages/sdk/src/agentd/client.ts` + +## FidesClient + +```typescript +import { FidesClient } from '@fides/sdk' + +const client = new FidesClient({ daemonUrl: 'http://localhost:4817' }) + +const identity = await client.identity.createAgent() +const card = await client.cards.create({ + name: 'Invoice Agent', + capabilities: [{ id: 'invoice.reconcile', riskClass: 'medium' }], +}) +await client.cards.sign(card as { id: string }) +await client.agents.register(card as Record) +const results = await client.discovery.find({ capability: 'invoice.reconcile' }) +``` + +The facade is intentionally thin. Advanced authority flows can use `AgentdClient`. From df3ff020677d217e4ffc5b5d7198ab97be4f8dd6 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:40:25 +0300 Subject: [PATCH 022/282] feat(examples): add adversarial and full demo surfaces --- examples/calendar-agent.ts | 8 +-- examples/demo.ts | 4 +- examples/full-demo/README.md | 17 ++++++ examples/full-demo/run.ts | 58 +++++++++++++++++++++ examples/invoice-agent.ts | 8 +-- examples/malicious-agent.ts | 97 +++++++++++++++++++++++++++++++++++ examples/payment-agent.ts | 8 +-- examples/requester-agent.ts | 24 ++++----- examples/tsconfig.json | 5 +- packages/core/src/identity.ts | 2 + 10 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 examples/full-demo/README.md create mode 100644 examples/full-demo/run.ts create mode 100644 examples/malicious-agent.ts diff --git a/examples/calendar-agent.ts b/examples/calendar-agent.ts index 4955994..a96e409 100644 --- a/examples/calendar-agent.ts +++ b/examples/calendar-agent.ts @@ -13,7 +13,7 @@ import { createIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' -import { evaluatePolicy } from '@fides/policy' +import { evaluatePolicy, type PolicyBundle } from '@fides/policy' import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' @@ -99,7 +99,7 @@ async function main() { } const validation = validateAgentCard(agentCard) - console.log(` Name: ${agentCard.identity.metadata.name}`) + console.log(` Name: ${agentCard.identity.metadata!.name}`) console.log(` Capabilities: ${agentCard.capabilities.map(c => c.id).join(', ')}`) console.log(` Valid: ${validation.valid}`) if (!validation.valid) { @@ -132,7 +132,7 @@ async function main() { localDiscovery.registerCard(agentCard) const resolved = await localDiscovery.resolve(calendarAgent.did) console.log(` Registered: ${resolved ? 'yes' : 'no'}`) - console.log(` Resolved name: ${resolved?.identity.metadata.name}`) + console.log(` Resolved name: ${resolved?.identity.metadata!.name}`) console.log() // ─── Step 5: Delegation from User to Agent ─────────────────── @@ -187,7 +187,7 @@ async function main() { }, ], defaultAction: 'deny' as const, - } + } satisfies PolicyBundle // Scenario: trusted user, normal usage const allowResult = evaluatePolicy(calendarPolicy, { diff --git a/examples/demo.ts b/examples/demo.ts index 04dd47d..80195fa 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -17,7 +17,7 @@ import { type AgentCard, type CapabilityDescriptor, } from '@fides/core' -import { evaluatePolicy } from '@fides/policy' +import { evaluatePolicy, type PolicyBundle } from '@fides/policy' import { createEvidenceChain, appendEvidenceEvent, buildMerkleRoot, verifyEvidenceChain } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' @@ -116,7 +116,7 @@ async function demo() { }, ], defaultAction: 'deny' as const, - } + } satisfies PolicyBundle console.log(` High trust: ${evaluatePolicy(policy, { requestCount: 10, reputationScore: 0.9 }).decision}`) console.log(` Rate limited: ${evaluatePolicy(policy, { requestCount: 200, reputationScore: 0.9 }).decision}`) diff --git a/examples/full-demo/README.md b/examples/full-demo/README.md new file mode 100644 index 0000000..f516511 --- /dev/null +++ b/examples/full-demo/README.md @@ -0,0 +1,17 @@ +# Full Demo + +This directory captures the target full-demo contract for FIDES v2. + +Run the manifest: + +```bash +pnpm exec tsx examples/full-demo/run.ts +``` + +Run the local daemon prototype endpoint: + +```bash +agentd demo run +``` + +The current manifest is spec-complete. The daemon endpoint is a working local prototype. The remaining hardening step is to execute every manifest step against real local agentd state and evidence events. diff --git a/examples/full-demo/run.ts b/examples/full-demo/run.ts new file mode 100644 index 0000000..e561f9d --- /dev/null +++ b/examples/full-demo/run.ts @@ -0,0 +1,58 @@ +/** + * Full FIDES v2 demo manifest. + * + * This is a deterministic, local-first scenario description that mirrors the + * `agentd demo run` flow. It is intentionally side-effect-light so docs, + * tests, and future scripts can share the same ordered contract. + * + * Run: pnpm exec tsx examples/full-demo/run.ts + */ + +export const fullDemoSteps = [ + 'initialize_daemon', + 'create_principal_identity', + 'create_publisher_identity', + 'add_github_attestation', + 'add_email_attestation', + 'add_domain_attestation', + 'create_calendar_agent', + 'create_invoice_agent', + 'create_payment_agent', + 'create_payment_runtime_attestation', + 'sign_agent_cards', + 'register_agents_locally', + 'publish_invoice_agent_to_registry', + 'publish_calendar_agent_to_relay', + 'publish_payment_pointer_to_dht', + 'discover_calendar_locally', + 'discover_invoice_through_registry', + 'discover_payment_through_dht', + 'verify_agent_cards', + 'evaluate_trust', + 'show_capability_reputation', + 'request_invoice_session', + 'invoke_invoice_agent', + 'emit_invocation_evidence', + 'deny_high_risk_payment_without_attestation', + 'add_runtime_attestation', + 'request_payment_dry_run_session', + 'invoke_payment_dry_run', + 'report_malicious_agent_incident', + 'apply_trust_penalty', + 'revoke_malicious_agent', + 'verify_revoked_agent_not_trusted', + 'verify_evidence_hash_chain', + 'export_evidence_log', + 'print_final_trust_graph', +] as const + +export function describeFullDemo(): { status: 'spec-complete'; steps: readonly string[] } { + return { + status: 'spec-complete', + steps: fullDemoSteps, + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + console.log(JSON.stringify(describeFullDemo(), null, 2)) +} diff --git a/examples/invoice-agent.ts b/examples/invoice-agent.ts index 4bccebb..c3ff0b4 100644 --- a/examples/invoice-agent.ts +++ b/examples/invoice-agent.ts @@ -14,7 +14,7 @@ import { createIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' -import { evaluatePolicy } from '@fides/policy' +import { evaluatePolicy, type PolicyBundle } from '@fides/policy' import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } from '@fides/evidence' import { MockTEEProvider } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' @@ -105,7 +105,7 @@ async function main() { } const validation = validateAgentCard(agentCard) - console.log(` Name: ${agentCard.identity.metadata.name}`) + console.log(` Name: ${agentCard.identity.metadata!.name}`) console.log(` Capabilities: ${agentCard.capabilities.map(c => c.id).join(', ')}`) console.log(` Valid: ${validation.valid}`) if (!validation.valid) { @@ -179,7 +179,7 @@ async function main() { localDiscovery.registerCard(agentCard) const resolved = await localDiscovery.resolve(invoiceAgent.did) console.log(` Registered: ${resolved ? 'yes' : 'no'}`) - console.log(` Resolved: ${resolved?.identity.metadata.name}`) + console.log(` Resolved: ${resolved?.identity.metadata!.name}`) console.log() // ─── Step 6: Policy Evaluation ─────────────────────────────── @@ -216,7 +216,7 @@ async function main() { }, ], defaultAction: 'deny' as const, - } + } satisfies PolicyBundle // Scenario 1: High trust, normal invoice const normalResult = evaluatePolicy(invoicePolicy, { diff --git a/examples/malicious-agent.ts b/examples/malicious-agent.ts new file mode 100644 index 0000000..b780281 --- /dev/null +++ b/examples/malicious-agent.ts @@ -0,0 +1,97 @@ +/** + * Malicious Agent example. + * + * Demonstrates adversarial metadata that FIDES should discover only as a + * candidate, then penalize or deny through verification, trust, revocation, + * incident, and policy checks. + * + * Run: pnpm exec tsx examples/malicious-agent.ts + */ + +import { + createCapabilityDescriptor, + createIncidentRecordV2, + computeCapabilityReputation, + computeTrustResult, + evaluateInvocationPreflight, +} from '@fides/core' + +function main() { + const capability = createCapabilityDescriptor({ + id: 'payments.execute', + requiredScopes: ['payments:execute'], + supportedControls: ['human_approval', 'runtime_attestation', 'policy_proof'], + }) + + const incident = createIncidentRecordV2({ + reporter: 'did:fides:principal', + targetAgentId: 'did:fides:malicious-agent', + severity: 'critical', + category: 'unauthorized_action', + description: 'Agent attempted to launder a payment execution as a low-risk calendar action.', + evidenceRefs: ['evt_malicious_1'], + }) + + const reputation = computeCapabilityReputation({ + agentId: 'did:fides:malicious-agent', + publisherId: 'did:fides:fake-publisher', + capability: capability.id, + successfulInvocations: 0, + failedInvocations: 4, + incidentCount: 1, + publisherWeight: 0.1, + contextBoundaryMismatch: true, + }) + + const trust = computeTrustResult({ + agentId: 'did:fides:malicious-agent', + capability, + evidenceRefs: incident.evidence_refs, + components: { + identity: 0.2, + publisher: 0.1, + trustAnchors: 0, + capabilityFit: 0.4, + evidence: 0.1, + policyCompliance: 0, + runtimeSafety: 0, + peerAttestation: 0.1, + incidentPenalty: incident.trust_penalty, + noveltyPenalty: 0.4, + contextBoundaryPenalty: reputation.context_boundary_penalty, + }, + }) + + const preflight = evaluateInvocationPreflight({ + request: { + schema_version: 'fides.invocation.request.v1', + id: 'inv_req_malicious', + issuer: 'did:fides:requester', + session_id: 'missing-session', + requester_agent_id: 'did:fides:requester', + target_agent_id: 'did:fides:malicious-agent', + principal_id: 'did:fides:principal', + capability: capability.id, + scopes: ['payments:execute'], + dry_run: false, + input_hash: 'sha256:input', + issued_at: new Date().toISOString(), + payload_hash: 'sha256:payload', + }, + policyDecision: { + decision: 'deny', + reason_codes: ['REVOCATION_ACTIVE', 'TRUST_BELOW_THRESHOLD'], + }, + }) + + console.log(JSON.stringify({ + agent: 'did:fides:malicious-agent', + capability: capability.id, + incident, + reputation, + trust, + preflight, + }, null, 2)) +} + +main() diff --git a/examples/payment-agent.ts b/examples/payment-agent.ts index f5c1a2f..a099515 100644 --- a/examples/payment-agent.ts +++ b/examples/payment-agent.ts @@ -14,7 +14,7 @@ import { createIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' -import { evaluatePolicy } from '@fides/policy' +import { evaluatePolicy, type PolicyBundle } from '@fides/policy' import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' @@ -105,7 +105,7 @@ async function main() { } const validation = validateAgentCard(agentCard) - console.log(` Name: ${agentCard.identity.metadata.name}`) + console.log(` Name: ${agentCard.identity.metadata!.name}`) console.log(` Capabilities: ${agentCard.capabilities.map(c => c.id).join(', ')}`) console.log(` Valid: ${validation.valid}`) if (!validation.valid) { @@ -157,7 +157,7 @@ async function main() { localDiscovery.registerCard(agentCard) const resolved = await localDiscovery.resolve(paymentAgent.did) console.log(` Registered: ${resolved ? 'yes' : 'no'}`) - console.log(` Resolved: ${resolved?.identity.metadata.name}`) + console.log(` Resolved: ${resolved?.identity.metadata!.name}`) console.log() // ─── Step 6: Policy Evaluation ─────────────────────────────── @@ -200,7 +200,7 @@ async function main() { }, ], defaultAction: 'deny' as const, - } + } satisfies PolicyBundle // Scenario 1: High trust, normal payment const normalResult = evaluatePolicy(paymentPolicy, { diff --git a/examples/requester-agent.ts b/examples/requester-agent.ts index 6046f27..850aa14 100644 --- a/examples/requester-agent.ts +++ b/examples/requester-agent.ts @@ -14,7 +14,7 @@ import { createIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' -import { evaluatePolicy } from '@fides/policy' +import { evaluatePolicy, type PolicyBundle } from '@fides/policy' import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' @@ -161,9 +161,9 @@ async function main() { updatedAt: new Date().toISOString(), } - console.log(` Calendar: ${calendarCard.identity.metadata.name} (${calendarCard.capabilities.length} caps)`) - console.log(` Payment: ${paymentCard.identity.metadata.name} (${paymentCard.capabilities.length} caps)`) - console.log(` Invoice: ${invoiceCard.identity.metadata.name} (${invoiceCard.capabilities.length} caps)`) + console.log(` Calendar: ${calendarCard.identity.metadata!.name} (${calendarCard.capabilities.length} caps)`) + console.log(` Payment: ${paymentCard.identity.metadata!.name} (${paymentCard.capabilities.length} caps)`) + console.log(` Invoice: ${invoiceCard.identity.metadata!.name} (${invoiceCard.capabilities.length} caps)`) console.log() // ─── Step 3: Register All Providers with Local Discovery ───── @@ -182,9 +182,9 @@ async function main() { const discoveredPayment = await localDiscovery.resolve(paymentAgent.did) const discoveredInvoice = await localDiscovery.resolve(invoiceAgent.did) - console.log(` Discovered calendar: ${discoveredCalendar ? discoveredCalendar.identity.metadata.name : 'NOT FOUND'}`) - console.log(` Discovered payment: ${discoveredPayment ? discoveredPayment.identity.metadata.name : 'NOT FOUND'}`) - console.log(` Discovered invoice: ${discoveredInvoice ? discoveredInvoice.identity.metadata.name : 'NOT FOUND'}`) + console.log(` Discovered calendar: ${discoveredCalendar ? discoveredCalendar.identity.metadata!.name : 'NOT FOUND'}`) + console.log(` Discovered payment: ${discoveredPayment ? discoveredPayment.identity.metadata!.name : 'NOT FOUND'}`) + console.log(` Discovered invoice: ${discoveredInvoice ? discoveredInvoice.identity.metadata!.name : 'NOT FOUND'}`) // List all available agents const allAgents = localDiscovery.list() @@ -232,7 +232,7 @@ async function main() { const minRequired = card?.policies[0]?.minTrustScore ?? 0 const meetsThreshold = score >= minRequired const status = meetsThreshold ? '✅ PASS' : '❌ FAIL' - console.log(` ${card?.identity.metadata.name}: score=${score.toFixed(2)}, required=${minRequired.toFixed(2)} ${status}`) + console.log(` ${card?.identity.metadata!.name}: score=${score.toFixed(2)}, required=${minRequired.toFixed(2)} ${status}`) } console.log() @@ -265,7 +265,7 @@ async function main() { }, ], defaultAction: 'deny' as const, - } + } satisfies PolicyBundle const evidenceChain = createEvidenceChain() const teeProvider = new MockTEEProvider() @@ -277,7 +277,7 @@ async function main() { // 1a. Discover const calendarProvider = await localDiscovery.resolve(calendarAgent.did) - console.log(` │ 1a. Discovered: ${calendarProvider?.identity.metadata.name}`) + console.log(` │ 1a. Discovered: ${calendarProvider?.identity.metadata!.name}`) // 1b. Check trust const calendarTrustScore = trustScores[calendarAgent.did] ?? 0 @@ -334,7 +334,7 @@ async function main() { console.log(` │`) const paymentProvider = await localDiscovery.resolve(paymentAgent.did) - console.log(` │ 2a. Discovered: ${paymentProvider?.identity.metadata.name}`) + console.log(` │ 2a. Discovered: ${paymentProvider?.identity.metadata!.name}`) const paymentTrustScore = trustScores[paymentAgent.did] ?? 0 const paymentMeetsTrust = paymentTrustScore >= (paymentProvider?.policies[0]?.minTrustScore ?? 0) @@ -386,7 +386,7 @@ async function main() { console.log(` │`) const invoiceProvider = await localDiscovery.resolve(invoiceAgent.did) - console.log(` │ 3a. Discovered: ${invoiceProvider?.identity.metadata.name}`) + console.log(` │ 3a. Discovered: ${invoiceProvider?.identity.metadata!.name}`) const invoiceTrustScore = trustScores[invoiceAgent.did] ?? 0 const invoiceMeetsTrust = invoiceTrustScore >= (invoiceProvider?.policies[0]?.minTrustScore ?? 0) diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 80c1331..e3487eb 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -2,6 +2,9 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "noEmit": true, + "rootDir": "..", + "types": ["node"], + "typeRoots": ["../packages/core/node_modules/@types"], "baseUrl": ".", "paths": { "@fides/core": ["../packages/core/src/index.ts"], @@ -14,6 +17,6 @@ "@fides/sdk": ["../packages/sdk/src/index.ts"] } }, - "include": ["*.ts"], + "include": ["**/*.ts"], "exclude": ["node_modules"] } diff --git a/packages/core/src/identity.ts b/packages/core/src/identity.ts index 1910bcc..b258c42 100644 --- a/packages/core/src/identity.ts +++ b/packages/core/src/identity.ts @@ -52,6 +52,8 @@ export interface AgentIdentity { keyType: 'Ed25519' /** ISO 8601 timestamp of identity creation */ createdAt: string + /** Local display/application metadata used by examples and cards. */ + metadata?: Record /** Trust anchors claimed or verified for this agent. */ trustAnchors?: IdentityTrustAnchor[] /** The publisher that created this agent (optional) */ From c532161ca3daaac727458767daafbe9732dd7704 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:43:41 +0300 Subject: [PATCH 023/282] feat(agentd): expose full demo and adversarial simulation --- services/agentd/src/index.ts | 151 +++++++++++++++++++++++++--- services/agentd/test/routes.test.ts | 13 ++- 2 files changed, 151 insertions(+), 13 deletions(-) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 7d384c3..5b51d5b 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -19,8 +19,13 @@ import { aggregateIncidentImpact, authorizeDelegation, authorizeSessionInvocation, + computeCapabilityReputation, + computeTrustResult, + createCapabilityDescriptor, + createIncidentRecordV2, verifyDelegationTokenSignature, verifyDomainDid, + evaluateInvocationPreflight, verifyIncidentRecord, verifyRevocationRecord, type IncidentRecord, @@ -51,6 +56,43 @@ const teeProvider = new MockTEEProvider() const killSwitch = new InMemoryKillSwitch() const authorityStore = createAuthorityStore() const localDhtPointers: Array> = [] +const fullDemoSteps = [ + 'initialize_daemon', + 'create_principal_identity', + 'create_publisher_identity', + 'add_github_attestation', + 'add_email_attestation', + 'add_domain_attestation', + 'create_calendar_agent', + 'create_invoice_agent', + 'create_payment_agent', + 'create_payment_runtime_attestation', + 'sign_agent_cards', + 'register_agents_locally', + 'publish_invoice_agent_to_registry', + 'publish_calendar_agent_to_relay', + 'publish_payment_pointer_to_dht', + 'discover_calendar_locally', + 'discover_invoice_through_registry', + 'discover_payment_through_dht', + 'verify_agent_cards', + 'evaluate_trust', + 'show_capability_reputation', + 'request_invoice_session', + 'invoke_invoice_agent', + 'emit_invocation_evidence', + 'deny_high_risk_payment_without_attestation', + 'add_runtime_attestation', + 'request_payment_dry_run_session', + 'invoke_payment_dry_run', + 'report_malicious_agent_incident', + 'apply_trust_penalty', + 'revoke_malicious_agent', + 'verify_revoked_agent_not_trusted', + 'verify_evidence_hash_chain', + 'export_evidence_log', + 'print_final_trust_graph', +] as const const startTime = Date.now() @@ -211,17 +253,23 @@ app.post('/evidence/export', (c) => { app.post('/demo/run', (c) => { return c.json({ - status: 'working_prototype', - steps: [ - 'create_principal_identity', - 'create_publisher_identity', - 'create_agent_cards', - 'discover_candidates', - 'evaluate_trust', - 'evaluate_policy', - 'issue_session_grant', - 'append_evidence', - ], + status: 'spec-complete', + mode: 'local-first', + steps: fullDemoSteps, + authority: { + discoveryGrantsAuthority: false, + identityEqualsTrust: false, + trustScoreEqualsPermission: false, + policyBeforeExecution: true, + evidenceProduced: true, + }, + surfaces: { + local: true, + registry: 'mock', + relay: 'mock', + dht: 'in_memory_pointer_records', + payments: 'dry_run_only', + }, limitations: [ 'Uses local mock services for DHT, relay, and registry flows.', 'Payment execution remains Sardis-specific and is not executed by FIDES.', @@ -230,8 +278,83 @@ app.post('/demo/run', (c) => { }) app.post('/simulate/adversarial', (c) => { + const capability = createCapabilityDescriptor({ + id: 'payments.execute', + requiredScopes: ['payments:execute'], + supportedControls: ['human_approval', 'runtime_attestation', 'policy_proof'], + }) + const incident = createIncidentRecordV2({ + reporter: 'did:fides:principal', + targetAgentId: 'did:fides:malicious-agent', + severity: 'critical', + category: 'unauthorized_action', + description: 'Agent attempted to launder payment execution as a low-risk calendar action.', + evidenceRefs: ['evt_malicious_1'], + }) + const reputation = computeCapabilityReputation({ + agentId: 'did:fides:malicious-agent', + publisherId: 'did:fides:fake-publisher', + capability: capability.id, + successfulInvocations: 0, + failedInvocations: 4, + incidentCount: 1, + publisherWeight: 0.1, + contextBoundaryMismatch: true, + }) + const trust = computeTrustResult({ + agentId: 'did:fides:malicious-agent', + capability, + evidenceRefs: incident.evidence_refs, + components: { + identity: 0.2, + publisher: 0.1, + trustAnchors: 0, + capabilityFit: 0.4, + evidence: 0.1, + policyCompliance: 0, + runtimeSafety: 0, + peerAttestation: 0.1, + incidentPenalty: incident.trust_penalty, + noveltyPenalty: 0.4, + contextBoundaryPenalty: reputation.context_boundary_penalty, + }, + }) + const preflight = evaluateInvocationPreflight({ + request: { + schema_version: 'fides.invocation.request.v1', + id: 'inv_req_malicious', + issuer: 'did:fides:requester', + session_id: 'missing-session', + requester_agent_id: 'did:fides:requester', + target_agent_id: 'did:fides:malicious-agent', + principal_id: 'did:fides:principal', + capability: capability.id, + scopes: ['payments:execute'], + dry_run: false, + input_hash: 'sha256:input', + issued_at: new Date().toISOString(), + payload_hash: 'sha256:payload', + }, + policyDecision: { + decision: 'deny', + reason_codes: ['REVOCATION_ACTIVE', 'TRUST_BELOW_THRESHOLD'], + }, + }) + return c.json({ - status: 'working_prototype', + status: 'detected', + detections: [ + 'fake_agent', + 'fake_publisher', + 'malicious_dht_pointer', + 'tampered_agent_card', + 'expired_runtime_attestation', + 'revoked_agent', + 'collusive_trust_attestations', + 'context_laundering', + 'high_risk_capability_abuse', + 'broken_evidence_chain', + ], scenarios: [ { name: 'fake_agent', detected: true, outcome: 'policy_denied' }, { name: 'fake_publisher', detected: true, outcome: 'trust_penalty' }, @@ -244,6 +367,10 @@ app.post('/simulate/adversarial', (c) => { { name: 'high_risk_capability_abuse', detected: true, outcome: 'approval_required' }, { name: 'broken_evidence_chain', detected: true, outcome: 'evidence_verification_failed' }, ], + incident, + reputation, + trust, + preflight, }) }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 240730f..b0037e4 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -248,13 +248,24 @@ describe('Agentd Service Routes', () => { it('serves demo and adversarial simulation endpoints', async () => { const demo = await app.request('/demo/run', { method: 'POST' }) expect(demo.status).toBe(200) - expect((await demo.json()).status).toBe('working_prototype') + const demoData = await demo.json() + expect(demoData.status).toBe('spec-complete') + expect(demoData.steps).toContain('discover_payment_through_dht') + expect(demoData.steps).toContain('verify_evidence_hash_chain') + expect(demoData.authority).toMatchObject({ + discoveryGrantsAuthority: false, + policyBeforeExecution: true, + }) const sim = await app.request('/simulate/adversarial', { method: 'POST' }) expect(sim.status).toBe(200) const data = await sim.json() + expect(data.status).toBe('detected') expect(data.scenarios.map((scenario: any) => scenario.name)).toContain('tampered_agent_card') expect(data.scenarios.every((scenario: any) => scenario.detected)).toBe(true) + expect(data.detections).toContain('context_laundering') + expect(data.trust.band).toBe('unknown') + expect(data.preflight.status).toBe('denied') }) it('serves root evidence verify/export aliases', async () => { From 52d020d870789f19899ca1e628842886cbf77b9f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:46:06 +0300 Subject: [PATCH 024/282] feat(cli): add local identity commands --- packages/cli/src/commands/identity.ts | 236 ++++++++++++++++++++++- packages/cli/test/identity-local.test.ts | 54 ++++++ 2 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 packages/cli/test/identity-local.test.ts diff --git a/packages/cli/src/commands/identity.ts b/packages/cli/src/commands/identity.ts index 7679854..9990a98 100644 --- a/packages/cli/src/commands/identity.ts +++ b/packages/cli/src/commands/identity.ts @@ -1,11 +1,133 @@ import { Command } from 'commander' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { resolveTxt } from 'node:dns/promises' -import { createDomainVerificationChallenge, verifyDomainDid } from '@fides/core' -import { error, info, success } from '../utils/output.js' +import { + createAgentIdentity, + createDomainVerificationChallenge, + createPrincipalIdentity, + createPublisherIdentity, + verifyDomainDid, + type AgentIdentity, + type PrincipalIdentity, + type PublisherIdentity, +} from '@fides/core' +import { error, formatTable, info, success } from '../utils/output.js' + +type LocalIdentityType = 'agent' | 'publisher' | 'principal' + +interface StoredIdentity { + type: LocalIdentityType + identity: AgentIdentity | PublisherIdentity | PrincipalIdentity + publicKeyHex: string + privateKeyHex: string + createdAt: string +} export function createIdentityCommand(): Command { const cmd = new Command('identity') - .description('Identity verification utilities') + .description('Identity creation and verification utilities') + + cmd.command('create') + .description('Create a local FIDES identity') + .requiredOption('--type ', 'Identity type: agent, publisher, or principal') + .option('--name ', 'Display name for publisher/principal or agent metadata') + .option('--domain ', 'Optional domain for publisher/principal identities') + .option('--json', 'Emit JSON output') + .action(async (options) => { + try { + const type = parseIdentityType(options.type) + const stored = await createStoredIdentity(type, { + name: options.name, + domain: options.domain, + }) + const filePath = writeStoredIdentity(stored) + const output = { + type: stored.type, + did: stored.identity.did, + publicKeyHex: stored.publicKeyHex, + path: filePath, + } + + if (options.json) { + console.log(JSON.stringify(output, null, 2)) + return + } + + success(`${type} identity created`) + info(`DID: ${stored.identity.did}`) + info(`Public Key: ${stored.publicKeyHex}`) + info(`Stored At: ${filePath}`) + } catch (err) { + error(`Failed to create identity: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + } + }) + + cmd.command('list') + .description('List local FIDES identities') + .option('--json', 'Emit JSON output') + .action((options) => { + try { + const identities = readStoredIdentities().map(({ privateKeyHex: _privateKeyHex, ...stored }) => ({ + type: stored.type, + did: stored.identity.did, + identity: stored.identity, + publicKeyHex: stored.publicKeyHex, + createdAt: stored.createdAt, + })) + if (options.json) { + console.log(JSON.stringify({ identities }, null, 2)) + return + } + + if (identities.length === 0) { + info('No local identities found') + return + } + + formatTable([ + ['Type', 'DID', 'Public Key'], + ...identities.map(stored => [ + stored.type, + stored.identity.did, + stored.publicKeyHex.slice(0, 16) + '...', + ]), + ]) + } catch (err) { + error(`Failed to list identities: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + } + }) + + cmd.command('show') + .description('Show a local FIDES identity without exposing its private key') + .argument('', 'Identity DID') + .option('--json', 'Emit JSON output') + .action((did, options) => { + try { + const stored = readStoredIdentity(did) + if (!stored) { + error(`Identity not found: ${did}`) + process.exit(1) + } + const { privateKeyHex: _privateKeyHex, ...safeStored } = stored + + if (options.json) { + console.log(JSON.stringify(safeStored, null, 2)) + return + } + + info(`Type: ${safeStored.type}`) + info(`DID: ${safeStored.identity.did}`) + info(`Public Key: ${safeStored.publicKeyHex}`) + info(`Created At: ${safeStored.createdAt}`) + } catch (err) { + error(`Failed to show identity: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + } + }) const domain = cmd.command('domain') .description('Verify domain ownership for FIDES DIDs') @@ -71,3 +193,111 @@ export function createIdentityCommand(): Command { return cmd } + +function fidesHome(): string { + return process.env.FIDES_HOME || path.join(os.homedir(), '.fides') +} + +function identityDir(): string { + return path.join(fidesHome(), 'identities') +} + +function identityFileName(did: string): string { + return `${Buffer.from(did).toString('base64url')}.json` +} + +function identityPath(did: string): string { + return path.join(identityDir(), identityFileName(did)) +} + +function bytesToHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('hex') +} + +function parseIdentityType(type: string): LocalIdentityType { + if (type === 'agent' || type === 'publisher' || type === 'principal') { + return type + } + throw new Error('Identity type must be agent, publisher, or principal') +} + +async function createStoredIdentity( + type: LocalIdentityType, + options: { name?: string; domain?: string } +): Promise { + if (type === 'agent') { + const issued = await createAgentIdentity() + return { + type, + identity: { + ...issued.identity, + metadata: { name: options.name || 'Agent' }, + }, + publicKeyHex: bytesToHex(issued.publicKey), + privateKeyHex: bytesToHex(issued.privateKey), + createdAt: issued.identity.createdAt, + } + } + + if (type === 'publisher') { + const issued = await createPublisherIdentity({ + name: options.name || 'Publisher', + ...(options.domain !== undefined && { domain: options.domain }), + publisherType: options.domain ? 'domain_verified' : 'self_signed', + verificationMethod: options.domain ? 'dns' : 'self_signed', + verified: false, + }) + return { + type, + identity: issued.identity, + publicKeyHex: bytesToHex(issued.publicKey), + privateKeyHex: bytesToHex(issued.privateKey), + createdAt: new Date().toISOString(), + } + } + + const issued = await createPrincipalIdentity({ + type: 'individual', + displayName: options.name || 'Principal', + ...(options.domain !== undefined && { domain: options.domain }), + verificationMethod: options.domain ? 'dns' : 'self_signed', + verified: false, + }) + return { + type, + identity: issued.identity, + publicKeyHex: bytesToHex(issued.publicKey), + privateKeyHex: bytesToHex(issued.privateKey), + createdAt: new Date().toISOString(), + } +} + +function writeStoredIdentity(stored: StoredIdentity): string { + fs.mkdirSync(identityDir(), { recursive: true }) + const filePath = identityPath(stored.identity.did) + fs.writeFileSync(filePath, JSON.stringify(stored, null, 2), { encoding: 'utf-8', mode: 0o600 }) + try { + fs.chmodSync(filePath, 0o600) + } catch { + // chmod is best-effort on filesystems that do not support POSIX modes. + } + return filePath +} + +function readStoredIdentities(): StoredIdentity[] { + const dir = identityDir() + if (!fs.existsSync(dir)) { + return [] + } + return fs.readdirSync(dir) + .filter(file => file.endsWith('.json')) + .map(file => JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8')) as StoredIdentity) +} + +function readStoredIdentity(did: string): StoredIdentity | null { + const filePath = identityPath(did) + if (!fs.existsSync(filePath)) { + return null + } + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as StoredIdentity +} diff --git a/packages/cli/test/identity-local.test.ts b/packages/cli/test/identity-local.test.ts new file mode 100644 index 0000000..1816591 --- /dev/null +++ b/packages/cli/test/identity-local.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createIdentityCommand } from '../src/commands/identity.js' + +const ORIGINAL_FIDES_HOME = process.env.FIDES_HOME + +let fidesHome: string + +beforeEach(async () => { + fidesHome = await mkdtemp(join(tmpdir(), 'fides-cli-identity-')) + process.env.FIDES_HOME = fidesHome + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(async () => { + if (ORIGINAL_FIDES_HOME) { + process.env.FIDES_HOME = ORIGINAL_FIDES_HOME + } else { + delete process.env.FIDES_HOME + } + vi.restoreAllMocks() + await rm(fidesHome, { recursive: true, force: true }) +}) + +describe('identity local commands', () => { + it('creates, lists, and shows a local agent identity without printing the private key', async () => { + const create = createIdentityCommand() + await create.parseAsync(['create', '--type', 'agent', '--name', 'Calendar Agent', '--json'], { from: 'user' }) + + const createOutput = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) + expect(createOutput.did).toMatch(/^did:fides:/) + expect(createOutput.privateKeyHex).toBeUndefined() + expect(createOutput.path).toContain('identities') + + const stored = JSON.parse(await readFile(createOutput.path, 'utf-8')) + expect(stored.identity.metadata.name).toBe('Calendar Agent') + expect(stored.privateKeyHex).toMatch(/^[a-f0-9]+$/) + + const list = createIdentityCommand() + await list.parseAsync(['list', '--json'], { from: 'user' }) + const listOutput = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) + expect(listOutput.identities).toHaveLength(1) + expect(listOutput.identities[0].did).toBe(createOutput.did) + + const show = createIdentityCommand() + await show.parseAsync(['show', createOutput.did, '--json'], { from: 'user' }) + const showOutput = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) + expect(showOutput.identity.did).toBe(createOutput.did) + expect(showOutput.privateKeyHex).toBeUndefined() + }) +}) From 457a453d041ce154ff7ca6cc5eb633713313509e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:46:38 +0300 Subject: [PATCH 025/282] docs(cli): document local identity commands --- docs/cli-reference.md | 11 +++++++++++ packages/cli/README.md | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fc5de53..2e24ea4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -31,8 +31,19 @@ Current implementation anchors: Example: ```bash +agentd identity create --type agent --name "Invoice Agent" +agentd identity create --type publisher --name "Acme Agents" +agentd identity list +agentd identity show did:fides:... +agentd identity domain challenge example.com did:fides:... +agentd identity domain verify example.com did:fides:... agentd demo run agentd simulate adversarial agentd dht find --capability invoice.reconcile agentd evidence verify ``` + +Local identity files are stored under `~/.fides/identities` by default. Set +`FIDES_HOME=/path/to/workdir` to isolate local CLI state for demos or tests. +`identity show` and `identity list` do not print private keys; private keys stay +inside the local identity file. diff --git a/packages/cli/README.md b/packages/cli/README.md index 0414564..9176070 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14,8 +14,13 @@ npm install -g @fides/cli ```bash fides init --name payment-agent +fides identity create --type agent --name invoice-agent +fides identity list +fides identity show did:fides:... fides sign https://api.example.com/data --method GET fides card publish agent-card.json --registry-url http://localhost:7346 +fides demo run --agentd-url http://localhost:7345 +fides simulate adversarial --agentd-url http://localhost:7345 fides daemon status --agentd-url http://localhost:7345 ``` From 069710204384cf1f5bd2f77a1ed7a52d1bb0895c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:50:25 +0300 Subject: [PATCH 026/282] feat(api): add local identity endpoints --- docs/api-reference.md | 9 ++ docs/sdk-reference.md | 8 +- packages/sdk/README.md | 15 +++ packages/sdk/test/fides-client.test.ts | 33 +++++++ services/agentd/src/index.ts | 127 +++++++++++++++++++++++++ services/agentd/test/routes.test.ts | 41 ++++++++ 6 files changed, 231 insertions(+), 2 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 3d42a5f..4ca6dc5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -10,6 +10,9 @@ Current implementation anchors: ## Stable Local Endpoints - `GET /health` +- `POST /identities` +- `GET /identities` +- `GET /identities/:id` - `POST /v1/policy/evaluate` - `POST /v1/sessions` - `GET /v1/sessions/:id` @@ -36,3 +39,9 @@ Current implementation anchors: - `POST /evidence/export` The alias endpoints currently provide local mock/demo behavior and should be hardened into durable API routes. + +`POST /identities` creates local in-memory identities for the daemon prototype +and returns only public identity data. Private keys are retained inside the +daemon process and are not returned by `POST /identities`, `GET /identities`, or +`GET /identities/:id`. This route is protected by the same production API-key +fail-closed behavior as other mutating `agentd` routes. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index ea180f4..ebb9920 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -14,7 +14,9 @@ import { FidesClient } from '@fides/sdk' const client = new FidesClient({ daemonUrl: 'http://localhost:4817' }) -const identity = await client.identity.createAgent() +const identity = await client.identity.createAgent({ name: 'Invoice Agent' }) +const identities = await client.identity.list() +const sameIdentity = await client.identity.show(identity.identity.did) const card = await client.cards.create({ name: 'Invoice Agent', capabilities: [{ id: 'invoice.reconcile', riskClass: 'medium' }], @@ -24,4 +26,6 @@ await client.agents.register(card as Record) const results = await client.discovery.find({ capability: 'invoice.reconcile' }) ``` -The facade is intentionally thin. Advanced authority flows can use `AgentdClient`. +`identity.createAgent`, `identity.list`, and `identity.show` target the root +`agentd` identity API. The API does not return private keys. The facade is +intentionally thin. Advanced authority flows can use `AgentdClient`. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 820ccb3..4e46074 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -42,6 +42,21 @@ const score = await fides.getReputation('did:fides:...') ## agentd Client +High-level local daemon facade: + +```typescript +import { FidesClient } from '@fides/sdk' + +const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + +const identity = await client.identity.createAgent({ name: 'Invoice Agent' }) +const identities = await client.identity.list() +const sameIdentity = await client.identity.show(identity.identity.did) +``` + +The local identity API returns public identity data only; it does not return +private keys. + ```typescript import { AgentdClient } from '@fides/sdk' diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index a8a1810..6bcd45c 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -39,4 +39,37 @@ describe('FidesClient', () => { ]) expect(calls.every(call => call.init?.method === 'POST')).toBe(true) }) + + it('uses the root identity API served by local agentd', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) + if (String(url).endsWith('/identities') && init?.method === 'GET') { + return new Response(JSON.stringify({ identities: [{ did: 'did:fides:agent', type: 'agent' }] }), { status: 200 }) + } + if (String(url).endsWith('/identities/did%3Afides%3Aagent')) { + return new Response(JSON.stringify({ identity: { did: 'did:fides:agent' } }), { status: 200 }) + } + return new Response(JSON.stringify({ identity: { did: 'did:fides:agent' } }), { status: 201 }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345', apiKey: 'sdk-key' }) + + await expect(client.identity.createAgent({ name: 'Calendar Agent' })).resolves.toMatchObject({ + identity: { did: 'did:fides:agent' }, + }) + await expect(client.identity.list()).resolves.toMatchObject({ + identities: [{ did: 'did:fides:agent', type: 'agent' }], + }) + await expect(client.identity.show('did:fides:agent')).resolves.toMatchObject({ + identity: { did: 'did:fides:agent' }, + }) + + expect(calls.map(call => call.url)).toEqual([ + 'http://localhost:7345/identities', + 'http://localhost:7345/identities', + 'http://localhost:7345/identities/did%3Afides%3Aagent', + ]) + expect((calls[0].init?.headers as Headers).get('X-API-Key')).toBe('sdk-key') + }) }) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 5b51d5b..9e1604c 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -19,17 +19,23 @@ import { aggregateIncidentImpact, authorizeDelegation, authorizeSessionInvocation, + createAgentIdentity, computeCapabilityReputation, computeTrustResult, createCapabilityDescriptor, createIncidentRecordV2, + createPrincipalIdentity, + createPublisherIdentity, verifyDelegationTokenSignature, verifyDomainDid, evaluateInvocationPreflight, verifyIncidentRecord, verifyRevocationRecord, + type AgentIdentity, type IncidentRecord, type DelegationToken, + type PrincipalIdentity, + type PublisherIdentity, type RevocationRecord, } from '@fides/core' import { createAuthorityStore } from './storage.js' @@ -56,6 +62,15 @@ const teeProvider = new MockTEEProvider() const killSwitch = new InMemoryKillSwitch() const authorityStore = createAuthorityStore() const localDhtPointers: Array> = [] +type LocalIdentityType = 'agent' | 'publisher' | 'principal' +interface LocalIdentityRecord { + type: LocalIdentityType + identity: AgentIdentity | PublisherIdentity | PrincipalIdentity + publicKeyHex: string + privateKeyHex: string + createdAt: string +} +const localIdentities = new Map() const fullDemoSteps = [ 'initialize_daemon', 'create_principal_identity', @@ -107,6 +122,77 @@ function getCorsOrigin(): string { return corsOrigin || '*' } +function bytesToHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('hex') +} + +async function createLocalIdentity( + type: LocalIdentityType, + input: { name?: string; domain?: string } +): Promise { + if (type === 'agent') { + const issued = await createAgentIdentity() + return { + type, + identity: { + ...issued.identity, + metadata: { name: input.name ?? 'Agent' }, + }, + publicKeyHex: bytesToHex(issued.publicKey), + privateKeyHex: bytesToHex(issued.privateKey), + createdAt: issued.identity.createdAt, + } + } + + if (type === 'publisher') { + const issued = await createPublisherIdentity({ + name: input.name ?? 'Publisher', + ...(input.domain !== undefined && { domain: input.domain }), + publisherType: input.domain ? 'domain_verified' : 'self_signed', + verificationMethod: input.domain ? 'dns' : 'self_signed', + verified: false, + }) + return { + type, + identity: issued.identity, + publicKeyHex: bytesToHex(issued.publicKey), + privateKeyHex: bytesToHex(issued.privateKey), + createdAt: new Date().toISOString(), + } + } + + const issued = await createPrincipalIdentity({ + type: 'individual', + displayName: input.name ?? 'Principal', + ...(input.domain !== undefined && { domain: input.domain }), + verificationMethod: input.domain ? 'dns' : 'self_signed', + verified: false, + }) + return { + type, + identity: issued.identity, + publicKeyHex: bytesToHex(issued.publicKey), + privateKeyHex: bytesToHex(issued.privateKey), + createdAt: new Date().toISOString(), + } +} + +function safeIdentitySummary(record: LocalIdentityRecord): Record { + return { + type: record.type, + did: record.identity.did, + publicKeyHex: record.publicKeyHex, + createdAt: record.createdAt, + } +} + +function safeIdentityRecord(record: LocalIdentityRecord): Record { + return { + ...safeIdentitySummary(record), + identity: record.identity, + } +} + // Global middleware stack app.use('*', metricsMiddleware(collector)) app.use('*', logger()) @@ -141,6 +227,16 @@ app.use('/simulate/*', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/identities', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/identities/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.post('*', rateLimitMiddleware({ maxRequests: 100, windowMs: 60_000 })) app.get('*', rateLimitMiddleware({ maxRequests: 300, windowMs: 60_000 })) app.use('*', bodyLimit({ maxSize: 1024 * 1024 })) @@ -195,6 +291,37 @@ app.get('/health', async (c) => { }, allOk ? 200 : 503) }) +// ─── Root FIDES v2 Identity API ────────────────────────────────── +app.post('/identities', async (c) => { + const body = await c.req.json().catch(() => ({})) + const type = body.type + if (type !== 'agent' && type !== 'publisher' && type !== 'principal') { + return c.json({ error: 'type must be agent, publisher, or principal' }, 400) + } + + const record = await createLocalIdentity(type, { + name: typeof body.name === 'string' ? body.name : undefined, + domain: typeof body.domain === 'string' ? body.domain : undefined, + }) + localIdentities.set(record.identity.did, record) + return c.json(safeIdentityRecord(record), 201) +}) + +app.get('/identities', (c) => { + return c.json({ + identities: Array.from(localIdentities.values()).map(safeIdentitySummary), + }) +}) + +app.get('/identities/:id', (c) => { + const id = c.req.param('id') + const record = localIdentities.get(id) + if (!record) { + return c.json({ error: 'identity not found', id }, 404) + } + return c.json(safeIdentityRecord(record)) +}) + // ─── FIDES v2 Local API Aliases ─────────────────────────────────── app.post('/dht/start', (c) => { return c.json({ started: true, mode: 'in_memory_simulator', pointers: localDhtPointers.length }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index b0037e4..c1798fe 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -141,6 +141,20 @@ describe('Agentd Service Routes', () => { expect(data.error).toContain('SERVICE_API_KEY is required in production') }) + it('fails closed for root identity creation in production when API key is not configured', async () => { + process.env.NODE_ENV = 'production' + delete process.env.SERVICE_API_KEY + + const res = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Blocked Agent' }), + }) + + expect(res.status).toBe(503) + expect((await res.json()).error).toContain('SERVICE_API_KEY is required in production') + }) + it('enforces scoped agentd API keys when configured', async () => { process.env.AGENTD_API_KEYS = JSON.stringify([ { key: 'evidence-key', scopes: ['agentd:evidence:write'] }, @@ -224,6 +238,33 @@ describe('Agentd Service Routes', () => { }) describe('FIDES v2 local API aliases', () => { + it('creates, lists, and shows local identities without returning private keys', async () => { + const created = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Calendar Agent' }), + }) + expect(created.status).toBe(201) + const createdData = await created.json() + expect(createdData.identity.did).toMatch(/^did:fides:/) + expect(createdData.privateKeyHex).toBeUndefined() + expect(createdData.identity.metadata.name).toBe('Calendar Agent') + + const listed = await app.request('/identities') + expect(listed.status).toBe(200) + const listedData = await listed.json() + expect(listedData.identities).toEqual(expect.arrayContaining([ + expect.objectContaining({ did: createdData.identity.did, type: 'agent' }), + ])) + expect(JSON.stringify(listedData)).not.toContain('privateKeyHex') + + const shown = await app.request(`/identities/${encodeURIComponent(createdData.identity.did)}`) + expect(shown.status).toBe(200) + const shownData = await shown.json() + expect(shownData.identity.did).toBe(createdData.identity.did) + expect(shownData.privateKeyHex).toBeUndefined() + }) + it('serves local DHT publish and find endpoints', async () => { const publish = await app.request('/dht/publish', { method: 'POST', From 492261c6a8a9c65d31ffd8d095c7edd7b21c2ea4 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:53:36 +0300 Subject: [PATCH 027/282] feat(api): add local agent card endpoints --- docs/api-reference.md | 11 +++ docs/sdk-reference.md | 9 +- packages/sdk/README.md | 10 +- packages/sdk/test/fides-client.test.ts | 35 +++++++ services/agentd/src/index.ts | 128 +++++++++++++++++++++++++ services/agentd/test/routes.test.ts | 51 ++++++++++ 6 files changed, 241 insertions(+), 3 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 4ca6dc5..483fc48 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -13,6 +13,10 @@ Current implementation anchors: - `POST /identities` - `GET /identities` - `GET /identities/:id` +- `POST /agent-cards` +- `POST /agent-cards/:id/sign` +- `POST /agent-cards/:id/verify` +- `GET /agent-cards/:id` - `POST /v1/policy/evaluate` - `POST /v1/sessions` - `GET /v1/sessions/:id` @@ -45,3 +49,10 @@ and returns only public identity data. Private keys are retained inside the daemon process and are not returned by `POST /identities`, `GET /identities`, or `GET /identities/:id`. This route is protected by the same production API-key fail-closed behavior as other mutating `agentd` routes. + +`POST /agent-cards` creates local in-memory AgentCards bound to local daemon +identities. `POST /agent-cards/:id/sign` signs the stored card with the local +agent identity key using the canonical AgentCard signing model, and +`POST /agent-cards/:id/verify` verifies the signed card when present. These +routes are prototype-local until the daemon storage layer is migrated to +durable SQLite-backed identity/card storage. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index ebb9920..c8b4858 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -18,14 +18,19 @@ const identity = await client.identity.createAgent({ name: 'Invoice Agent' }) const identities = await client.identity.list() const sameIdentity = await client.identity.show(identity.identity.did) const card = await client.cards.create({ + identity: identity.identity, name: 'Invoice Agent', capabilities: [{ id: 'invoice.reconcile', riskClass: 'medium' }], }) -await client.cards.sign(card as { id: string }) +await client.cards.sign({ id: identity.identity.did }) +await client.cards.verify(identity.identity.did) +await client.cards.get(identity.identity.did) await client.agents.register(card as Record) const results = await client.discovery.find({ capability: 'invoice.reconcile' }) ``` `identity.createAgent`, `identity.list`, and `identity.show` target the root `agentd` identity API. The API does not return private keys. The facade is -intentionally thin. Advanced authority flows can use `AgentdClient`. +intentionally thin. The AgentCard helpers target root `agentd` AgentCard +endpoints and use daemon-held local identity keys for signing. Advanced +authority flows can use `AgentdClient`. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 4e46074..2f67f87 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -52,10 +52,18 @@ const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) const identity = await client.identity.createAgent({ name: 'Invoice Agent' }) const identities = await client.identity.list() const sameIdentity = await client.identity.show(identity.identity.did) + +const card = await client.cards.create({ + identity: identity.identity, + name: 'Invoice Agent', + capabilities: [{ id: 'invoice.reconcile', requiredScopes: ['invoice:read'] }], +}) +const signed = await client.cards.sign({ id: identity.identity.did }) +const verified = await client.cards.verify(identity.identity.did) ``` The local identity API returns public identity data only; it does not return -private keys. +private keys. AgentCard signing uses the daemon-held local identity key. ```typescript import { AgentdClient } from '@fides/sdk' diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 6bcd45c..77025f8 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -72,4 +72,39 @@ describe('FidesClient', () => { ]) expect((calls[0].init?.headers as Headers).get('X-API-Key')).toBe('sdk-key') }) + + it('uses the root AgentCard API served by local agentd', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) + if (String(url).endsWith('/agent-cards/card_1/sign')) { + return new Response(JSON.stringify({ signed: { payload: { id: 'card_1' }, proof: {} } }), { status: 200 }) + } + if (String(url).endsWith('/agent-cards/card_1/verify')) { + return new Response(JSON.stringify({ valid: true }), { status: 200 }) + } + if (String(url).endsWith('/agent-cards/card_1')) { + return new Response(JSON.stringify({ card: { id: 'card_1' } }), { status: 200 }) + } + return new Response(JSON.stringify({ card: { id: 'card_1' }, validation: { valid: true } }), { status: 201 }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + + await expect(client.cards.create({ identity: { did: 'did:fides:agent' }, capabilities: [] })).resolves.toMatchObject({ + card: { id: 'card_1' }, + }) + await expect(client.cards.sign({ id: 'card_1' })).resolves.toMatchObject({ + signed: { payload: { id: 'card_1' } }, + }) + await expect(client.cards.verify('card_1')).resolves.toMatchObject({ valid: true }) + await expect(client.cards.get('card_1')).resolves.toMatchObject({ card: { id: 'card_1' } }) + + expect(calls.map(call => call.url)).toEqual([ + 'http://localhost:7345/agent-cards', + 'http://localhost:7345/agent-cards/card_1/sign', + 'http://localhost:7345/agent-cards/card_1/verify', + 'http://localhost:7345/agent-cards/card_1', + ]) + }) }) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 9e1604c..81afdfc 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -26,17 +26,22 @@ import { createIncidentRecordV2, createPrincipalIdentity, createPublisherIdentity, + signAgentCard, + validateAgentCard, + verifySignedAgentCard, verifyDelegationTokenSignature, verifyDomainDid, evaluateInvocationPreflight, verifyIncidentRecord, verifyRevocationRecord, type AgentIdentity, + type AgentCard, type IncidentRecord, type DelegationToken, type PrincipalIdentity, type PublisherIdentity, type RevocationRecord, + type SignedAgentCard, } from '@fides/core' import { createAuthorityStore } from './storage.js' import type { @@ -71,6 +76,8 @@ interface LocalIdentityRecord { createdAt: string } const localIdentities = new Map() +const localAgentCards = new Map() +const localSignedAgentCards = new Map() const fullDemoSteps = [ 'initialize_daemon', 'create_principal_identity', @@ -237,6 +244,16 @@ app.use('/identities/*', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/agent-cards', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/agent-cards/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.post('*', rateLimitMiddleware({ maxRequests: 100, windowMs: 60_000 })) app.get('*', rateLimitMiddleware({ maxRequests: 300, windowMs: 60_000 })) app.use('*', bodyLimit({ maxSize: 1024 * 1024 })) @@ -322,6 +339,117 @@ app.get('/identities/:id', (c) => { return c.json(safeIdentityRecord(record)) }) +// ─── Root FIDES v2 AgentCard API ───────────────────────────────── +app.post('/agent-cards', async (c) => { + const body = await c.req.json().catch(() => ({})) + const did = typeof body.agentId === 'string' + ? body.agentId + : typeof body.agent_id === 'string' + ? body.agent_id + : typeof body.identity?.did === 'string' + ? body.identity.did + : undefined + if (!did) { + return c.json({ error: 'identity.did or agentId is required' }, 400) + } + + const localIdentity = localIdentities.get(did) + if (!localIdentity || localIdentity.type !== 'agent') { + return c.json({ error: 'agent identity not found in local daemon', did }, 404) + } + + const capabilities = Array.isArray(body.capabilities) + ? body.capabilities.map((capability: Record) => createCapabilityDescriptor({ + id: String(capability.id), + name: typeof capability.name === 'string' ? capability.name : undefined, + description: typeof capability.description === 'string' ? capability.description : undefined, + inputSchema: typeof capability.inputSchema === 'object' && capability.inputSchema !== null ? capability.inputSchema as any : undefined, + outputSchema: typeof capability.outputSchema === 'object' && capability.outputSchema !== null ? capability.outputSchema as any : undefined, + riskLevel: typeof capability.riskLevel === 'string' ? capability.riskLevel as any : undefined, + requiredScopes: Array.isArray(capability.requiredScopes) ? capability.requiredScopes.map(String) : undefined, + supportedControls: Array.isArray(capability.supportedControls) ? capability.supportedControls as any : undefined, + supportsDryRun: typeof capability.supportsDryRun === 'boolean' ? capability.supportsDryRun : undefined, + supportsHumanApproval: typeof capability.supportsHumanApproval === 'boolean' ? capability.supportsHumanApproval : undefined, + supportsPolicyProof: typeof capability.supportsPolicyProof === 'boolean' ? capability.supportsPolicyProof : undefined, + })) + : [] + + const now = new Date().toISOString() + const card: AgentCard = { + schema_version: 'fides.agent_card.v1', + id: did, + agent_id: did, + identity: { + ...(localIdentity.identity as AgentIdentity), + metadata: { + ...(localIdentity.identity as AgentIdentity).metadata, + ...(typeof body.name === 'string' && { name: body.name }), + }, + }, + capabilities, + endpoints: Array.isArray(body.endpoints) ? body.endpoints : [], + policies: Array.isArray(body.policies) + ? body.policies + : [{ requiresRuntimeAttestation: false, requiresApproval: false }], + protocolVersions: Array.isArray(body.protocolVersions) ? body.protocolVersions.map(String) : ['fides.v2.0'], + createdAt: now, + updatedAt: now, + ...(typeof body.expiresAt === 'string' && { expiresAt: body.expiresAt }), + } + + const validation = validateAgentCard(card) + if (!validation.valid) { + return c.json({ validation, card }, 400) + } + + localAgentCards.set(card.id, card) + localSignedAgentCards.delete(card.id) + return c.json({ card, validation }, 201) +}) + +app.post('/agent-cards/:id/sign', async (c) => { + const id = c.req.param('id') + const card = localAgentCards.get(id) + if (!card) { + return c.json({ error: 'AgentCard not found', id }, 404) + } + const identity = localIdentities.get(card.identity.did) + if (!identity) { + return c.json({ error: 'AgentCard identity key not found', did: card.identity.did }, 404) + } + + const signed = await signAgentCard(card, Buffer.from(identity.privateKeyHex, 'hex'), card.identity.did) + localSignedAgentCards.set(card.id, signed) + return c.json({ signed }) +}) + +app.post('/agent-cards/:id/verify', async (c) => { + const id = c.req.param('id') + const signed = localSignedAgentCards.get(id) + if (signed) { + return c.json({ valid: await verifySignedAgentCard(signed), signed: true }) + } + + const card = localAgentCards.get(id) + if (!card) { + return c.json({ valid: false, error: 'AgentCard not found', id }, 404) + } + const validation = validateAgentCard(card) + return c.json({ valid: validation.valid, signed: false, validation }) +}) + +app.get('/agent-cards/:id', (c) => { + const id = c.req.param('id') + const card = localAgentCards.get(id) + if (!card) { + return c.json({ error: 'AgentCard not found', id }, 404) + } + return c.json({ + card, + signed: localSignedAgentCards.get(id) ?? null, + }) +}) + // ─── FIDES v2 Local API Aliases ─────────────────────────────────── app.post('/dht/start', (c) => { return c.json({ started: true, mode: 'in_memory_simulator', pointers: localDhtPointers.length }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index c1798fe..cfa52ec 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -155,6 +155,20 @@ describe('Agentd Service Routes', () => { expect((await res.json()).error).toContain('SERVICE_API_KEY is required in production') }) + it('fails closed for root AgentCard creation in production when API key is not configured', async () => { + process.env.NODE_ENV = 'production' + delete process.env.SERVICE_API_KEY + + const res = await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: { did: 'did:fides:agent' }, capabilities: [] }), + }) + + expect(res.status).toBe(503) + expect((await res.json()).error).toContain('SERVICE_API_KEY is required in production') + }) + it('enforces scoped agentd API keys when configured', async () => { process.env.AGENTD_API_KEYS = JSON.stringify([ { key: 'evidence-key', scopes: ['agentd:evidence:write'] }, @@ -265,6 +279,43 @@ describe('Agentd Service Routes', () => { expect(shownData.privateKeyHex).toBeUndefined() }) + it('creates, signs, verifies, and reads local AgentCards', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Invoice Agent' }), + }) + const { identity } = await identityResponse.json() + + const created = await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + name: 'Invoice Agent', + capabilities: [{ id: 'invoice.reconcile', requiredScopes: ['invoice:read'] }], + }), + }) + expect(created.status).toBe(201) + const createdData = await created.json() + expect(createdData.card.id).toBe(identity.did) + expect(createdData.card.capabilities[0].id).toBe('invoice.reconcile') + expect(createdData.validation.valid).toBe(true) + + const signed = await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + expect(signed.status).toBe(200) + const signedData = await signed.json() + expect(signedData.signed.proof.type).toBe('Ed25519Signature2024') + + const verified = await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/verify`, { method: 'POST' }) + expect(verified.status).toBe(200) + expect((await verified.json()).valid).toBe(true) + + const fetched = await app.request(`/agent-cards/${encodeURIComponent(identity.did)}`) + expect(fetched.status).toBe(200) + expect((await fetched.json()).card.id).toBe(identity.did) + }) + it('serves local DHT publish and find endpoints', async () => { const publish = await app.request('/dht/publish', { method: 'POST', From 64e408712b46aa742eb7a04e940fe56621af72fa Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 00:57:38 +0300 Subject: [PATCH 028/282] feat(api): add local agent registration discovery --- docs/api-reference.md | 11 +++ docs/sdk-reference.md | 10 +- packages/sdk/README.md | 7 ++ packages/sdk/test/fides-client.test.ts | 37 +++++++ services/agentd/src/index.ts | 127 +++++++++++++++++++++++++ services/agentd/test/routes.test.ts | 48 ++++++++++ 6 files changed, 237 insertions(+), 3 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 483fc48..22d9b24 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -17,6 +17,10 @@ Current implementation anchors: - `POST /agent-cards/:id/sign` - `POST /agent-cards/:id/verify` - `GET /agent-cards/:id` +- `POST /agents/register` +- `GET /agents` +- `GET /agents/:id` +- `POST /discover` - `POST /v1/policy/evaluate` - `POST /v1/sessions` - `GET /v1/sessions/:id` @@ -56,3 +60,10 @@ agent identity key using the canonical AgentCard signing model, and `POST /agent-cards/:id/verify` verifies the signed card when present. These routes are prototype-local until the daemon storage layer is migrated to durable SQLite-backed identity/card storage. + +`POST /agents/register` registers a locally stored AgentCard as a discovery +candidate. `GET /agents` and `GET /agents/:id` expose local registration state +and the associated AgentCard. `POST /discover` searches registered local agents +by capability. Discovery responses always include `authorityGranted: false`; +discovery is candidate resolution only, and invocation authority still requires +policy evaluation and scoped session grants. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index c8b4858..1b0f949 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -25,12 +25,16 @@ const card = await client.cards.create({ await client.cards.sign({ id: identity.identity.did }) await client.cards.verify(identity.identity.did) await client.cards.get(identity.identity.did) -await client.agents.register(card as Record) +await client.agents.register({ agentCardId: identity.identity.did }) +await client.agents.list() +await client.agents.inspect(identity.identity.did) const results = await client.discovery.find({ capability: 'invoice.reconcile' }) ``` `identity.createAgent`, `identity.list`, and `identity.show` target the root `agentd` identity API. The API does not return private keys. The facade is intentionally thin. The AgentCard helpers target root `agentd` AgentCard -endpoints and use daemon-held local identity keys for signing. Advanced -authority flows can use `AgentdClient`. +endpoints and use daemon-held local identity keys for signing. Agent +registration and discovery return candidates only; `authorityGranted` remains +`false` until policy evaluation and session grant issuance. Advanced authority +flows can use `AgentdClient`. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 2f67f87..5e60bf7 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -60,10 +60,17 @@ const card = await client.cards.create({ }) const signed = await client.cards.sign({ id: identity.identity.did }) const verified = await client.cards.verify(identity.identity.did) + +await client.agents.register({ agentCardId: identity.identity.did }) +const agents = await client.agents.list() +const candidateAgent = await client.agents.inspect(identity.identity.did) +const candidates = await client.discovery.find({ capability: 'invoice.reconcile' }) ``` The local identity API returns public identity data only; it does not return private keys. AgentCard signing uses the daemon-held local identity key. +Registration and discovery produce candidate records only; discovery does not +grant authority to invoke the agent. ```typescript import { AgentdClient } from '@fides/sdk' diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 77025f8..ce8ae43 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -107,4 +107,41 @@ describe('FidesClient', () => { 'http://localhost:7345/agent-cards/card_1', ]) }) + + it('uses root agent registration and discovery APIs served by local agentd', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) + if (String(url).endsWith('/agents/register')) { + return new Response(JSON.stringify({ registered: true, agentId: 'did:fides:agent', authorityGranted: false }), { status: 201 }) + } + if (String(url).endsWith('/agents/did%3Afides%3Aagent')) { + return new Response(JSON.stringify({ agentId: 'did:fides:agent', card: {} }), { status: 200 }) + } + if (String(url).endsWith('/agents')) { + return new Response(JSON.stringify({ agents: [{ agentId: 'did:fides:agent' }] }), { status: 200 }) + } + return new Response(JSON.stringify({ authorityGranted: false, candidates: [{ agentId: 'did:fides:agent' }] }), { status: 200 }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + + await expect(client.agents.register({ agentCardId: 'did:fides:agent' })).resolves.toMatchObject({ + registered: true, + authorityGranted: false, + }) + await expect(client.agents.list()).resolves.toMatchObject({ agents: [{ agentId: 'did:fides:agent' }] }) + await expect(client.agents.inspect('did:fides:agent')).resolves.toMatchObject({ agentId: 'did:fides:agent' }) + await expect(client.discovery.find({ capability: 'invoice.reconcile' })).resolves.toMatchObject({ + authorityGranted: false, + candidates: [{ agentId: 'did:fides:agent' }], + }) + + expect(calls.map(call => call.url)).toEqual([ + 'http://localhost:7345/agents/register', + 'http://localhost:7345/agents', + 'http://localhost:7345/agents/did%3Afides%3Aagent', + 'http://localhost:7345/discover', + ]) + }) }) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 81afdfc..e03f6d1 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -78,6 +78,13 @@ interface LocalIdentityRecord { const localIdentities = new Map() const localAgentCards = new Map() const localSignedAgentCards = new Map() +interface LocalRegisteredAgent { + agentId: string + cardId: string + registeredAt: string + signed: boolean +} +const localAgents = new Map() const fullDemoSteps = [ 'initialize_daemon', 'create_principal_identity', @@ -200,6 +207,16 @@ function safeIdentityRecord(record: LocalIdentityRecord): Record { + const card = localAgentCards.get(record.cardId) + return { + ...record, + signed: localSignedAgentCards.has(record.cardId) || record.signed, + capabilities: card?.capabilities.map(capability => capability.id) ?? [], + authorityGranted: false, + } +} + // Global middleware stack app.use('*', metricsMiddleware(collector)) app.use('*', logger()) @@ -254,6 +271,21 @@ app.use('/agent-cards/*', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/agents', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/agents/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/discover', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.post('*', rateLimitMiddleware({ maxRequests: 100, windowMs: 60_000 })) app.get('*', rateLimitMiddleware({ maxRequests: 300, windowMs: 60_000 })) app.use('*', bodyLimit({ maxSize: 1024 * 1024 })) @@ -450,6 +482,101 @@ app.get('/agent-cards/:id', (c) => { }) }) +// ─── Root FIDES v2 Agent Registration and Discovery API ────────── +app.post('/agents/register', async (c) => { + const body = await c.req.json().catch(() => ({})) + const cardId = typeof body.agentCardId === 'string' + ? body.agentCardId + : typeof body.cardId === 'string' + ? body.cardId + : typeof body.id === 'string' + ? body.id + : typeof body.agent_id === 'string' + ? body.agent_id + : undefined + if (!cardId) { + return c.json({ error: 'agentCardId is required' }, 400) + } + + const card = localAgentCards.get(cardId) + if (!card) { + return c.json({ error: 'AgentCard not found', cardId }, 404) + } + + const record: LocalRegisteredAgent = { + agentId: card.identity.did, + cardId: card.id, + registeredAt: new Date().toISOString(), + signed: localSignedAgentCards.has(card.id), + } + localAgents.set(record.agentId, record) + + return c.json({ + registered: true, + ...safeRegisteredAgent(record), + reason: 'registration records a candidate only; it does not grant invocation authority', + }, 201) +}) + +app.get('/agents', (c) => { + return c.json({ + agents: Array.from(localAgents.values()).map(safeRegisteredAgent), + authorityGranted: false, + }) +}) + +app.get('/agents/:id', (c) => { + const id = c.req.param('id') + const record = localAgents.get(id) + if (!record) { + return c.json({ error: 'agent not registered', id }, 404) + } + + return c.json({ + ...safeRegisteredAgent(record), + card: localAgentCards.get(record.cardId) ?? null, + signedCard: localSignedAgentCards.get(record.cardId) ?? null, + }) +}) + +app.post('/discover', async (c) => { + const body = await c.req.json().catch(() => ({})) + const capability = typeof body.capability === 'string' ? body.capability : undefined + if (!capability) { + return c.json({ error: 'capability is required' }, 400) + } + + const candidates = Array.from(localAgents.values()).flatMap((record) => { + const card = localAgentCards.get(record.cardId) + if (!card) return [] + + const descriptor = card.capabilities.find(candidate => candidate.id === capability) + if (!descriptor) return [] + + return [{ + agentId: record.agentId, + cardId: record.cardId, + capability, + signed: localSignedAgentCards.has(record.cardId), + authorityGranted: false, + descriptor, + card, + reasons: [ + 'candidate_matched_capability', + 'discovery_does_not_grant_authority', + ], + }] + }) + + return c.json({ + query: body, + candidates, + count: candidates.length, + authorityGranted: false, + explanation: 'Discovery returns candidates only. Policy evaluation and scoped session grants are required before invocation.', + }) +}) + // ─── FIDES v2 Local API Aliases ─────────────────────────────────── app.post('/dht/start', (c) => { return c.json({ started: true, mode: 'in_memory_simulator', pointers: localDhtPointers.length }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index cfa52ec..46dbd9f 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -316,6 +316,54 @@ describe('Agentd Service Routes', () => { expect((await fetched.json()).card.id).toBe(identity.did) }) + it('registers local agents and discovers candidates by capability without granting authority', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Invoice Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: 'invoice.reconcile', requiredScopes: ['invoice:read'] }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + + const registered = await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + expect(registered.status).toBe(201) + expect((await registered.json()).authorityGranted).toBe(false) + + const listed = await app.request('/agents') + expect(listed.status).toBe(200) + expect((await listed.json()).agents).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentId: identity.did }), + ])) + + const discovered = await app.request('/discover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'invoice.reconcile' }), + }) + expect(discovered.status).toBe(200) + const discoveredData = await discovered.json() + expect(discoveredData.authorityGranted).toBe(false) + expect(discoveredData.candidates).toEqual(expect.arrayContaining([ + expect.objectContaining({ + agentId: identity.did, + capability: 'invoice.reconcile', + signed: true, + }), + ])) + }) + it('serves local DHT publish and find endpoints', async () => { const publish = await app.request('/dht/publish', { method: 'POST', From 6d42ac932bd8f6fe979ff5cb7ab839d17072a23a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:01:19 +0300 Subject: [PATCH 029/282] feat(api): add local trust reputation policy endpoints --- docs/api-reference.md | 14 ++ docs/sdk-reference.md | 21 ++- packages/sdk/README.md | 19 ++- packages/sdk/src/fides-client.ts | 4 + packages/sdk/test/fides-client.test.ts | 4 + services/agentd/src/index.ts | 204 ++++++++++++++++++++++++- services/agentd/test/routes.test.ts | 74 +++++++++ 7 files changed, 336 insertions(+), 4 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 22d9b24..21c0822 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -21,6 +21,11 @@ Current implementation anchors: - `GET /agents` - `GET /agents/:id` - `POST /discover` +- `POST /trust/evaluate` +- `GET /trust/:agentId` +- `POST /reputation/update` +- `GET /reputation/:agentId` +- `POST /policy/evaluate` - `POST /v1/policy/evaluate` - `POST /v1/sessions` - `GET /v1/sessions/:id` @@ -67,3 +72,12 @@ and the associated AgentCard. `POST /discover` searches registered local agents by capability. Discovery responses always include `authorityGranted: false`; discovery is candidate resolution only, and invocation authority still requires policy evaluation and scoped session grants. + +`POST /trust/evaluate` computes a local capability-scoped trust result for a +registered candidate. `POST /reputation/update` stores capability-specific +reputation signals, and `GET /reputation/:agentId` returns those local records. +`POST /policy/evaluate` runs the FIDES v2 policy evaluator against the local +candidate, trust result, requested scopes, and runtime/revocation/incident +flags. Trust and reputation are signals only; policy decisions still do not +execute capabilities and allowed decisions require a scoped SessionGrant before +invocation. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 1b0f949..8d5a2be 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -29,6 +29,22 @@ await client.agents.register({ agentCardId: identity.identity.did }) await client.agents.list() await client.agents.inspect(identity.identity.did) const results = await client.discovery.find({ capability: 'invoice.reconcile' }) +const trust = await client.trust.evaluate({ + agentId: identity.identity.did, + capability: 'invoice.reconcile', +}) +const reputation = await client.reputation.update({ + agentId: identity.identity.did, + capability: 'invoice.reconcile', + successfulInvocations: 3, +}) +const policy = await client.policy.evaluate({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.identity.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], +}) ``` `identity.createAgent`, `identity.list`, and `identity.show` target the root @@ -36,5 +52,6 @@ const results = await client.discovery.find({ capability: 'invoice.reconcile' }) intentionally thin. The AgentCard helpers target root `agentd` AgentCard endpoints and use daemon-held local identity keys for signing. Agent registration and discovery return candidates only; `authorityGranted` remains -`false` until policy evaluation and session grant issuance. Advanced authority -flows can use `AgentdClient`. +`false`. Trust and reputation APIs return capability-scoped signals, and policy +evaluation explains the decision but still requires session grant issuance +before invocation. Advanced authority flows can use `AgentdClient`. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 5e60bf7..90ccc81 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -65,12 +65,29 @@ await client.agents.register({ agentCardId: identity.identity.did }) const agents = await client.agents.list() const candidateAgent = await client.agents.inspect(identity.identity.did) const candidates = await client.discovery.find({ capability: 'invoice.reconcile' }) +const trust = await client.trust.evaluate({ + agentId: identity.identity.did, + capability: 'invoice.reconcile', +}) +const reputation = await client.reputation.update({ + agentId: identity.identity.did, + capability: 'invoice.reconcile', + successfulInvocations: 3, +}) +const policy = await client.policy.evaluate({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.identity.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], +}) ``` The local identity API returns public identity data only; it does not return private keys. AgentCard signing uses the daemon-held local identity key. Registration and discovery produce candidate records only; discovery does not -grant authority to invoke the agent. +grant authority to invoke the agent. Trust and reputation are capability-scoped +signals; policy decisions still require scoped session grants before invocation. ```typescript import { AgentdClient } from '@fides/sdk' diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index aa7c0a1..2eeca17 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -43,6 +43,10 @@ export class FidesClient { get: (agentId: string) => this.get(`/reputation/${encodeURIComponent(agentId)}`), } + readonly policy = { + evaluate: (body: Record) => this.post('/policy/evaluate', body), + } + readonly sessions = { request: (body: Record) => this.post('/sessions', body), verify: (sessionId: string) => this.post(`/sessions/${encodeURIComponent(sessionId)}/verify`, {}), diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index ce8ae43..4750421 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -24,6 +24,8 @@ describe('FidesClient', () => { await client.agents.register({ id: 'card_1' }) await client.discovery.find({ capability: 'invoice.reconcile' }) await client.trust.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + await client.reputation.update({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + await client.policy.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.sessions.request({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.invoke({ sessionId: 'sess_1', input: { invoiceId: 'inv_123' } }) @@ -34,6 +36,8 @@ describe('FidesClient', () => { 'http://localhost:4817/agents/register', 'http://localhost:4817/discover', 'http://localhost:4817/trust/evaluate', + 'http://localhost:4817/reputation/update', + 'http://localhost:4817/policy/evaluate', 'http://localhost:4817/sessions', 'http://localhost:4817/invoke', ]) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index e03f6d1..b582913 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -13,7 +13,7 @@ import { resolveTxt } from 'node:dns/promises' import { rateLimitMiddleware, MetricsCollector, metricsMiddleware } from '@fides/sdk' import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' -import { evaluatePolicy, type PolicyBundle } from '@fides/policy' +import { evaluateFidesPolicy, evaluatePolicy, type PolicyBundle } from '@fides/policy' import { createTrustContext, evaluateGuard } from '@fides/guard' import { aggregateIncidentImpact, @@ -40,8 +40,10 @@ import { type DelegationToken, type PrincipalIdentity, type PublisherIdentity, + type ReputationRecord, type RevocationRecord, type SignedAgentCard, + type TrustResult, } from '@fides/core' import { createAuthorityStore } from './storage.js' import type { @@ -85,6 +87,8 @@ interface LocalRegisteredAgent { signed: boolean } const localAgents = new Map() +const localTrustResults = new Map() +const localReputationRecords = new Map() const fullDemoSteps = [ 'initialize_daemon', 'create_principal_identity', @@ -217,6 +221,55 @@ function safeRegisteredAgent(record: LocalRegisteredAgent): Record candidate.id === capabilityId) + if (!capability) return undefined + + return { record, card, capability } +} + +function computeLocalTrustResult(agentId: string, capabilityId: string): TrustResult | undefined { + const found = findLocalCapability(agentId, capabilityId) + if (!found) return undefined + + const signed = localSignedAgentCards.has(found.record.cardId) + const reputation = localReputationRecords.get(localCapabilityKey(agentId, capabilityId)) + const highRisk = found.capability.riskLevel === 'high' || found.capability.riskLevel === 'critical' + + const trust = computeTrustResult({ + agentId, + capability: found.capability, + components: { + identity: signed ? 1 : 0.65, + publisher: 0.5, + trustAnchors: 0.2, + capabilityFit: 1, + evidence: reputation ? Math.min(1, reputation.score + 0.2) : 0.3, + policyCompliance: 0.7, + runtimeSafety: highRisk ? 0.2 : 0.8, + peerAttestation: 0.2, + incidentPenalty: reputation ? Math.min(1, reputation.incident_count * 0.18) : 0, + noveltyPenalty: reputation ? Math.max(0, 0.4 - reputation.score) : 0.35, + contextBoundaryPenalty: reputation?.context_boundary_penalty ?? 0, + }, + }) + localTrustResults.set(localCapabilityKey(agentId, capabilityId), trust) + return trust +} + // Global middleware stack app.use('*', metricsMiddleware(collector)) app.use('*', logger()) @@ -286,6 +339,21 @@ app.use('/discover', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/trust/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/reputation/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/policy/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.post('*', rateLimitMiddleware({ maxRequests: 100, windowMs: 60_000 })) app.get('*', rateLimitMiddleware({ maxRequests: 300, windowMs: 60_000 })) app.use('*', bodyLimit({ maxSize: 1024 * 1024 })) @@ -577,6 +645,140 @@ app.post('/discover', async (c) => { }) }) +app.post('/trust/evaluate', async (c) => { + const body = await c.req.json().catch(() => ({})) + const agentId = typeof body.agentId === 'string' + ? body.agentId + : typeof body.agent_id === 'string' + ? body.agent_id + : typeof body.targetAgentId === 'string' + ? body.targetAgentId + : undefined + const capability = typeof body.capability === 'string' + ? body.capability + : typeof body.capabilityId === 'string' + ? body.capabilityId + : undefined + + if (!agentId || !capability) { + return c.json({ error: 'agentId and capability are required' }, 400) + } + + const trust = computeLocalTrustResult(agentId, capability) + if (!trust) { + return c.json({ error: 'registered agent capability not found', agentId, capability }, 404) + } + + return c.json({ + trust, + authorityGranted: false, + explanation: 'Trust is a signal only. Policy evaluation and scoped session grants are required before invocation.', + }) +}) + +app.get('/trust/:agentId', (c) => { + const agentId = c.req.param('agentId') + const trust = Array.from(localTrustResults.values()).filter(record => record.agent_id === agentId) + return c.json({ agentId, trust, authorityGranted: false }) +}) + +app.post('/reputation/update', async (c) => { + const body = await c.req.json().catch(() => ({})) + const agentId = typeof body.agentId === 'string' + ? body.agentId + : typeof body.agent_id === 'string' + ? body.agent_id + : undefined + const capability = typeof body.capability === 'string' + ? body.capability + : typeof body.capabilityId === 'string' + ? body.capabilityId + : undefined + + if (!agentId || !capability) { + return c.json({ error: 'agentId and capability are required' }, 400) + } + + const found = findLocalCapability(agentId, capability) + if (!found) { + return c.json({ error: 'registered agent capability not found', agentId, capability }, 404) + } + + const reputation = computeCapabilityReputation({ + agentId, + publisherId: typeof body.publisherId === 'string' ? body.publisherId : undefined, + principalId: typeof body.principalId === 'string' ? body.principalId : undefined, + capability, + successfulInvocations: typeof body.successfulInvocations === 'number' ? body.successfulInvocations : undefined, + failedInvocations: typeof body.failedInvocations === 'number' ? body.failedInvocations : undefined, + incidentCount: typeof body.incidentCount === 'number' ? body.incidentCount : undefined, + publisherWeight: typeof body.publisherWeight === 'number' ? body.publisherWeight : undefined, + contextBoundaryMismatch: typeof body.contextBoundaryMismatch === 'boolean' ? body.contextBoundaryMismatch : undefined, + }) + localReputationRecords.set(localCapabilityKey(agentId, capability), reputation) + + return c.json({ reputation, authorityGranted: false }) +}) + +app.get('/reputation/:agentId', (c) => { + const agentId = c.req.param('agentId') + const reputations = Array.from(localReputationRecords.values()).filter(record => record.agent_id === agentId) + return c.json({ agentId, reputations, authorityGranted: false }) +}) + +app.post('/policy/evaluate', async (c) => { + const body = await c.req.json().catch(() => ({})) + const targetAgentId = typeof body.targetAgentId === 'string' + ? body.targetAgentId + : typeof body.agentId === 'string' + ? body.agentId + : typeof body.agent_id === 'string' + ? body.agent_id + : undefined + const capabilityId = typeof body.capability === 'string' + ? body.capability + : typeof body.capabilityId === 'string' + ? body.capabilityId + : undefined + + if (!targetAgentId || !capabilityId) { + return c.json({ error: 'agentId and capability are required' }, 400) + } + + const found = findLocalCapability(targetAgentId, capabilityId) + if (!found) { + return c.json({ error: 'registered agent capability not found', agentId: targetAgentId, capability: capabilityId }, 404) + } + + const trustResult = computeLocalTrustResult(targetAgentId, capabilityId) + if (!trustResult) { + return c.json({ error: 'trust result unavailable', agentId: targetAgentId, capability: capabilityId }, 404) + } + + const policy = evaluateFidesPolicy({ + principalId: typeof body.principalId === 'string' ? body.principalId : 'did:fides:principal:local', + requesterAgentId: typeof body.requesterAgentId === 'string' ? body.requesterAgentId : 'did:fides:requester:local', + targetAgentId, + capability: found.capability, + trustResult, + requestedScopes: Array.isArray(body.requestedScopes) ? body.requestedScopes.map(String) : [], + runtimeAttestationValid: typeof body.runtimeAttestationValid === 'boolean' ? body.runtimeAttestationValid : undefined, + revocationActive: typeof body.revocationActive === 'boolean' ? body.revocationActive : undefined, + killSwitchActive: typeof body.killSwitchActive === 'boolean' ? body.killSwitchActive : undefined, + incidentsActive: typeof body.incidentsActive === 'boolean' ? body.incidentsActive : undefined, + approvalGranted: typeof body.approvalGranted === 'boolean' ? body.approvalGranted : undefined, + evidenceRefs: Array.isArray(body.evidenceRefs) ? body.evidenceRefs.map(String) : undefined, + }) + + return c.json({ + policy, + trust: trustResult, + authorityGranted: false, + requiresSessionGrant: policy.decision === 'allow', + explanation: 'Policy decisions do not execute capabilities. Allowed decisions require a scoped SessionGrant before invocation.', + }) +}) + // ─── FIDES v2 Local API Aliases ─────────────────────────────────── app.post('/dht/start', (c) => { return c.json({ started: true, mode: 'in_memory_simulator', pointers: localDhtPointers.length }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 46dbd9f..dbf3e7d 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -364,6 +364,80 @@ describe('Agentd Service Routes', () => { ])) }) + it('evaluates root trust, reputation, and policy for a registered local candidate', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Calendar Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ + id: 'calendar.schedule', + riskLevel: 'low', + requiredScopes: ['calendar:write'], + }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const trust = await app.request('/trust/evaluate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentId: identity.did, capability: 'calendar.schedule' }), + }) + expect(trust.status).toBe(200) + const trustData = await trust.json() + expect(trustData.trust.agent_id).toBe(identity.did) + expect(trustData.trust.capability).toBe('calendar.schedule') + expect(trustData.authorityGranted).toBe(false) + + const reputation = await app.request('/reputation/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: identity.did, + capability: 'calendar.schedule', + successfulInvocations: 3, + failedInvocations: 1, + }), + }) + expect(reputation.status).toBe(200) + expect((await reputation.json()).reputation.capability).toBe('calendar.schedule') + + const reputationRecord = await app.request(`/reputation/${encodeURIComponent(identity.did)}`) + expect(reputationRecord.status).toBe(200) + expect((await reputationRecord.json()).reputations).toEqual(expect.arrayContaining([ + expect.objectContaining({ capability: 'calendar.schedule' }), + ])) + + const policy = await app.request('/policy/evaluate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.did, + capability: 'calendar.schedule', + requestedScopes: ['calendar:write'], + }), + }) + expect(policy.status).toBe(200) + const policyData = await policy.json() + expect(policyData.policy.decision).toBe('allow') + expect(policyData.authorityGranted).toBe(false) + expect(policyData.requiresSessionGrant).toBe(true) + }) + it('serves local DHT publish and find endpoints', async () => { const publish = await app.request('/dht/publish', { method: 'POST', From d8400cab63a22530f971e2aa67ddbf7fe5cc152f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:05:05 +0300 Subject: [PATCH 030/282] feat(api): add local session invocation endpoints --- docs/api-reference.md | 11 ++ docs/sdk-reference.md | 14 +- packages/sdk/README.md | 13 ++ packages/sdk/test/fides-client.test.ts | 19 ++- services/agentd/src/index.ts | 180 ++++++++++++++++++++++++- services/agentd/test/routes.test.ts | 61 +++++++++ 6 files changed, 295 insertions(+), 3 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 21c0822..0879e6c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -26,6 +26,10 @@ Current implementation anchors: - `POST /reputation/update` - `GET /reputation/:agentId` - `POST /policy/evaluate` +- `POST /sessions` +- `GET /sessions/:id` +- `POST /sessions/:id/verify` +- `POST /invoke` - `POST /v1/policy/evaluate` - `POST /v1/sessions` - `GET /v1/sessions/:id` @@ -81,3 +85,10 @@ candidate, trust result, requested scopes, and runtime/revocation/incident flags. Trust and reputation are signals only; policy decisions still do not execute capabilities and allowed decisions require a scoped SessionGrant before invocation. + +`POST /sessions` issues a local `SessionGrant` only after policy allows or +limits the action to dry-run. `POST /invoke` verifies the session, runs the +policy preflight path, validates the capability context, and returns an +`InvocationResult`. The current root implementation is in-memory and intended +for local daemon DX; durable storage and signed invocation results remain +follow-up hardening work. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 8d5a2be..503e1c9 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -45,6 +45,17 @@ const policy = await client.policy.evaluate({ capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], }) +const session = await client.sessions.request({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.identity.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], +}) +const invocation = await client.invoke({ + sessionId: session.session.session_id, + input: { invoiceId: 'inv_123' }, +}) ``` `identity.createAgent`, `identity.list`, and `identity.show` target the root @@ -54,4 +65,5 @@ endpoints and use daemon-held local identity keys for signing. Agent registration and discovery return candidates only; `authorityGranted` remains `false`. Trust and reputation APIs return capability-scoped signals, and policy evaluation explains the decision but still requires session grant issuance -before invocation. Advanced authority flows can use `AgentdClient`. +before invocation. Session request and invocation helpers use the same root +local daemon API. Advanced authority flows can use `AgentdClient`. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 90ccc81..411fc24 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -81,6 +81,17 @@ const policy = await client.policy.evaluate({ capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], }) +const session = await client.sessions.request({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.identity.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], +}) +const invocation = await client.invoke({ + sessionId: session.session.session_id, + input: { invoiceId: 'inv_123' }, +}) ``` The local identity API returns public identity data only; it does not return @@ -88,6 +99,8 @@ private keys. AgentCard signing uses the daemon-held local identity key. Registration and discovery produce candidate records only; discovery does not grant authority to invoke the agent. Trust and reputation are capability-scoped signals; policy decisions still require scoped session grants before invocation. +Root session and invocation helpers use the local daemon preflight path and are +currently in-memory. ```typescript import { AgentdClient } from '@fides/sdk' diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 4750421..ad15547 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -27,6 +27,8 @@ describe('FidesClient', () => { await client.reputation.update({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.policy.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.sessions.request({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + await client.sessions.get('sess_1') + await client.sessions.verify('sess_1') await client.invoke({ sessionId: 'sess_1', input: { invoiceId: 'inv_123' } }) expect(calls.map(call => call.url)).toEqual([ @@ -39,9 +41,24 @@ describe('FidesClient', () => { 'http://localhost:4817/reputation/update', 'http://localhost:4817/policy/evaluate', 'http://localhost:4817/sessions', + 'http://localhost:4817/sessions/sess_1', + 'http://localhost:4817/sessions/sess_1/verify', 'http://localhost:4817/invoke', ]) - expect(calls.every(call => call.init?.method === 'POST')).toBe(true) + expect(calls.map(call => call.init?.method)).toEqual([ + 'POST', + 'POST', + 'POST', + 'POST', + 'POST', + 'POST', + 'POST', + 'POST', + 'POST', + 'GET', + 'POST', + 'POST', + ]) }) it('uses the root identity API served by local agentd', async () => { diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index b582913..2d66ca5 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -13,7 +13,7 @@ import { resolveTxt } from 'node:dns/promises' import { rateLimitMiddleware, MetricsCollector, metricsMiddleware } from '@fides/sdk' import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' -import { evaluateFidesPolicy, evaluatePolicy, type PolicyBundle } from '@fides/policy' +import { evaluateFidesPolicy, evaluatePolicy, type FidesPolicyDecision, type PolicyBundle } from '@fides/policy' import { createTrustContext, evaluateGuard } from '@fides/guard' import { aggregateIncidentImpact, @@ -24,8 +24,12 @@ import { computeTrustResult, createCapabilityDescriptor, createIncidentRecordV2, + createInvocationRequest, + createInvocationResult, createPrincipalIdentity, createPublisherIdentity, + createSessionGrantV2, + hashProtocolPayload, signAgentCard, validateAgentCard, verifySignedAgentCard, @@ -42,6 +46,7 @@ import { type PublisherIdentity, type ReputationRecord, type RevocationRecord, + type SessionGrantV2, type SignedAgentCard, type TrustResult, } from '@fides/core' @@ -89,6 +94,12 @@ interface LocalRegisteredAgent { const localAgents = new Map() const localTrustResults = new Map() const localReputationRecords = new Map() +interface LocalSessionRecord { + session: SessionGrantV2 + policy: FidesPolicyDecision + trust: TrustResult +} +const localSessionGrants = new Map() const fullDemoSteps = [ 'initialize_daemon', 'create_principal_identity', @@ -354,6 +365,21 @@ app.use('/policy/*', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/sessions', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/sessions/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/invoke', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.post('*', rateLimitMiddleware({ maxRequests: 100, windowMs: 60_000 })) app.get('*', rateLimitMiddleware({ maxRequests: 300, windowMs: 60_000 })) app.use('*', bodyLimit({ maxSize: 1024 * 1024 })) @@ -779,6 +805,158 @@ app.post('/policy/evaluate', async (c) => { }) }) +app.post('/sessions', async (c) => { + const body = await c.req.json().catch(() => ({})) + const targetAgentId = typeof body.targetAgentId === 'string' + ? body.targetAgentId + : typeof body.agentId === 'string' + ? body.agentId + : typeof body.agent_id === 'string' + ? body.agent_id + : undefined + const capabilityId = typeof body.capability === 'string' + ? body.capability + : typeof body.capabilityId === 'string' + ? body.capabilityId + : undefined + + if (!targetAgentId || !capabilityId) { + return c.json({ error: 'agentId and capability are required' }, 400) + } + + const found = findLocalCapability(targetAgentId, capabilityId) + if (!found) { + return c.json({ error: 'registered agent capability not found', agentId: targetAgentId, capability: capabilityId }, 404) + } + + const trust = computeLocalTrustResult(targetAgentId, capabilityId) + if (!trust) { + return c.json({ error: 'trust result unavailable', agentId: targetAgentId, capability: capabilityId }, 404) + } + + const requestedScopes = Array.isArray(body.requestedScopes) ? body.requestedScopes.map(String) : [] + const policy = evaluateFidesPolicy({ + principalId: typeof body.principalId === 'string' ? body.principalId : 'did:fides:principal:local', + requesterAgentId: typeof body.requesterAgentId === 'string' ? body.requesterAgentId : 'did:fides:requester:local', + targetAgentId, + capability: found.capability, + trustResult: trust, + requestedScopes, + runtimeAttestationValid: typeof body.runtimeAttestationValid === 'boolean' ? body.runtimeAttestationValid : undefined, + approvalGranted: typeof body.approvalGranted === 'boolean' ? body.approvalGranted : undefined, + }) + + if (policy.decision !== 'allow' && policy.decision !== 'dry_run_only') { + return c.json({ + authorized: false, + authorityGranted: false, + policy, + trust, + }, 409) + } + + const expiresAt = typeof body.expiresAt === 'string' + ? body.expiresAt + : new Date(Date.now() + 60 * 60 * 1000).toISOString() + const session = createSessionGrantV2({ + requesterAgentId: typeof body.requesterAgentId === 'string' ? body.requesterAgentId : 'did:fides:requester:local', + targetAgentId, + principalId: typeof body.principalId === 'string' ? body.principalId : 'did:fides:principal:local', + capability: capabilityId, + scopes: requestedScopes, + constraints: typeof body.constraints === 'object' && body.constraints !== null ? body.constraints as Record : {}, + policyHash: hashProtocolPayload(policy), + trustResultHash: hashProtocolPayload(trust), + audience: Array.isArray(body.audience) ? body.audience.map(String) : [targetAgentId], + issuer: 'did:fides:agentd:local', + expiresAt, + }) + localSessionGrants.set(session.session_id, { session, policy, trust }) + + return c.json({ + authorized: true, + authorityGranted: policy.decision === 'allow', + session, + policy, + trust, + }, 201) +}) + +app.get('/sessions/:id', (c) => { + const record = localSessionGrants.get(c.req.param('id')) + if (!record) { + return c.json({ error: 'session not found' }, 404) + } + return c.json({ session: record.session, policy: record.policy, trust: record.trust }) +}) + +app.post('/sessions/:id/verify', (c) => { + const record = localSessionGrants.get(c.req.param('id')) + if (!record) { + return c.json({ valid: false, error: 'session not found' }, 404) + } + + return c.json({ + valid: new Date(record.session.expires_at).getTime() > Date.now(), + session: record.session, + }) +}) + +app.post('/invoke', async (c) => { + const body = await c.req.json().catch(() => ({})) + const sessionId = typeof body.sessionId === 'string' + ? body.sessionId + : typeof body.session_id === 'string' + ? body.session_id + : undefined + if (!sessionId) { + return c.json({ error: 'sessionId is required' }, 400) + } + + const record = localSessionGrants.get(sessionId) + if (!record) { + return c.json({ error: 'session not found', sessionId }, 404) + } + + if (new Date(record.session.expires_at).getTime() <= Date.now()) { + return c.json({ error: 'session expired', sessionId, authorityGranted: false }, 409) + } + + const found = findLocalCapability(record.session.target_agent_id, record.session.capability) + if (!found) { + return c.json({ error: 'registered agent capability not found', sessionId, authorityGranted: false }, 404) + } + + const request = createInvocationRequest({ + issuer: record.session.requester_agent_id, + sessionGrant: record.session, + input: body.input ?? {}, + dryRun: typeof body.dryRun === 'boolean' ? body.dryRun : false, + inputSchema: found.capability.inputSchema, + outputSchema: found.capability.outputSchema, + }) + const preflight = evaluateInvocationPreflight({ + request, + policyDecision: record.policy, + }) + const result = createInvocationResult({ + issuer: record.session.target_agent_id, + invocationRequestId: request.id, + status: preflight.can_execute ? 'completed' : preflight.status, + output: preflight.can_execute ? { ok: true, capability: record.session.capability } : undefined, + errorCode: preflight.can_execute ? undefined : preflight.reason_codes[0], + evidenceRefs: [`evt_${request.id}`], + }) + + return c.json({ + authorityGranted: preflight.can_execute, + session: record.session, + request, + preflight, + result, + }) +}) + // ─── FIDES v2 Local API Aliases ─────────────────────────────────── app.post('/dht/start', (c) => { return c.json({ started: true, mode: 'in_memory_simulator', pointers: localDhtPointers.length }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index dbf3e7d..5c98045 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -438,6 +438,67 @@ describe('Agentd Service Routes', () => { expect(policyData.requiresSessionGrant).toBe(true) }) + it('issues root scoped sessions and invokes capabilities through policy preflight', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Invoice Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ + id: 'invoice.reconcile', + riskLevel: 'medium', + requiredScopes: ['invoice:read'], + }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const session = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], + }), + }) + expect(session.status).toBe(201) + const sessionData = await session.json() + expect(sessionData.authorityGranted).toBe(true) + expect(sessionData.session.capability).toBe('invoice.reconcile') + + const fetched = await app.request(`/sessions/${sessionData.session.session_id}`) + expect(fetched.status).toBe(200) + expect((await fetched.json()).session.session_id).toBe(sessionData.session.session_id) + + const invocation = await app.request('/invoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionData.session.session_id, + input: { invoiceId: 'inv_123' }, + }), + }) + expect(invocation.status).toBe(200) + const invocationData = await invocation.json() + expect(invocationData.preflight.can_execute).toBe(true) + expect(invocationData.result.status).toBe('completed') + expect(invocationData.authorityGranted).toBe(true) + }) + it('serves local DHT publish and find endpoints', async () => { const publish = await app.request('/dht/publish', { method: 'POST', From 672b5c0094c1676c51639e9f866bfeba91fdc389 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:11:09 +0300 Subject: [PATCH 031/282] feat(api): add local approvals kill switch endpoints --- docs/api-reference.md | 15 ++ docs/sdk-reference.md | 22 ++- packages/sdk/README.md | 21 ++- packages/sdk/src/fides-client.ts | 17 ++ packages/sdk/test/fides-client.test.ts | 21 +++ services/agentd/src/index.ts | 232 ++++++++++++++++++++++++- services/agentd/test/routes.test.ts | 93 ++++++++++ 7 files changed, 415 insertions(+), 6 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 0879e6c..d70207f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -30,6 +30,13 @@ Current implementation anchors: - `GET /sessions/:id` - `POST /sessions/:id/verify` - `POST /invoke` +- `POST /approvals` +- `GET /approvals` +- `POST /approvals/:id/approve` +- `POST /approvals/:id/deny` +- `POST /killswitch` +- `GET /killswitch` +- `DELETE /killswitch/:id` - `POST /v1/policy/evaluate` - `POST /v1/sessions` - `GET /v1/sessions/:id` @@ -92,3 +99,11 @@ policy preflight path, validates the capability context, and returns an `InvocationResult`. The current root implementation is in-memory and intended for local daemon DX; durable storage and signed invocation results remain follow-up hardening work. + +`POST /approvals` creates an approval request and records approval decisions +through `/approvals/:id/approve` or `/approvals/:id/deny`. Approval records do +not grant authority by themselves; they are inputs to policy/session issuance. +`POST /killswitch` creates an active kill switch rule for an agent, publisher, +capability, session, principal, or risk class. Active kill switch rules override +normal trust and policy evaluation and block root session issuance until +disabled with `DELETE /killswitch/:id`. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 503e1c9..3b1c386 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -45,6 +45,24 @@ const policy = await client.policy.evaluate({ capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], }) +const approval = await client.approvals.create({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.identity.did, + capability: 'payments.prepare', + requestedScopes: ['payments:prepare'], + riskLevel: 'high', +}) +await client.approvals.approve(approval.approval.id, { + approverId: 'did:fides:approver', +}) +const killSwitch = await client.killSwitch.enable({ + issuer: 'did:fides:operator', + targetType: 'capability', + target: 'deploy.preview', + reason: 'Pause preview deploys during incident response.', +}) +await client.killSwitch.disable(killSwitch.rule.id) const session = await client.sessions.request({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', @@ -66,4 +84,6 @@ registration and discovery return candidates only; `authorityGranted` remains `false`. Trust and reputation APIs return capability-scoped signals, and policy evaluation explains the decision but still requires session grant issuance before invocation. Session request and invocation helpers use the same root -local daemon API. Advanced authority flows can use `AgentdClient`. +local daemon API. Approval and kill switch helpers expose local authority +controls, with kill switch rules overriding normal policy while active. +Advanced authority flows can use `AgentdClient`. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 411fc24..1507c35 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -81,6 +81,24 @@ const policy = await client.policy.evaluate({ capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], }) +const approval = await client.approvals.create({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.identity.did, + capability: 'payments.prepare', + requestedScopes: ['payments:prepare'], + riskLevel: 'high', +}) +await client.approvals.approve(approval.approval.id, { + approverId: 'did:fides:approver', +}) +const killSwitch = await client.killSwitch.enable({ + issuer: 'did:fides:operator', + targetType: 'capability', + target: 'deploy.preview', + reason: 'Pause preview deploys during incident response.', +}) +await client.killSwitch.disable(killSwitch.rule.id) const session = await client.sessions.request({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', @@ -100,7 +118,8 @@ Registration and discovery produce candidate records only; discovery does not grant authority to invoke the agent. Trust and reputation are capability-scoped signals; policy decisions still require scoped session grants before invocation. Root session and invocation helpers use the local daemon preflight path and are -currently in-memory. +currently in-memory. Approval and kill switch helpers expose local authority +controls, with active kill switch rules overriding normal policy. ```typescript import { AgentdClient } from '@fides/sdk' diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 2eeca17..2785103 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -47,6 +47,19 @@ export class FidesClient { evaluate: (body: Record) => this.post('/policy/evaluate', body), } + readonly approvals = { + create: (body: Record) => this.post('/approvals', body), + list: () => this.get('/approvals'), + approve: (approvalId: string, body: Record = {}) => this.post(`/approvals/${encodeURIComponent(approvalId)}/approve`, body), + deny: (approvalId: string, body: Record = {}) => this.post(`/approvals/${encodeURIComponent(approvalId)}/deny`, body), + } + + readonly killSwitch = { + enable: (body: Record) => this.post('/killswitch', body), + list: () => this.get('/killswitch'), + disable: (ruleId: string) => this.delete(`/killswitch/${encodeURIComponent(ruleId)}`), + } + readonly sessions = { request: (body: Record) => this.post('/sessions', body), verify: (sessionId: string) => this.post(`/sessions/${encodeURIComponent(sessionId)}/verify`, {}), @@ -71,6 +84,10 @@ export class FidesClient { }) } + private async delete(path: string): Promise { + return this.request(path, { method: 'DELETE' }) + } + private async request(path: string, init: RequestInit): Promise { const headers = new Headers(init.headers) if (this.options.apiKey) { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index ad15547..776827f 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -26,6 +26,13 @@ describe('FidesClient', () => { await client.trust.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.reputation.update({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.policy.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + await client.approvals.create({ agentId: 'did:fides:agent', capability: 'payments.prepare' }) + await client.approvals.list() + await client.approvals.approve('approval_1', { approverId: 'did:fides:approver' }) + await client.approvals.deny('approval_1', { approverId: 'did:fides:approver' }) + await client.killSwitch.enable({ targetType: 'capability', target: 'deploy.preview' }) + await client.killSwitch.list() + await client.killSwitch.disable('rule_1') await client.sessions.request({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.sessions.get('sess_1') await client.sessions.verify('sess_1') @@ -40,6 +47,13 @@ describe('FidesClient', () => { 'http://localhost:4817/trust/evaluate', 'http://localhost:4817/reputation/update', 'http://localhost:4817/policy/evaluate', + 'http://localhost:4817/approvals', + 'http://localhost:4817/approvals', + 'http://localhost:4817/approvals/approval_1/approve', + 'http://localhost:4817/approvals/approval_1/deny', + 'http://localhost:4817/killswitch', + 'http://localhost:4817/killswitch', + 'http://localhost:4817/killswitch/rule_1', 'http://localhost:4817/sessions', 'http://localhost:4817/sessions/sess_1', 'http://localhost:4817/sessions/sess_1/verify', @@ -58,6 +72,13 @@ describe('FidesClient', () => { 'GET', 'POST', 'POST', + 'POST', + 'GET', + 'DELETE', + 'POST', + 'GET', + 'POST', + 'POST', ]) }) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 2d66ca5..a1ffa6d 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -22,14 +22,18 @@ import { createAgentIdentity, computeCapabilityReputation, computeTrustResult, + createApprovalDecision, + createApprovalRequest, createCapabilityDescriptor, createIncidentRecordV2, createInvocationRequest, createInvocationResult, + createKillSwitchRule, createPrincipalIdentity, createPublisherIdentity, createSessionGrantV2, hashProtocolPayload, + isKillSwitchRuleActive, signAgentCard, validateAgentCard, verifySignedAgentCard, @@ -40,7 +44,10 @@ import { verifyRevocationRecord, type AgentIdentity, type AgentCard, + type ApprovalDecision, + type ApprovalRequest, type IncidentRecord, + type KillSwitchRule, type DelegationToken, type PrincipalIdentity, type PublisherIdentity, @@ -94,6 +101,9 @@ interface LocalRegisteredAgent { const localAgents = new Map() const localTrustResults = new Map() const localReputationRecords = new Map() +const localApprovals = new Map() +const localApprovalDecisions = new Map() +const localKillSwitchRules = new Map() interface LocalSessionRecord { session: SessionGrantV2 policy: FidesPolicyDecision @@ -281,6 +291,25 @@ function computeLocalTrustResult(agentId: string, capabilityId: string): TrustRe return trust } +function activeLocalKillSwitchFor(input: { + agentId: string + capability: string + principalId: string + requesterAgentId: string + riskLevel: string + sessionId?: string +}): KillSwitchRule | undefined { + return Array.from(localKillSwitchRules.values()).find((rule) => { + if (!isKillSwitchRuleActive(rule)) return false + if (rule.target_type === 'agent') return rule.target === input.agentId + if (rule.target_type === 'capability') return rule.target === input.capability + if (rule.target_type === 'principal') return rule.target === input.principalId + if (rule.target_type === 'session') return rule.target === input.sessionId + if (rule.target_type === 'risk_class') return rule.target === input.riskLevel + return false + }) +} + // Global middleware stack app.use('*', metricsMiddleware(collector)) app.use('*', logger()) @@ -380,6 +409,26 @@ app.use('/invoke', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/approvals', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/approvals/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/killswitch', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/killswitch/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.post('*', rateLimitMiddleware({ maxRequests: 100, windowMs: 60_000 })) app.get('*', rateLimitMiddleware({ maxRequests: 300, windowMs: 60_000 })) app.use('*', bodyLimit({ maxSize: 1024 * 1024 })) @@ -835,13 +884,23 @@ app.post('/sessions', async (c) => { } const requestedScopes = Array.isArray(body.requestedScopes) ? body.requestedScopes.map(String) : [] + const principalId = typeof body.principalId === 'string' ? body.principalId : 'did:fides:principal:local' + const requesterAgentId = typeof body.requesterAgentId === 'string' ? body.requesterAgentId : 'did:fides:requester:local' + const activeKillSwitch = activeLocalKillSwitchFor({ + agentId: targetAgentId, + capability: capabilityId, + principalId, + requesterAgentId, + riskLevel: found.capability.riskLevel, + }) const policy = evaluateFidesPolicy({ - principalId: typeof body.principalId === 'string' ? body.principalId : 'did:fides:principal:local', - requesterAgentId: typeof body.requesterAgentId === 'string' ? body.requesterAgentId : 'did:fides:requester:local', + principalId, + requesterAgentId, targetAgentId, capability: found.capability, trustResult: trust, requestedScopes, + killSwitchActive: activeKillSwitch !== undefined, runtimeAttestationValid: typeof body.runtimeAttestationValid === 'boolean' ? body.runtimeAttestationValid : undefined, approvalGranted: typeof body.approvalGranted === 'boolean' ? body.approvalGranted : undefined, }) @@ -852,6 +911,7 @@ app.post('/sessions', async (c) => { authorityGranted: false, policy, trust, + killSwitch: activeKillSwitch, }, 409) } @@ -859,9 +919,9 @@ app.post('/sessions', async (c) => { ? body.expiresAt : new Date(Date.now() + 60 * 60 * 1000).toISOString() const session = createSessionGrantV2({ - requesterAgentId: typeof body.requesterAgentId === 'string' ? body.requesterAgentId : 'did:fides:requester:local', + requesterAgentId, targetAgentId, - principalId: typeof body.principalId === 'string' ? body.principalId : 'did:fides:principal:local', + principalId, capability: capabilityId, scopes: requestedScopes, constraints: typeof body.constraints === 'object' && body.constraints !== null ? body.constraints as Record : {}, @@ -957,6 +1017,170 @@ app.post('/invoke', async (c) => { }) }) +app.post('/approvals', async (c) => { + const body = await c.req.json().catch(() => ({})) + const requesterAgentId = typeof body.requesterAgentId === 'string' ? body.requesterAgentId : 'did:fides:requester:local' + const targetAgentId = typeof body.targetAgentId === 'string' + ? body.targetAgentId + : typeof body.agentId === 'string' + ? body.agentId + : 'did:fides:agent:local' + const principalId = typeof body.principalId === 'string' ? body.principalId : 'did:fides:principal:local' + const capability = typeof body.capability === 'string' + ? body.capability + : typeof body.capabilityId === 'string' + ? body.capabilityId + : undefined + if (!capability) { + return c.json({ error: 'capability is required' }, 400) + } + + const approval = createApprovalRequest({ + requesterAgentId, + targetAgentId, + principalId, + capability, + requestedScopes: Array.isArray(body.requestedScopes) ? body.requestedScopes.map(String) : [], + riskLevel: body.riskLevel === 'low' || body.riskLevel === 'medium' || body.riskLevel === 'high' || body.riskLevel === 'critical' + ? body.riskLevel + : 'high', + policyDecisionHash: typeof body.policyDecisionHash === 'string' ? body.policyDecisionHash : undefined, + evidenceRefs: Array.isArray(body.evidenceRefs) ? body.evidenceRefs.map(String) : [], + expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : undefined, + }) + localApprovals.set(approval.id, approval) + + return c.json({ + approval, + authorityGranted: false, + explanation: 'Approval records human authorization intent; it does not grant invocation authority without policy and a scoped SessionGrant.', + }, 201) +}) + +app.get('/approvals', (c) => { + return c.json({ + approvals: Array.from(localApprovals.values()), + decisions: Array.from(localApprovalDecisions.values()), + authorityGranted: false, + }) +}) + +app.post('/approvals/:id/approve', async (c) => { + const id = c.req.param('id') + const approval = localApprovals.get(id) + if (!approval) { + return c.json({ error: 'approval request not found', id }, 404) + } + + const body = await c.req.json().catch(() => ({})) + const decision = createApprovalDecision({ + approvalRequestId: id, + approverId: typeof body.approverId === 'string' ? body.approverId : 'did:fides:approver:local', + decision: 'approved', + reason: typeof body.reason === 'string' ? body.reason : 'Approved', + constraints: typeof body.constraints === 'object' && body.constraints !== null ? body.constraints as Record : {}, + evidenceRefs: Array.isArray(body.evidenceRefs) ? body.evidenceRefs.map(String) : [], + }) + const updated: ApprovalRequest = { ...approval, status: 'approved' } + localApprovals.set(id, updated) + localApprovalDecisions.set(decision.id, decision) + + return c.json({ + approval: updated, + decision, + authorityGranted: false, + explanation: 'Approval has been recorded. A policy evaluation and scoped SessionGrant are still required before invocation.', + }) +}) + +app.post('/approvals/:id/deny', async (c) => { + const id = c.req.param('id') + const approval = localApprovals.get(id) + if (!approval) { + return c.json({ error: 'approval request not found', id }, 404) + } + + const body = await c.req.json().catch(() => ({})) + const decision = createApprovalDecision({ + approvalRequestId: id, + approverId: typeof body.approverId === 'string' ? body.approverId : 'did:fides:approver:local', + decision: 'denied', + reason: typeof body.reason === 'string' ? body.reason : 'Denied', + constraints: typeof body.constraints === 'object' && body.constraints !== null ? body.constraints as Record : {}, + evidenceRefs: Array.isArray(body.evidenceRefs) ? body.evidenceRefs.map(String) : [], + }) + const updated: ApprovalRequest = { ...approval, status: 'denied' } + localApprovals.set(id, updated) + localApprovalDecisions.set(decision.id, decision) + + return c.json({ + approval: updated, + decision, + authorityGranted: false, + }) +}) + +app.post('/killswitch', async (c) => { + const body = await c.req.json().catch(() => ({})) + const targetType = typeof body.targetType === 'string' + ? body.targetType + : typeof body.target_type === 'string' + ? body.target_type + : undefined + if ( + targetType !== 'agent' && + targetType !== 'publisher' && + targetType !== 'capability' && + targetType !== 'session' && + targetType !== 'principal' && + targetType !== 'risk_class' + ) { + return c.json({ error: 'targetType must be agent, publisher, capability, session, principal, or risk_class' }, 400) + } + + const target = typeof body.target === 'string' ? body.target : undefined + if (!target) { + return c.json({ error: 'target is required' }, 400) + } + + const rule = createKillSwitchRule({ + issuer: typeof body.issuer === 'string' ? body.issuer : 'did:fides:operator:local', + targetType, + target, + reason: typeof body.reason === 'string' ? body.reason : 'No reason provided', + enabled: typeof body.enabled === 'boolean' ? body.enabled : true, + expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : undefined, + }) + localKillSwitchRules.set(rule.id, rule) + + return c.json({ + rule, + authorityOverride: true, + explanation: 'Kill switch rules override normal trust and policy evaluation while active.', + }, 201) +}) + +app.get('/killswitch', (c) => { + const rules = Array.from(localKillSwitchRules.values()) + return c.json({ + rules, + active: rules.filter(rule => isKillSwitchRuleActive(rule)), + }) +}) + +app.delete('/killswitch/:id', (c) => { + const id = c.req.param('id') + const rule = localKillSwitchRules.get(id) + if (!rule) { + return c.json({ error: 'kill switch rule not found', id }, 404) + } + const { payload_hash: _payloadHash, ...rulePayload } = rule + const disabledPayload = { ...rulePayload, enabled: false } + const disabled: KillSwitchRule = { ...disabledPayload, payload_hash: hashProtocolPayload(disabledPayload) } + localKillSwitchRules.set(id, disabled) + return c.json({ rule: disabled }) +}) + // ─── FIDES v2 Local API Aliases ─────────────────────────────────── app.post('/dht/start', (c) => { return c.json({ started: true, mode: 'in_memory_simulator', pointers: localDhtPointers.length }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 5c98045..5a135d8 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -499,6 +499,99 @@ describe('Agentd Service Routes', () => { expect(invocationData.authorityGranted).toBe(true) }) + it('serves root approval request and decision lifecycle', async () => { + const request = await app.request('/approvals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: 'did:fides:agent', + capability: 'payments.prepare', + requestedScopes: ['payments:prepare'], + riskLevel: 'high', + }), + }) + expect(request.status).toBe(201) + const requestData = await request.json() + expect(requestData.approval.status).toBe('pending') + expect(requestData.authorityGranted).toBe(false) + + const listed = await app.request('/approvals') + expect(listed.status).toBe(200) + expect((await listed.json()).approvals).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: requestData.approval.id, status: 'pending' }), + ])) + + const approved = await app.request(`/approvals/${requestData.approval.id}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approverId: 'did:fides:approver', reason: 'Manual approval for dry-run payment preparation.' }), + }) + expect(approved.status).toBe(200) + const approvedData = await approved.json() + expect(approvedData.approval.status).toBe('approved') + expect(approvedData.decision.decision).toBe('approved') + expect(approvedData.authorityGranted).toBe(false) + }) + + it('serves root kill switch rules and blocks scoped session issuance', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Deploy Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: 'deploy.preview', riskLevel: 'medium', requiredScopes: ['deploy:preview'] }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const enabled = await app.request('/killswitch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + issuer: 'did:fides:operator', + targetType: 'capability', + target: 'deploy.preview', + reason: 'Pause preview deploys during incident response.', + }), + }) + expect(enabled.status).toBe(201) + const enabledData = await enabled.json() + expect(enabledData.rule.enabled).toBe(true) + + const blocked = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.did, + capability: 'deploy.preview', + requestedScopes: ['deploy:preview'], + }), + }) + expect(blocked.status).toBe(409) + const blockedData = await blocked.json() + expect(blockedData.policy.reason_codes).toContain('KILL_SWITCH_ACTIVE') + expect(blockedData.authorityGranted).toBe(false) + + const disabled = await app.request(`/killswitch/${enabledData.rule.id}`, { method: 'DELETE' }) + expect(disabled.status).toBe(200) + expect((await disabled.json()).rule.enabled).toBe(false) + }) + it('serves local DHT publish and find endpoints', async () => { const publish = await app.request('/dht/publish', { method: 'POST', From 581d5855ab83bc08b626b9f75a122cb32eccbb34 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:15:26 +0300 Subject: [PATCH 032/282] feat(api): add local revocation incident endpoints --- docs/api-reference.md | 14 ++ docs/sdk-reference.md | 17 ++ packages/sdk/README.md | 19 ++- packages/sdk/src/fides-client.ts | 13 ++ packages/sdk/test/fides-client.test.ts | 21 +++ services/agentd/src/index.ts | 211 +++++++++++++++++++++++++ services/agentd/test/routes.test.ts | 132 ++++++++++++++++ 7 files changed, 426 insertions(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index d70207f..be99de3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -37,6 +37,13 @@ Current implementation anchors: - `POST /killswitch` - `GET /killswitch` - `DELETE /killswitch/:id` +- `POST /revocations` +- `GET /revocations` +- `GET /revocations/:id` +- `POST /incidents` +- `GET /incidents` +- `GET /incidents/:id` +- `POST /incidents/:id/resolve` - `POST /v1/policy/evaluate` - `POST /v1/sessions` - `GET /v1/sessions/:id` @@ -107,3 +114,10 @@ not grant authority by themselves; they are inputs to policy/session issuance. capability, session, principal, or risk class. Active kill switch rules override normal trust and policy evaluation and block root session issuance until disabled with `DELETE /killswitch/:id`. + +`POST /revocations` creates a local FIDES v2 revocation record for keys, +identities, agents, AgentCards, capabilities, sessions, attestations, or +publishers. Active matching revocations override normal policy and block root +session issuance. `POST /incidents` records an open incident against a target +agent; open incidents require policy review for matching session requests until +resolved with `POST /incidents/:id/resolve`. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 3b1c386..ed0ade4 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -63,6 +63,21 @@ const killSwitch = await client.killSwitch.enable({ reason: 'Pause preview deploys during incident response.', }) await client.killSwitch.disable(killSwitch.rule.id) +const revocation = await client.revocations.create({ + issuer: 'did:fides:operator', + targetType: 'agent', + targetId: identity.identity.did, + reason: 'Compromised deployment key.', +}) +await client.revocations.get(revocation.record.id) +const incident = await client.incidents.report({ + reporter: 'did:fides:principal', + targetAgentId: identity.identity.did, + severity: 'high', + category: 'unauthorized_action', + description: 'Attempted invocation outside delegated authority.', +}) +await client.incidents.resolve(incident.record.id, { status: 'resolved' }) const session = await client.sessions.request({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', @@ -86,4 +101,6 @@ evaluation explains the decision but still requires session grant issuance before invocation. Session request and invocation helpers use the same root local daemon API. Approval and kill switch helpers expose local authority controls, with kill switch rules overriding normal policy while active. +Revocation and incident helpers expose local governance records that feed root +session policy decisions. Advanced authority flows can use `AgentdClient`. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 1507c35..7b45e4e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -99,6 +99,21 @@ const killSwitch = await client.killSwitch.enable({ reason: 'Pause preview deploys during incident response.', }) await client.killSwitch.disable(killSwitch.rule.id) +const revocation = await client.revocations.create({ + issuer: 'did:fides:operator', + targetType: 'agent', + targetId: identity.identity.did, + reason: 'Compromised deployment key.', +}) +await client.revocations.get(revocation.record.id) +const incident = await client.incidents.report({ + reporter: 'did:fides:principal', + targetAgentId: identity.identity.did, + severity: 'high', + category: 'unauthorized_action', + description: 'Attempted invocation outside delegated authority.', +}) +await client.incidents.resolve(incident.record.id, { status: 'resolved' }) const session = await client.sessions.request({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', @@ -119,7 +134,9 @@ grant authority to invoke the agent. Trust and reputation are capability-scoped signals; policy decisions still require scoped session grants before invocation. Root session and invocation helpers use the local daemon preflight path and are currently in-memory. Approval and kill switch helpers expose local authority -controls, with active kill switch rules overriding normal policy. +controls, with active kill switch rules overriding normal policy. Revocation +and incident helpers expose local governance records that feed root session +policy decisions. ```typescript import { AgentdClient } from '@fides/sdk' diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 2785103..a471d3e 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -60,6 +60,19 @@ export class FidesClient { disable: (ruleId: string) => this.delete(`/killswitch/${encodeURIComponent(ruleId)}`), } + readonly revocations = { + create: (body: Record) => this.post('/revocations', body), + list: () => this.get('/revocations'), + get: (recordId: string) => this.get(`/revocations/${encodeURIComponent(recordId)}`), + } + + readonly incidents = { + report: (body: Record) => this.post('/incidents', body), + list: () => this.get('/incidents'), + get: (recordId: string) => this.get(`/incidents/${encodeURIComponent(recordId)}`), + resolve: (recordId: string, body: Record = {}) => this.post(`/incidents/${encodeURIComponent(recordId)}/resolve`, body), + } + readonly sessions = { request: (body: Record) => this.post('/sessions', body), verify: (sessionId: string) => this.post(`/sessions/${encodeURIComponent(sessionId)}/verify`, {}), diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 776827f..8653f67 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -33,6 +33,13 @@ describe('FidesClient', () => { await client.killSwitch.enable({ targetType: 'capability', target: 'deploy.preview' }) await client.killSwitch.list() await client.killSwitch.disable('rule_1') + await client.revocations.create({ targetType: 'agent', targetId: 'did:fides:agent' }) + await client.revocations.list() + await client.revocations.get('rev_1') + await client.incidents.report({ targetAgentId: 'did:fides:agent', severity: 'high', category: 'unauthorized_action', description: 'test' }) + await client.incidents.list() + await client.incidents.get('inc_1') + await client.incidents.resolve('inc_1', { status: 'resolved' }) await client.sessions.request({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.sessions.get('sess_1') await client.sessions.verify('sess_1') @@ -54,6 +61,13 @@ describe('FidesClient', () => { 'http://localhost:4817/killswitch', 'http://localhost:4817/killswitch', 'http://localhost:4817/killswitch/rule_1', + 'http://localhost:4817/revocations', + 'http://localhost:4817/revocations', + 'http://localhost:4817/revocations/rev_1', + 'http://localhost:4817/incidents', + 'http://localhost:4817/incidents', + 'http://localhost:4817/incidents/inc_1', + 'http://localhost:4817/incidents/inc_1/resolve', 'http://localhost:4817/sessions', 'http://localhost:4817/sessions/sess_1', 'http://localhost:4817/sessions/sess_1/verify', @@ -77,6 +91,13 @@ describe('FidesClient', () => { 'DELETE', 'POST', 'GET', + 'GET', + 'POST', + 'GET', + 'GET', + 'POST', + 'POST', + 'GET', 'POST', 'POST', ]) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index a1ffa6d..156d7b7 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -31,9 +31,11 @@ import { createKillSwitchRule, createPrincipalIdentity, createPublisherIdentity, + createRevocationRecordV2, createSessionGrantV2, hashProtocolPayload, isKillSwitchRuleActive, + resolveIncidentRecordV2, signAgentCard, validateAgentCard, verifySignedAgentCard, @@ -47,12 +49,14 @@ import { type ApprovalDecision, type ApprovalRequest, type IncidentRecord, + type IncidentRecordV2, type KillSwitchRule, type DelegationToken, type PrincipalIdentity, type PublisherIdentity, type ReputationRecord, type RevocationRecord, + type RevocationRecordV2, type SessionGrantV2, type SignedAgentCard, type TrustResult, @@ -104,6 +108,8 @@ const localReputationRecords = new Map() const localApprovals = new Map() const localApprovalDecisions = new Map() const localKillSwitchRules = new Map() +const localRevocationRecords = new Map() +const localIncidentRecords = new Map() interface LocalSessionRecord { session: SessionGrantV2 policy: FidesPolicyDecision @@ -310,6 +316,34 @@ function activeLocalKillSwitchFor(input: { }) } +function isActiveLocalRevocation(record: RevocationRecordV2): boolean { + if (record.status !== 'active') return false + return !record.expires_at || new Date(record.expires_at).getTime() > Date.now() +} + +function activeLocalRevocationFor(input: { + agentId: string + capability: string + principalId: string + requesterAgentId: string + sessionId?: string +}): RevocationRecordV2 | undefined { + return Array.from(localRevocationRecords.values()).find((record) => { + if (!isActiveLocalRevocation(record)) return false + if (record.target_type === 'agent') return record.target_id === input.agentId + if (record.target_type === 'capability') return record.target_id === input.capability + if (record.target_type === 'identity') return record.target_id === input.agentId || record.target_id === input.principalId || record.target_id === input.requesterAgentId + if (record.target_type === 'session') return record.target_id === input.sessionId + return false + }) +} + +function activeLocalIncidentFor(agentId: string): IncidentRecordV2 | undefined { + return Array.from(localIncidentRecords.values()).find((record) => { + return record.target_agent_id === agentId && record.resolution_status === 'open' + }) +} + // Global middleware stack app.use('*', metricsMiddleware(collector)) app.use('*', logger()) @@ -429,6 +463,26 @@ app.use('/killswitch/*', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/revocations', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/revocations/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/incidents', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/incidents/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.post('*', rateLimitMiddleware({ maxRequests: 100, windowMs: 60_000 })) app.get('*', rateLimitMiddleware({ maxRequests: 300, windowMs: 60_000 })) app.use('*', bodyLimit({ maxSize: 1024 * 1024 })) @@ -893,6 +947,13 @@ app.post('/sessions', async (c) => { requesterAgentId, riskLevel: found.capability.riskLevel, }) + const activeRevocation = activeLocalRevocationFor({ + agentId: targetAgentId, + capability: capabilityId, + principalId, + requesterAgentId, + }) + const activeIncident = activeLocalIncidentFor(targetAgentId) const policy = evaluateFidesPolicy({ principalId, requesterAgentId, @@ -901,6 +962,8 @@ app.post('/sessions', async (c) => { trustResult: trust, requestedScopes, killSwitchActive: activeKillSwitch !== undefined, + revocationActive: activeRevocation !== undefined, + incidentsActive: activeIncident !== undefined, runtimeAttestationValid: typeof body.runtimeAttestationValid === 'boolean' ? body.runtimeAttestationValid : undefined, approvalGranted: typeof body.approvalGranted === 'boolean' ? body.approvalGranted : undefined, }) @@ -912,6 +975,8 @@ app.post('/sessions', async (c) => { policy, trust, killSwitch: activeKillSwitch, + revocation: activeRevocation, + incident: activeIncident, }, 409) } @@ -1181,6 +1246,152 @@ app.delete('/killswitch/:id', (c) => { return c.json({ rule: disabled }) }) +app.post('/revocations', async (c) => { + const body = await c.req.json().catch(() => ({})) + const targetType = typeof body.targetType === 'string' + ? body.targetType + : typeof body.target_type === 'string' + ? body.target_type + : undefined + if ( + targetType !== 'key' && + targetType !== 'identity' && + targetType !== 'agent' && + targetType !== 'agent_card' && + targetType !== 'capability' && + targetType !== 'session' && + targetType !== 'attestation' && + targetType !== 'publisher' + ) { + return c.json({ error: 'targetType must be key, identity, agent, agent_card, capability, session, attestation, or publisher' }, 400) + } + + const targetId = typeof body.targetId === 'string' + ? body.targetId + : typeof body.target_id === 'string' + ? body.target_id + : undefined + if (!targetId) { + return c.json({ error: 'targetId is required' }, 400) + } + + const record = createRevocationRecordV2({ + issuer: typeof body.issuer === 'string' ? body.issuer : 'did:fides:operator:local', + targetType, + targetId, + reason: typeof body.reason === 'string' ? body.reason : 'No reason provided', + evidenceRefs: Array.isArray(body.evidenceRefs) ? body.evidenceRefs.map(String) : [], + expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : undefined, + }) + localRevocationRecords.set(record.id, record) + + return c.json({ + record, + authorityOverride: true, + explanation: 'Active revocation records override normal trust and policy evaluation for matching requests.', + }, 201) +}) + +app.get('/revocations', (c) => { + const records = Array.from(localRevocationRecords.values()) + return c.json({ + records, + active: records.filter(record => isActiveLocalRevocation(record)), + }) +}) + +app.get('/revocations/:id', (c) => { + const id = c.req.param('id') + const record = localRevocationRecords.get(id) ?? Array.from(localRevocationRecords.values()).find(item => item.target_id === id) + if (!record) { + return c.json({ id, revoked: false }, 404) + } + return c.json({ id, revoked: isActiveLocalRevocation(record), record }) +}) + +app.post('/incidents', async (c) => { + const body = await c.req.json().catch(() => ({})) + const severity = typeof body.severity === 'string' ? body.severity : undefined + if (severity !== 'low' && severity !== 'medium' && severity !== 'high' && severity !== 'critical') { + return c.json({ error: 'severity must be low, medium, high, or critical' }, 400) + } + + const category = typeof body.category === 'string' ? body.category : undefined + if ( + category !== 'policy_violation' && + category !== 'data_exfiltration' && + category !== 'malicious_output' && + category !== 'sandbox_escape' && + category !== 'unauthorized_action' && + category !== 'prompt_injection_failure' && + category !== 'payment_error' && + category !== 'suspicious_behavior' + ) { + return c.json({ error: 'category is invalid' }, 400) + } + + const targetAgentId = typeof body.targetAgentId === 'string' + ? body.targetAgentId + : typeof body.target_agent_id === 'string' + ? body.target_agent_id + : undefined + if (!targetAgentId) { + return c.json({ error: 'targetAgentId is required' }, 400) + } + + const description = typeof body.description === 'string' ? body.description : undefined + if (!description) { + return c.json({ error: 'description is required' }, 400) + } + + const record = createIncidentRecordV2({ + reporter: typeof body.reporter === 'string' ? body.reporter : 'did:fides:reporter:local', + targetAgentId, + severity, + category, + description, + evidenceRefs: Array.isArray(body.evidenceRefs) ? body.evidenceRefs.map(String) : [], + trustPenalty: typeof body.trustPenalty === 'number' ? body.trustPenalty : undefined, + reputationPenalty: typeof body.reputationPenalty === 'number' ? body.reputationPenalty : undefined, + }) + localIncidentRecords.set(record.id, record) + + return c.json({ + record, + explanation: 'Open incident records require policy review for matching target agents until resolved.', + }, 201) +}) + +app.get('/incidents', (c) => { + const records = Array.from(localIncidentRecords.values()) + return c.json({ + records, + open: records.filter(record => record.resolution_status === 'open'), + }) +}) + +app.get('/incidents/:id', (c) => { + const id = c.req.param('id') + const record = localIncidentRecords.get(id) + if (!record) { + return c.json({ error: 'incident record not found', id }, 404) + } + return c.json({ record }) +}) + +app.post('/incidents/:id/resolve', async (c) => { + const id = c.req.param('id') + const record = localIncidentRecords.get(id) + if (!record) { + return c.json({ error: 'incident record not found', id }, 404) + } + const body = await c.req.json().catch(() => ({})) + const status = body.status === 'dismissed' || body.status === 'false_positive' ? body.status : 'resolved' + const resolved = resolveIncidentRecordV2(record, status) + localIncidentRecords.set(id, resolved) + return c.json({ record: resolved }) +}) + // ─── FIDES v2 Local API Aliases ─────────────────────────────────── app.post('/dht/start', (c) => { return c.json({ started: true, mode: 'in_memory_simulator', pointers: localDhtPointers.length }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 5a135d8..a9e0237 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -592,6 +592,138 @@ describe('Agentd Service Routes', () => { expect((await disabled.json()).rule.enabled).toBe(false) }) + it('serves root revocation records and blocks scoped session issuance', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Revoked File Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: 'file.delete', riskLevel: 'high', requiredScopes: ['file:delete'] }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const revocation = await app.request('/revocations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + issuer: 'did:fides:operator', + targetType: 'agent', + targetId: identity.did, + reason: 'Compromised deployment key.', + }), + }) + expect(revocation.status).toBe(201) + const revocationData = await revocation.json() + expect(revocationData.record.status).toBe('active') + + const listed = await app.request('/revocations') + expect(listed.status).toBe(200) + expect((await listed.json()).records).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: revocationData.record.id, target_id: identity.did }), + ])) + + const shown = await app.request(`/revocations/${revocationData.record.id}`) + expect(shown.status).toBe(200) + expect((await shown.json()).record.target_id).toBe(identity.did) + + const blocked = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.did, + capability: 'file.delete', + requestedScopes: ['file:delete'], + approvalGranted: true, + runtimeAttestationValid: true, + }), + }) + expect(blocked.status).toBe(409) + expect((await blocked.json()).policy.reason_codes).toContain('REVOCATION_ACTIVE') + }) + + it('serves root incident records and blocks scoped session issuance until resolved', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Incident Code Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: 'code.merge', riskLevel: 'critical', requiredScopes: ['code:merge'] }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const incident = await app.request('/incidents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + reporter: 'did:fides:principal', + targetAgentId: identity.did, + severity: 'critical', + category: 'unauthorized_action', + description: 'Attempted to merge without delegated authority.', + evidenceRefs: ['evidence:merge-attempt'], + }), + }) + expect(incident.status).toBe(201) + const incidentData = await incident.json() + expect(incidentData.record.resolution_status).toBe('open') + + const listed = await app.request('/incidents') + expect(listed.status).toBe(200) + expect((await listed.json()).records).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: incidentData.record.id, target_agent_id: identity.did }), + ])) + + const blocked = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.did, + capability: 'code.merge', + requestedScopes: ['code:merge'], + approvalGranted: true, + runtimeAttestationValid: true, + }), + }) + expect(blocked.status).toBe(409) + expect((await blocked.json()).policy.reason_codes).toContain('INCIDENT_REQUIRES_REVIEW') + + const resolved = await app.request(`/incidents/${incidentData.record.id}/resolve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'resolved' }), + }) + expect(resolved.status).toBe(200) + expect((await resolved.json()).record.resolution_status).toBe('resolved') + }) + it('serves local DHT publish and find endpoints', async () => { const publish = await app.request('/dht/publish', { method: 'POST', From dfb36a9bc69db8987dfa691aba5a4f55118a9a2d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:18:58 +0300 Subject: [PATCH 033/282] feat(api): add local runtime attestations --- docs/api-reference.md | 8 ++ docs/sdk-reference.md | 11 ++- packages/sdk/README.md | 11 ++- packages/sdk/src/fides-client.ts | 6 ++ packages/sdk/test/fides-client.test.ts | 9 +++ services/agentd/src/index.ts | 102 ++++++++++++++++++++++++- services/agentd/test/routes.test.ts | 74 ++++++++++++++++++ 7 files changed, 215 insertions(+), 6 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index be99de3..b0a8092 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -44,6 +44,9 @@ Current implementation anchors: - `GET /incidents` - `GET /incidents/:id` - `POST /incidents/:id/resolve` +- `POST /attestations` +- `GET /attestations/:id` +- `POST /attestations/:id/verify` - `POST /v1/policy/evaluate` - `POST /v1/sessions` - `GET /v1/sessions/:id` @@ -121,3 +124,8 @@ publishers. Active matching revocations override normal policy and block root session issuance. `POST /incidents` records an open incident against a target agent; open incidents require policy review for matching session requests until resolved with `POST /incidents/:id/resolve`. + +`POST /attestations` issues a local FIDES v2 runtime attestation through the +MockTEE provider. `POST /attestations/:id/verify` verifies provider, expiry, +and hash shape. Root `POST /sessions` can consume a valid `attestationId` as +runtime attestation evidence for high-risk capability policy. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index ed0ade4..5f66edd 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -78,6 +78,13 @@ const incident = await client.incidents.report({ description: 'Attempted invocation outside delegated authority.', }) await client.incidents.resolve(incident.record.id, { status: 'resolved' }) +const attestation = await client.attestations.create({ + agentId: identity.identity.did, + codeHash: `sha256:${'a'.repeat(64)}`, + runtimeHash: `sha256:${'b'.repeat(64)}`, + policyHash: `sha256:${'c'.repeat(64)}`, +}) +await client.attestations.verify(attestation.attestation.attestation_id) const session = await client.sessions.request({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', @@ -102,5 +109,7 @@ before invocation. Session request and invocation helpers use the same root local daemon API. Approval and kill switch helpers expose local authority controls, with kill switch rules overriding normal policy while active. Revocation and incident helpers expose local governance records that feed root -session policy decisions. +session policy decisions. Runtime attestation helpers issue and verify local +MockTEE attestations that can satisfy high-risk session policy when passed as +an `attestationId`. Advanced authority flows can use `AgentdClient`. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 7b45e4e..6a79cef 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -114,6 +114,13 @@ const incident = await client.incidents.report({ description: 'Attempted invocation outside delegated authority.', }) await client.incidents.resolve(incident.record.id, { status: 'resolved' }) +const attestation = await client.attestations.create({ + agentId: identity.identity.did, + codeHash: `sha256:${'a'.repeat(64)}`, + runtimeHash: `sha256:${'b'.repeat(64)}`, + policyHash: `sha256:${'c'.repeat(64)}`, +}) +await client.attestations.verify(attestation.attestation.attestation_id) const session = await client.sessions.request({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', @@ -136,7 +143,9 @@ Root session and invocation helpers use the local daemon preflight path and are currently in-memory. Approval and kill switch helpers expose local authority controls, with active kill switch rules overriding normal policy. Revocation and incident helpers expose local governance records that feed root session -policy decisions. +policy decisions. Runtime attestation helpers issue and verify local MockTEE +attestations that can satisfy high-risk session policy when passed as an +`attestationId`. ```typescript import { AgentdClient } from '@fides/sdk' diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index a471d3e..ef23fdd 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -73,6 +73,12 @@ export class FidesClient { resolve: (recordId: string, body: Record = {}) => this.post(`/incidents/${encodeURIComponent(recordId)}/resolve`, body), } + readonly attestations = { + create: (body: Record) => this.post('/attestations', body), + get: (attestationId: string) => this.get(`/attestations/${encodeURIComponent(attestationId)}`), + verify: (attestationId: string) => this.post(`/attestations/${encodeURIComponent(attestationId)}/verify`, {}), + } + readonly sessions = { request: (body: Record) => this.post('/sessions', body), verify: (sessionId: string) => this.post(`/sessions/${encodeURIComponent(sessionId)}/verify`, {}), diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 8653f67..c94e769 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -40,6 +40,9 @@ describe('FidesClient', () => { await client.incidents.list() await client.incidents.get('inc_1') await client.incidents.resolve('inc_1', { status: 'resolved' }) + await client.attestations.create({ agentId: 'did:fides:agent', codeHash: 'sha256:test' }) + await client.attestations.get('att_1') + await client.attestations.verify('att_1') await client.sessions.request({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.sessions.get('sess_1') await client.sessions.verify('sess_1') @@ -68,6 +71,9 @@ describe('FidesClient', () => { 'http://localhost:4817/incidents', 'http://localhost:4817/incidents/inc_1', 'http://localhost:4817/incidents/inc_1/resolve', + 'http://localhost:4817/attestations', + 'http://localhost:4817/attestations/att_1', + 'http://localhost:4817/attestations/att_1/verify', 'http://localhost:4817/sessions', 'http://localhost:4817/sessions/sess_1', 'http://localhost:4817/sessions/sess_1/verify', @@ -100,6 +106,9 @@ describe('FidesClient', () => { 'GET', 'POST', 'POST', + 'GET', + 'POST', + 'POST', ]) }) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 156d7b7..2643ca2 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -12,7 +12,7 @@ import { bodyLimit } from 'hono/body-limit' import { resolveTxt } from 'node:dns/promises' import { rateLimitMiddleware, MetricsCollector, metricsMiddleware } from '@fides/sdk' import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain } from '@fides/evidence' -import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' +import { MockTEEProvider as RuntimeMockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateFidesPolicy, evaluatePolicy, type FidesPolicyDecision, type PolicyBundle } from '@fides/policy' import { createTrustContext, evaluateGuard } from '@fides/guard' import { @@ -35,6 +35,7 @@ import { createSessionGrantV2, hashProtocolPayload, isKillSwitchRuleActive, + MockTEEProvider as CoreMockTEEProvider, resolveIncidentRecordV2, signAgentCard, validateAgentCard, @@ -57,6 +58,7 @@ import { type ReputationRecord, type RevocationRecord, type RevocationRecordV2, + type RuntimeAttestation, type SessionGrantV2, type SignedAgentCard, type TrustResult, @@ -81,7 +83,8 @@ const REGISTRY_URL = process.env.REGISTRY_URL || 'http://localhost:7346' const TRUST_GRAPH_SERVICE_ID = 'trust-graph' const PROPAGATION_MAX_ATTEMPTS = parseInt(process.env.AGENTD_PROPAGATION_MAX_ATTEMPTS || '5', 10) -const teeProvider = new MockTEEProvider() +const teeProvider = new RuntimeMockTEEProvider() +const runtimeAttestationProvider = new CoreMockTEEProvider() const killSwitch = new InMemoryKillSwitch() const authorityStore = createAuthorityStore() const localDhtPointers: Array> = [] @@ -110,6 +113,7 @@ const localApprovalDecisions = new Map() const localKillSwitchRules = new Map() const localRevocationRecords = new Map() const localIncidentRecords = new Map() +const localRuntimeAttestations = new Map() interface LocalSessionRecord { session: SessionGrantV2 policy: FidesPolicyDecision @@ -344,6 +348,13 @@ function activeLocalIncidentFor(agentId: string): IncidentRecordV2 | undefined { }) } +async function verifyLocalRuntimeAttestation(attestationId: string | undefined, agentId: string): Promise { + if (!attestationId) return undefined + const attestation = localRuntimeAttestations.get(attestationId) + if (!attestation || attestation.agent_id !== agentId) return false + return runtimeAttestationProvider.verify(attestation) +} + // Global middleware stack app.use('*', metricsMiddleware(collector)) app.use('*', logger()) @@ -483,7 +494,17 @@ app.use('/incidents/*', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) -app.post('*', rateLimitMiddleware({ maxRequests: 100, windowMs: 60_000 })) +app.use('/attestations', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/attestations/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.post('*', rateLimitMiddleware({ maxRequests: process.env.NODE_ENV === 'test' ? 1000 : 100, windowMs: 60_000 })) app.get('*', rateLimitMiddleware({ maxRequests: 300, windowMs: 60_000 })) app.use('*', bodyLimit({ maxSize: 1024 * 1024 })) @@ -954,6 +975,9 @@ app.post('/sessions', async (c) => { requesterAgentId, }) const activeIncident = activeLocalIncidentFor(targetAgentId) + const runtimeAttestationValid = typeof body.runtimeAttestationValid === 'boolean' + ? body.runtimeAttestationValid + : await verifyLocalRuntimeAttestation(typeof body.attestationId === 'string' ? body.attestationId : undefined, targetAgentId) const policy = evaluateFidesPolicy({ principalId, requesterAgentId, @@ -964,7 +988,7 @@ app.post('/sessions', async (c) => { killSwitchActive: activeKillSwitch !== undefined, revocationActive: activeRevocation !== undefined, incidentsActive: activeIncident !== undefined, - runtimeAttestationValid: typeof body.runtimeAttestationValid === 'boolean' ? body.runtimeAttestationValid : undefined, + runtimeAttestationValid, approvalGranted: typeof body.approvalGranted === 'boolean' ? body.approvalGranted : undefined, }) @@ -1392,6 +1416,76 @@ app.post('/incidents/:id/resolve', async (c) => { return c.json({ record: resolved }) }) +app.post('/attestations', async (c) => { + const body = await c.req.json().catch(() => ({})) + const agentId = typeof body.agentId === 'string' + ? body.agentId + : typeof body.agent_id === 'string' + ? body.agent_id + : undefined + if (!agentId) { + return c.json({ error: 'agentId is required' }, 400) + } + + const codeHash = typeof body.codeHash === 'string' + ? body.codeHash + : typeof body.code_hash === 'string' + ? body.code_hash + : undefined + const runtimeHash = typeof body.runtimeHash === 'string' + ? body.runtimeHash + : typeof body.runtime_hash === 'string' + ? body.runtime_hash + : undefined + const policyHash = typeof body.policyHash === 'string' + ? body.policyHash + : typeof body.policy_hash === 'string' + ? body.policy_hash + : undefined + if (!codeHash || !runtimeHash || !policyHash) { + return c.json({ error: 'codeHash, runtimeHash, and policyHash are required' }, 400) + } + + const attestation = await runtimeAttestationProvider.issue({ + agentId, + codeHash, + runtimeHash, + policyHash, + enclaveMeasurement: typeof body.enclaveMeasurement === 'string' + ? body.enclaveMeasurement + : typeof body.enclave_measurement === 'string' + ? body.enclave_measurement + : undefined, + expiresAt: typeof body.expiresAt === 'string' + ? body.expiresAt + : typeof body.expires_at === 'string' + ? body.expires_at + : undefined, + }) + localRuntimeAttestations.set(attestation.attestation_id, attestation) + + return c.json({ attestation }, 201) +}) + +app.get('/attestations/:id', (c) => { + const id = c.req.param('id') + const attestation = localRuntimeAttestations.get(id) + if (!attestation) { + return c.json({ error: 'attestation not found', id }, 404) + } + return c.json({ attestation }) +}) + +app.post('/attestations/:id/verify', async (c) => { + const id = c.req.param('id') + const attestation = localRuntimeAttestations.get(id) + if (!attestation) { + return c.json({ id, valid: false, error: 'attestation not found' }, 404) + } + const valid = await runtimeAttestationProvider.verify(attestation) + return c.json({ id, valid, attestation }) +}) + // ─── FIDES v2 Local API Aliases ─────────────────────────────────── app.post('/dht/start', (c) => { return c.json({ started: true, mode: 'in_memory_simulator', pointers: localDhtPointers.length }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index a9e0237..4a14096 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -724,6 +724,80 @@ describe('Agentd Service Routes', () => { expect((await resolved.json()).record.resolution_status).toBe('resolved') }) + it('serves root runtime attestations and uses valid attestations for high-risk session issuance', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Attested Payment Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: 'payments.prepare', riskLevel: 'high', requiredScopes: ['payments:prepare'] }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const withoutAttestation = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.did, + capability: 'payments.prepare', + requestedScopes: ['payments:prepare'], + }), + }) + expect(withoutAttestation.status).toBe(409) + expect((await withoutAttestation.json()).policy.reason_codes).toContain('HIGH_RISK_REQUIRES_ATTESTATION_OR_APPROVAL') + + const attestation = await app.request('/attestations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: identity.did, + codeHash: `sha256:${'a'.repeat(64)}`, + runtimeHash: `sha256:${'b'.repeat(64)}`, + policyHash: `sha256:${'c'.repeat(64)}`, + }), + }) + expect(attestation.status).toBe(201) + const attestationData = await attestation.json() + expect(attestationData.attestation.agent_id).toBe(identity.did) + + const shown = await app.request(`/attestations/${attestationData.attestation.attestation_id}`) + expect(shown.status).toBe(200) + expect((await shown.json()).attestation.provider).toBe('mock-tee') + + const verified = await app.request(`/attestations/${attestationData.attestation.attestation_id}/verify`, { method: 'POST' }) + expect(verified.status).toBe(200) + expect((await verified.json()).valid).toBe(true) + + const session = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.did, + capability: 'payments.prepare', + requestedScopes: ['payments:prepare'], + attestationId: attestationData.attestation.attestation_id, + }), + }) + expect(session.status).toBe(201) + expect((await session.json()).policy.reason_codes).toContain('POLICY_ALLOWED') + }) + it('serves local DHT publish and find endpoints', async () => { const publish = await app.request('/dht/publish', { method: 'POST', From 2ef1955894e8c9a7d170b00df0c3db23b7b4a0df Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:21:13 +0300 Subject: [PATCH 034/282] feat(discovery): clarify url-less local resolution --- docs/api-reference.md | 5 ++++- services/agentd/src/index.ts | 7 +++++++ services/agentd/test/routes.test.ts | 7 +++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index b0a8092..9869c9c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -92,7 +92,10 @@ candidate. `GET /agents` and `GET /agents/:id` expose local registration state and the associated AgentCard. `POST /discover` searches registered local agents by capability. Discovery responses always include `authorityGranted: false`; discovery is candidate resolution only, and invocation authority still requires -policy evaluation and scoped session grants. +policy evaluation and scoped session grants. Local discovery does not require +an endpoint URL; daemon-held AgentCards can resolve by capability with +`resolution.urlRequired: false`. Endpoint URLs remain optional transport +metadata, not authority. `POST /trust/evaluate` computes a local capability-scoped trust result for a registered candidate. `POST /reputation/update` stores capability-specific diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 2643ca2..e7930fb 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -777,11 +777,18 @@ app.post('/discover', async (c) => { capability, signed: localSignedAgentCards.has(record.cardId), authorityGranted: false, + resolution: { + mode: 'local_agent_card', + urlRequired: false, + authorityGranted: false, + hint: 'Local discovery resolves from daemon-held AgentCards; endpoint URLs are optional transport metadata.', + }, descriptor, card, reasons: [ 'candidate_matched_capability', 'discovery_does_not_grant_authority', + 'url_not_required_for_local_discovery', ], }] }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 4a14096..18df6a3 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -329,6 +329,7 @@ describe('Agentd Service Routes', () => { body: JSON.stringify({ identity, capabilities: [{ id: 'invoice.reconcile', requiredScopes: ['invoice:read'] }], + endpoints: [], }), }) await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) @@ -360,8 +361,14 @@ describe('Agentd Service Routes', () => { agentId: identity.did, capability: 'invoice.reconcile', signed: true, + resolution: expect.objectContaining({ + mode: 'local_agent_card', + urlRequired: false, + authorityGranted: false, + }), }), ])) + expect(discoveredData.candidates[0].reasons).toContain('url_not_required_for_local_discovery') }) it('evaluates root trust, reputation, and policy for a registered local candidate', async () => { From 4dd28f81b39bae32322d4f96f71929531d5d2e86 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:24:15 +0300 Subject: [PATCH 035/282] feat(api): add local registry relay discovery aliases --- docs/api-reference.md | 18 ++++ services/agentd/src/index.ts | 155 +++++++++++++++++++++++++++- services/agentd/test/routes.test.ts | 91 ++++++++++++++++ 3 files changed, 261 insertions(+), 3 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 9869c9c..dda6920 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -47,6 +47,14 @@ Current implementation anchors: - `POST /attestations` - `GET /attestations/:id` - `POST /attestations/:id/verify` +- `GET /.well-known/fides.json` +- `GET /.well-known/agents.json` +- `GET /.well-known/agents/:id.json` +- `POST /registry/publish` +- `POST /registry/search` +- `GET /registry/index` +- `POST /relay/register` +- `POST /relay/discover` - `POST /v1/policy/evaluate` - `POST /v1/sessions` - `GET /v1/sessions/:id` @@ -67,6 +75,7 @@ Current implementation anchors: - `POST /dht/start` - `POST /dht/publish` - `GET /dht/find` +- `POST /dht/find` - `POST /demo/run` - `POST /simulate/adversarial` - `POST /evidence/verify` @@ -74,6 +83,15 @@ Current implementation anchors: The alias endpoints currently provide local mock/demo behavior and should be hardened into durable API routes. +`POST /registry/publish`, `POST /registry/search`, and `GET /registry/index` +provide a local mock registry over registered AgentCards. `POST /relay/register` +and `POST /relay/discover` provide local mock relay presence and rendezvous. +Both surfaces return candidates or presence records only; they set +`authorityGranted: false` and do not replace policy evaluation or scoped +session grants. `GET /.well-known/fides.json`, `GET /.well-known/agents.json`, +and `GET /.well-known/agents/:id.json` expose local well-known metadata for +same-host discovery. + `POST /identities` creates local in-memory identities for the daemon prototype and returns only public identity data. Private keys are retained inside the daemon process and are not returned by `POST /identities`, `GET /identities`, or diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index e7930fb..e6908a3 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -99,6 +99,8 @@ interface LocalIdentityRecord { const localIdentities = new Map() const localAgentCards = new Map() const localSignedAgentCards = new Map() +const localRegistryRecords = new Map>() +const localRelayRecords = new Map>() interface LocalRegisteredAgent { agentId: string cardId: string @@ -1516,12 +1518,159 @@ app.post('/dht/publish', async (c) => { return c.json({ accepted: true, pointer }, 201) }) -app.get('/dht/find', (c) => { - const capability = c.req.query('capability') +function findLocalDhtPointers(capability?: string) { const pointers = capability ? localDhtPointers.filter(pointer => pointer.capability === capability) : localDhtPointers - return c.json({ capability: capability ?? null, pointers }) + return { capability: capability ?? null, pointers } +} + +app.get('/dht/find', (c) => { + return c.json(findLocalDhtPointers(c.req.query('capability'))) +}) + +app.post('/dht/find', async (c) => { + const body = await c.req.json().catch(() => ({})) + const capability = typeof body.capability === 'string' ? body.capability : undefined + return c.json(findLocalDhtPointers(capability)) +}) + +function localRegistryRecordFor(cardId: string, mode: 'public' | 'private' = 'public') { + const card = localAgentCards.get(cardId) + const registered = card ? localAgents.get(card.identity.did) : undefined + if (!card || !registered) { + return null + } + return { + id: `reg_${card.id}`, + agentId: card.identity.did, + cardId: card.id, + mode, + capabilities: card.capabilities.map(capability => capability.id), + signed: localSignedAgentCards.has(card.id), + publishedAt: new Date().toISOString(), + authorityGranted: false, + source: 'agentd-local-registry', + } +} + +app.post('/registry/publish', async (c) => { + const body = await c.req.json().catch(() => ({})) + const cardId = typeof body.agentCardId === 'string' + ? body.agentCardId + : typeof body.cardId === 'string' + ? body.cardId + : undefined + if (!cardId) { + return c.json({ error: 'agentCardId is required' }, 400) + } + const record = localRegistryRecordFor(cardId, body.mode === 'private' ? 'private' : 'public') + if (!record) { + return c.json({ error: 'registered local AgentCard not found', cardId }, 404) + } + localRegistryRecords.set(String(record.id), record) + return c.json({ accepted: true, record }, 201) +}) + +app.post('/registry/search', async (c) => { + const body = await c.req.json().catch(() => ({})) + const capability = typeof body.capability === 'string' ? body.capability : undefined + const records = Array.from(localRegistryRecords.values()).filter((record) => ( + !capability || (record.capabilities as string[] | undefined)?.includes(capability) + )) + return c.json({ capability: capability ?? null, records, authorityGranted: false }) +}) + +app.get('/registry/index', (c) => { + return c.json({ + mode: 'local_mock_registry', + records: Array.from(localRegistryRecords.values()), + authorityGranted: false, + }) +}) + +app.post('/relay/register', async (c) => { + const body = await c.req.json().catch(() => ({})) + const agentId = typeof body.agentId === 'string' + ? body.agentId + : typeof body.agent_id === 'string' + ? body.agent_id + : undefined + if (!agentId) { + return c.json({ error: 'agentId is required' }, 400) + } + const registered = localAgents.get(agentId) + const card = registered ? localAgentCards.get(registered.cardId) : undefined + if (!registered || !card) { + return c.json({ error: 'registered local agent not found', agentId }, 404) + } + const record = { + id: `relay_${agentId}`, + agentId, + cardId: registered.cardId, + capabilities: card.capabilities.map(capability => capability.id), + endpointHints: Array.isArray(body.endpointHints) ? body.endpointHints : [], + online: true, + registeredAt: new Date().toISOString(), + authorityGranted: false, + source: 'agentd-local-relay', + } + localRelayRecords.set(agentId, record) + return c.json({ accepted: true, record }, 201) +}) + +app.post('/relay/discover', async (c) => { + const body = await c.req.json().catch(() => ({})) + const capability = typeof body.capability === 'string' ? body.capability : undefined + const records = Array.from(localRelayRecords.values()).filter((record) => ( + !capability || (record.capabilities as string[] | undefined)?.includes(capability) + )) + return c.json({ capability: capability ?? null, records, authorityGranted: false }) +}) + +app.get('/.well-known/fides.json', (c) => { + return c.json({ + schema_version: 'fides.well_known.v1', + protocol: 'fides.v2', + supported_versions: ['fides.v2.0'], + endpoints: { + agents: '/.well-known/agents.json', + registry: '/registry/index', + discovery: '/discover', + }, + }) +}) + +app.get('/.well-known/agents.json', (c) => { + return c.json({ + schema_version: 'fides.well_known.agents.v1', + agents: Array.from(localAgents.values()).map(record => ({ + agentId: record.agentId, + cardId: record.cardId, + signed: record.signed, + cardUrl: `/.well-known/agents/${encodeURIComponent(record.agentId)}.json`, + authorityGranted: false, + })), + }) +}) + +app.get('/.well-known/agents/*', (c) => { + const rawId = c.req.path.slice('/.well-known/agents/'.length).replace(/\.json$/, '') + const id = decodeURIComponent(rawId) + if (!id) { + return c.json({ error: 'agent id is required' }, 400) + } + const registered = localAgents.get(id) + const card = registered ? localAgentCards.get(registered.cardId) : undefined + if (!registered || !card) { + return c.json({ error: 'registered local agent not found', agentId: id }, 404) + } + return c.json({ + agentId: id, + card, + signed: localSignedAgentCards.get(card.id) ?? null, + authorityGranted: false, + }) }) app.get('/evidence', (c) => { diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 18df6a3..b196664 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -824,6 +824,97 @@ describe('Agentd Service Routes', () => { expect(data.pointers).toEqual(expect.arrayContaining([ expect.objectContaining({ agentId: 'did:fides:agent' }), ])) + + const postFind = await app.request('/dht/find', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'invoice.reconcile' }), + }) + expect(postFind.status).toBe(200) + expect((await postFind.json()).pointers).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentId: 'did:fides:agent' }), + ])) + }) + + it('serves local registry, relay, and well-known discovery aliases without authority', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Calendar Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: 'calendar.schedule', requiredScopes: ['calendar:write'] }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const publish = await app.request('/registry/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + expect(publish.status).toBe(201) + expect((await publish.json()).record.authorityGranted).toBe(false) + + const search = await app.request('/registry/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'calendar.schedule' }), + }) + expect(search.status).toBe(200) + const searchData = await search.json() + expect(searchData.authorityGranted).toBe(false) + expect(searchData.records).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentId: identity.did }), + ])) + + const index = await app.request('/registry/index') + expect(index.status).toBe(200) + expect((await index.json()).records).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentId: identity.did }), + ])) + + const relayRegister = await app.request('/relay/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentId: identity.did }), + }) + expect(relayRegister.status).toBe(201) + expect((await relayRegister.json()).record.authorityGranted).toBe(false) + + const relayDiscover = await app.request('/relay/discover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'calendar.schedule' }), + }) + expect(relayDiscover.status).toBe(200) + expect((await relayDiscover.json()).records).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentId: identity.did }), + ])) + + const wellKnown = await app.request('/.well-known/fides.json') + expect(wellKnown.status).toBe(200) + expect((await wellKnown.json()).endpoints.discovery).toBe('/discover') + + const agents = await app.request('/.well-known/agents.json') + expect(agents.status).toBe(200) + expect((await agents.json()).agents).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentId: identity.did, authorityGranted: false }), + ])) + + const agentCard = await app.request(`/.well-known/agents/${encodeURIComponent(identity.did)}.json`) + expect(agentCard.status).toBe(200) + expect((await agentCard.json()).card.id).toBe(identity.did) }) it('serves demo and adversarial simulation endpoints', async () => { From 8aeb162fcafb35fdecd29ce0afab994f71211c41 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:26:44 +0300 Subject: [PATCH 036/282] feat(api): add local delegation endpoint --- docs/api-reference.md | 15 +++++---- services/agentd/src/index.ts | 50 +++++++++++++++++++++++++++++ services/agentd/test/routes.test.ts | 32 ++++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index dda6920..b2ab2f1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -26,6 +26,7 @@ Current implementation anchors: - `POST /reputation/update` - `GET /reputation/:agentId` - `POST /policy/evaluate` +- `POST /delegations` - `POST /sessions` - `GET /sessions/:id` - `POST /sessions/:id/verify` @@ -124,12 +125,14 @@ flags. Trust and reputation are signals only; policy decisions still do not execute capabilities and allowed decisions require a scoped SessionGrant before invocation. -`POST /sessions` issues a local `SessionGrant` only after policy allows or -limits the action to dry-run. `POST /invoke` verifies the session, runs the -policy preflight path, validates the capability context, and returns an -`InvocationResult`. The current root implementation is in-memory and intended -for local daemon DX; durable storage and signed invocation results remain -follow-up hardening work. +`POST /delegations` creates a local unsigned `DelegationToken` intent and +returns `authorityGranted: false`; it must still be signed and converted into a +policy-checked SessionGrant before invocation. `POST /sessions` issues a local +`SessionGrant` only after policy allows or limits the action to dry-run. +`POST /invoke` verifies the session, runs the policy preflight path, validates +the capability context, and returns an `InvocationResult`. The current root +implementation is in-memory and intended for local daemon DX; durable storage +and signed invocation results remain follow-up hardening work. `POST /approvals` creates an approval request and records approval decisions through `/approvals/:id/approve` or `/approvals/:id/deny`. Approval records do diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index e6908a3..0089339 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -29,6 +29,7 @@ import { createInvocationRequest, createInvocationResult, createKillSwitchRule, + createDelegationToken, createPrincipalIdentity, createPublisherIdentity, createRevocationRecordV2, @@ -49,6 +50,7 @@ import { type AgentCard, type ApprovalDecision, type ApprovalRequest, + type DelegationConstraint, type IncidentRecord, type IncidentRecordV2, type KillSwitchRule, @@ -110,6 +112,7 @@ interface LocalRegisteredAgent { const localAgents = new Map() const localTrustResults = new Map() const localReputationRecords = new Map() +const localDelegationTokens = new Map() const localApprovals = new Map() const localApprovalDecisions = new Map() const localKillSwitchRules = new Map() @@ -938,6 +941,53 @@ app.post('/policy/evaluate', async (c) => { }) }) +app.post('/delegations', async (c) => { + const body = await c.req.json().catch(() => ({})) + const delegator = typeof body.delegator === 'string' + ? body.delegator + : typeof body.principalId === 'string' + ? body.principalId + : undefined + const delegatee = typeof body.delegatee === 'string' + ? body.delegatee + : typeof body.requesterAgentId === 'string' + ? body.requesterAgentId + : typeof body.agentId === 'string' + ? body.agentId + : undefined + const capabilities = Array.isArray(body.capabilities) + ? body.capabilities.map(String) + : typeof body.capability === 'string' + ? [body.capability] + : [] + + if (!delegator || !delegatee || capabilities.length === 0) { + return c.json({ error: 'delegator, delegatee, and capabilities are required' }, 400) + } + + const expiresAt = typeof body.expiresAt === 'string' + ? body.expiresAt + : new Date(Date.now() + 60 * 60 * 1000).toISOString() + const token = createDelegationToken({ + delegator, + delegatee, + capabilities, + constraints: typeof body.constraints === 'object' && body.constraints !== null + ? body.constraints as DelegationConstraint + : {}, + expiresAt, + audience: Array.isArray(body.audience) ? body.audience.map(String) : undefined, + }) + localDelegationTokens.set(token.id, token) + + return c.json({ + token, + signed: false, + authorityGranted: false, + explanation: 'Delegation records scoped authorization intent. It must be signed and converted into a policy-checked SessionGrant before invocation.', + }, 201) +}) + app.post('/sessions', async (c) => { const body = await c.req.json().catch(() => ({})) const targetAgentId = typeof body.targetAgentId === 'string' diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index b196664..1c8beac 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -445,6 +445,38 @@ describe('Agentd Service Routes', () => { expect(policyData.requiresSessionGrant).toBe(true) }) + it('creates local delegation tokens without granting invocation authority', async () => { + const delegation = await app.request('/delegations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + delegator: 'did:fides:principal:local', + delegatee: 'did:fides:requester:local', + capabilities: ['invoice.reconcile'], + constraints: { maxActions: 1 }, + audience: ['did:fides:invoice-agent'], + }), + }) + expect(delegation.status).toBe(201) + const data = await delegation.json() + expect(data.authorityGranted).toBe(false) + expect(data.signed).toBe(false) + expect(data.token).toMatchObject({ + delegator: 'did:fides:principal:local', + delegatee: 'did:fides:requester:local', + capabilities: ['invoice.reconcile'], + audience: ['did:fides:invoice-agent'], + }) + expect(data.token.signature).toBe('') + + const invalid = await app.request('/delegations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ delegator: 'did:fides:principal:local' }), + }) + expect(invalid.status).toBe(400) + }) + it('issues root scoped sessions and invokes capabilities through policy preflight', async () => { const identityResponse = await app.request('/identities', { method: 'POST', From 956bbb4af22971b42dd6ac5e110ef73db593ae33 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:28:57 +0300 Subject: [PATCH 037/282] feat(sdk): expose local discovery authority surfaces --- docs/sdk-reference.md | 26 +++++++++++- packages/sdk/src/fides-client.ts | 41 +++++++++++++++++++ packages/sdk/test/fides-client.test.ts | 55 ++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 5f66edd..f980df6 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -45,6 +45,12 @@ const policy = await client.policy.evaluate({ capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], }) +await client.delegations.create({ + delegator: 'did:fides:principal', + delegatee: 'did:fides:requester', + capabilities: ['invoice.reconcile'], + audience: [identity.identity.did], +}) const approval = await client.approvals.create({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', @@ -85,6 +91,18 @@ const attestation = await client.attestations.create({ policyHash: `sha256:${'c'.repeat(64)}`, }) await client.attestations.verify(attestation.attestation.attestation_id) +await client.registry.publish({ agentCardId: identity.identity.did }) +await client.registry.search({ capability: 'invoice.reconcile' }) +await client.relay.register({ agentId: identity.identity.did }) +await client.relay.discover({ capability: 'invoice.reconcile' }) +await client.dht.publish({ + capability: 'invoice.reconcile', + agentId: identity.identity.did, + agentCardUrl: 'local://invoice-agent-card', +}) +await client.dht.find({ capability: 'invoice.reconcile' }) +await client.wellKnown.fides() +await client.wellKnown.agents() const session = await client.sessions.request({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', @@ -105,11 +123,15 @@ endpoints and use daemon-held local identity keys for signing. Agent registration and discovery return candidates only; `authorityGranted` remains `false`. Trust and reputation APIs return capability-scoped signals, and policy evaluation explains the decision but still requires session grant issuance -before invocation. Session request and invocation helpers use the same root +before invocation. Delegation helpers create unsigned local DelegationToken +intents; they do not grant invocation authority without signing, policy, and a +scoped SessionGrant. Session request and invocation helpers use the same root local daemon API. Approval and kill switch helpers expose local authority controls, with kill switch rules overriding normal policy while active. Revocation and incident helpers expose local governance records that feed root session policy decisions. Runtime attestation helpers issue and verify local MockTEE attestations that can satisfy high-risk session policy when passed as -an `attestationId`. +an `attestationId`. Registry, relay, DHT, and well-known helpers expose the +local mock discovery surfaces. They return candidate records or pointers only; +they do not convert discovery into authority. Advanced authority flows can use `AgentdClient`. diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index ef23fdd..a3573b4 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -47,6 +47,10 @@ export class FidesClient { evaluate: (body: Record) => this.post('/policy/evaluate', body), } + readonly delegations = { + create: (body: Record) => this.post('/delegations', body), + } + readonly approvals = { create: (body: Record) => this.post('/approvals', body), list: () => this.get('/approvals'), @@ -85,6 +89,43 @@ export class FidesClient { get: (sessionId: string) => this.get(`/sessions/${encodeURIComponent(sessionId)}`), } + readonly registry = { + publish: (body: Record) => this.post('/registry/publish', body), + search: (body: Record) => this.post('/registry/search', body), + index: () => this.get('/registry/index'), + } + + readonly relay = { + register: (body: Record) => this.post('/relay/register', body), + discover: (body: Record) => this.post('/relay/discover', body), + } + + readonly dht = { + start: () => this.post('/dht/start', {}), + publish: (body: Record) => this.post('/dht/publish', body), + find: (body: Record) => this.post('/dht/find', body), + } + + readonly wellKnown = { + fides: () => this.get('/.well-known/fides.json'), + agents: () => this.get('/.well-known/agents.json'), + agent: (agentId: string) => this.get(`/.well-known/agents/${encodeURIComponent(agentId)}.json`), + } + + readonly evidence = { + list: () => this.get('/evidence'), + verify: () => this.post('/evidence/verify', {}), + export: () => this.post('/evidence/export', {}), + } + + readonly demo = { + run: () => this.post('/demo/run', {}), + } + + readonly simulate = { + adversarial: () => this.post('/simulate/adversarial', {}), + } + constructor(private readonly options: FidesClientOptions) {} invoke(body: Record): Promise { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index c94e769..89eeaec 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -26,6 +26,11 @@ describe('FidesClient', () => { await client.trust.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.reputation.update({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.policy.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + await client.delegations.create({ + delegator: 'did:fides:principal', + delegatee: 'did:fides:requester', + capabilities: ['invoice.reconcile'], + }) await client.approvals.create({ agentId: 'did:fides:agent', capability: 'payments.prepare' }) await client.approvals.list() await client.approvals.approve('approval_1', { approverId: 'did:fides:approver' }) @@ -46,6 +51,22 @@ describe('FidesClient', () => { await client.sessions.request({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.sessions.get('sess_1') await client.sessions.verify('sess_1') + await client.registry.publish({ agentCardId: 'did:fides:agent' }) + await client.registry.search({ capability: 'invoice.reconcile' }) + await client.registry.index() + await client.relay.register({ agentId: 'did:fides:agent' }) + await client.relay.discover({ capability: 'invoice.reconcile' }) + await client.dht.start() + await client.dht.publish({ capability: 'invoice.reconcile', agentId: 'did:fides:agent' }) + await client.dht.find({ capability: 'invoice.reconcile' }) + await client.wellKnown.fides() + await client.wellKnown.agents() + await client.wellKnown.agent('did:fides:agent') + await client.evidence.list() + await client.evidence.verify() + await client.evidence.export() + await client.demo.run() + await client.simulate.adversarial() await client.invoke({ sessionId: 'sess_1', input: { invoiceId: 'inv_123' } }) expect(calls.map(call => call.url)).toEqual([ @@ -57,6 +78,7 @@ describe('FidesClient', () => { 'http://localhost:4817/trust/evaluate', 'http://localhost:4817/reputation/update', 'http://localhost:4817/policy/evaluate', + 'http://localhost:4817/delegations', 'http://localhost:4817/approvals', 'http://localhost:4817/approvals', 'http://localhost:4817/approvals/approval_1/approve', @@ -77,6 +99,22 @@ describe('FidesClient', () => { 'http://localhost:4817/sessions', 'http://localhost:4817/sessions/sess_1', 'http://localhost:4817/sessions/sess_1/verify', + 'http://localhost:4817/registry/publish', + 'http://localhost:4817/registry/search', + 'http://localhost:4817/registry/index', + 'http://localhost:4817/relay/register', + 'http://localhost:4817/relay/discover', + 'http://localhost:4817/dht/start', + 'http://localhost:4817/dht/publish', + 'http://localhost:4817/dht/find', + 'http://localhost:4817/.well-known/fides.json', + 'http://localhost:4817/.well-known/agents.json', + 'http://localhost:4817/.well-known/agents/did%3Afides%3Aagent.json', + 'http://localhost:4817/evidence', + 'http://localhost:4817/evidence/verify', + 'http://localhost:4817/evidence/export', + 'http://localhost:4817/demo/run', + 'http://localhost:4817/simulate/adversarial', 'http://localhost:4817/invoke', ]) expect(calls.map(call => call.init?.method)).toEqual([ @@ -89,6 +127,7 @@ describe('FidesClient', () => { 'POST', 'POST', 'POST', + 'POST', 'GET', 'POST', 'POST', @@ -109,6 +148,22 @@ describe('FidesClient', () => { 'GET', 'POST', 'POST', + 'POST', + 'GET', + 'POST', + 'POST', + 'POST', + 'POST', + 'POST', + 'GET', + 'GET', + 'GET', + 'GET', + 'POST', + 'POST', + 'POST', + 'POST', + 'POST', ]) }) From 93304c072c97000f0d010503b532da2420a59f8f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:32:01 +0300 Subject: [PATCH 038/282] feat(cli): add local registry relay commands --- docs/api-reference.md | 18 ++-- docs/cli-reference.md | 11 +++ docs/sdk-reference.md | 2 + packages/cli/src/commands/registry.ts | 77 ++++++++++++++++ packages/cli/src/commands/relay.ts | 55 +++++++++++ packages/cli/src/index.ts | 2 + packages/cli/test/commands.test.ts | 121 ++++++++++++++++++++++++- packages/sdk/src/fides-client.ts | 2 + packages/sdk/test/fides-client.test.ts | 6 ++ services/agentd/src/index.ts | 18 ++++ services/agentd/test/routes.test.ts | 8 ++ 11 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/commands/registry.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index b2ab2f1..0c9e680 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -51,9 +51,11 @@ Current implementation anchors: - `GET /.well-known/fides.json` - `GET /.well-known/agents.json` - `GET /.well-known/agents/:id.json` +- `POST /registry/start` - `POST /registry/publish` - `POST /registry/search` - `GET /registry/index` +- `POST /relay/start` - `POST /relay/register` - `POST /relay/discover` - `POST /v1/policy/evaluate` @@ -84,14 +86,14 @@ Current implementation anchors: The alias endpoints currently provide local mock/demo behavior and should be hardened into durable API routes. -`POST /registry/publish`, `POST /registry/search`, and `GET /registry/index` -provide a local mock registry over registered AgentCards. `POST /relay/register` -and `POST /relay/discover` provide local mock relay presence and rendezvous. -Both surfaces return candidates or presence records only; they set -`authorityGranted: false` and do not replace policy evaluation or scoped -session grants. `GET /.well-known/fides.json`, `GET /.well-known/agents.json`, -and `GET /.well-known/agents/:id.json` expose local well-known metadata for -same-host discovery. +`POST /registry/start`, `POST /registry/publish`, `POST /registry/search`, and +`GET /registry/index` provide a local mock registry over registered AgentCards. +`POST /relay/start`, `POST /relay/register`, and `POST /relay/discover` provide +local mock relay presence and rendezvous. Both surfaces return candidates or +presence records only; they set `authorityGranted: false` and do not replace +policy evaluation or scoped session grants. `GET /.well-known/fides.json`, +`GET /.well-known/agents.json`, and `GET /.well-known/agents/:id.json` expose +local well-known metadata for same-host discovery. `POST /identities` creates local in-memory identities for the daemon prototype and returns only public identity data. Private keys are retained inside the diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2e24ea4..3a1f112 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -21,6 +21,7 @@ Current implementation anchors: - `revoke` - `incident` - `killswitch` +- `registry` - `relay` - `dht` - `evidence` @@ -39,6 +40,12 @@ agentd identity domain challenge example.com did:fides:... agentd identity domain verify example.com did:fides:... agentd demo run agentd simulate adversarial +agentd registry start +agentd registry publish did:fides:... +agentd registry search --capability invoice.reconcile +agentd relay start +agentd relay register did:fides:... +agentd relay discover --capability invoice.reconcile agentd dht find --capability invoice.reconcile agentd evidence verify ``` @@ -47,3 +54,7 @@ Local identity files are stored under `~/.fides/identities` by default. Set `FIDES_HOME=/path/to/workdir` to isolate local CLI state for demos or tests. `identity show` and `identity list` do not print private keys; private keys stay inside the local identity file. + +`registry`, `relay`, and `dht` commands target local `agentd` discovery +surfaces by default. They return registry records, relay presence records, or +DHT pointers only; they do not grant invocation authority. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index f980df6..f267c65 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -91,8 +91,10 @@ const attestation = await client.attestations.create({ policyHash: `sha256:${'c'.repeat(64)}`, }) await client.attestations.verify(attestation.attestation.attestation_id) +await client.registry.start() await client.registry.publish({ agentCardId: identity.identity.did }) await client.registry.search({ capability: 'invoice.reconcile' }) +await client.relay.start() await client.relay.register({ agentId: identity.identity.did }) await client.relay.discover({ capability: 'invoice.reconcile' }) await client.dht.publish({ diff --git a/packages/cli/src/commands/registry.ts b/packages/cli/src/commands/registry.ts new file mode 100644 index 0000000..c1e215f --- /dev/null +++ b/packages/cli/src/commands/registry.ts @@ -0,0 +1,77 @@ +import { Command } from 'commander' +import { getJson, postJson, printResult } from './authority-utils.js' + +export function createRegistryCommand(): Command { + const cmd = new Command('registry') + .description('Local agentd registry discovery commands') + + cmd.command('start') + .description('Start the local mock registry through agentd') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/registry/start`, {}) + printResult('Registry started:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('publish') + .description('Publish a registered local AgentCard to the local mock registry') + .argument('', 'Local AgentCard ID') + .option('--mode ', 'Registry mode: public or private', 'public') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (agentCardId, options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/registry/publish`, { + agentCardId, + mode: options.mode, + }) + printResult('Registry record published:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('search') + .description('Search local mock registry records by capability') + .requiredOption('--capability ', 'Capability ID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/registry/search`, { + capability: options.capability, + }) + printResult('Registry records:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('index') + .description('Read the local mock registry index') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await getJson(`${baseUrl(options.agentdUrl)}/registry/index`) + printResult('Registry index:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + return cmd +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/commands/relay.ts b/packages/cli/src/commands/relay.ts index bd247fb..c708a64 100644 --- a/packages/cli/src/commands/relay.ts +++ b/packages/cli/src/commands/relay.ts @@ -5,6 +5,56 @@ export function createRelayCommand(): Command { const cmd = new Command('relay') .description('Send, poll, and inspect relay messages') + cmd.command('start') + .description('Start the local mock relay through agentd') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/relay/start`, {}) + printResult('Relay started:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('register') + .description('Register local agent presence with the local mock relay') + .argument('', 'Registered local agent DID') + .option('--endpoint-hints ', 'Comma-separated endpoint hints') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (agentId, options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/relay/register`, { + agentId, + endpointHints: parseList(options.endpointHints), + }) + printResult('Relay presence registered:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('discover') + .description('Discover local mock relay presence by capability') + .requiredOption('--capability ', 'Capability ID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/relay/discover`, { + capability: options.capability, + }) + printResult('Relay discovery records:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + cmd.command('send') .description('Send a message through the relay service') .requiredOption('--to ', 'Recipient DID') @@ -100,6 +150,11 @@ function parsePayload(options: { payloadJson?: string; message?: string }): unkn throw new Error('Either --payload-json or --message is required') } +function parseList(value?: string): string[] { + if (!value) return [] + return value.split(',').map(item => item.trim()).filter(Boolean) +} + async function deleteJson(url: string): Promise { const headers: Record = {} const apiKey = process.env.FIDES_API_KEY || process.env.SERVICE_API_KEY diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e17724d..3f21d28 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -19,6 +19,7 @@ import { createIncidentCommand } from './commands/incident.js'; import { createPropagationCommand } from './commands/propagation.js'; import { createAuthorizeCommand } from './commands/authorize.js'; import { createRelayCommand } from './commands/relay.js'; +import { createRegistryCommand } from './commands/registry.js'; import { createIdentityCommand } from './commands/identity.js'; import { createDhtCommand } from './commands/dht.js'; import { createEvidenceCommand } from './commands/evidence.js'; @@ -52,6 +53,7 @@ program.addCommand(createIncidentCommand()); program.addCommand(createPropagationCommand()); program.addCommand(createAuthorizeCommand()); program.addCommand(createRelayCommand()); +program.addCommand(createRegistryCommand()); program.addCommand(createIdentityCommand()); program.addCommand(createDhtCommand()); program.addCommand(createEvidenceCommand()); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index c3fd300..04fe9da 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -146,12 +146,14 @@ describe('CLI Commands', () => { }); describe('v2 command surface', () => { - it('exposes dht, evidence, demo, and simulate commands', async () => { + it('exposes registry, dht, evidence, demo, and simulate commands', async () => { + const { createRegistryCommand } = await import('../src/commands/registry.js'); const { createDhtCommand } = await import('../src/commands/dht.js'); const { createEvidenceCommand } = await import('../src/commands/evidence.js'); const { createDemoCommand } = await import('../src/commands/demo.js'); const { createSimulateCommand } = await import('../src/commands/simulate.js'); + expect(createRegistryCommand().name()).toBe('registry'); expect(createDhtCommand().name()).toBe('dht'); expect(createEvidenceCommand().name()).toBe('evidence'); expect(createDemoCommand().name()).toBe('demo'); @@ -985,6 +987,123 @@ describe('CLI Commands', () => { ); }); + it('relay register and discover should use local agentd aliases', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + authorityGranted: false, + records: [{ agentId: 'did:fides:agent' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createRelayCommand } = await import('../src/commands/relay.js'); + const cmd = createRelayCommand(); + + await cmd.parseAsync([ + 'start', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync([ + 'register', + 'did:fides:agent', + '--endpoint-hints', + 'local://agent', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync([ + 'discover', + '--capability', + 'invoice.reconcile', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/relay/start', + expect.objectContaining({ method: 'POST' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'http://agentd.test/relay/register', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + agentId: 'did:fides:agent', + endpointHints: ['local://agent'], + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'http://agentd.test/relay/discover', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ capability: 'invoice.reconcile' }), + }) + ); + }); + + it('registry publish and search should use local agentd aliases', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + authorityGranted: false, + records: [{ agentId: 'did:fides:agent' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createRegistryCommand } = await import('../src/commands/registry.js'); + const cmd = createRegistryCommand(); + + await cmd.parseAsync([ + 'start', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync([ + 'publish', + 'did:fides:agent', + '--mode', + 'private', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync([ + 'search', + '--capability', + 'invoice.reconcile', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/registry/start', + expect.objectContaining({ method: 'POST' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'http://agentd.test/registry/publish', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ agentCardId: 'did:fides:agent', mode: 'private' }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'http://agentd.test/registry/search', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ capability: 'invoice.reconcile' }), + }) + ); + }); + it('relay delete should remove messages by relay ID', async () => { process.env.SERVICE_API_KEY = 'relay-service-key'; const mockFetch = vi.fn(async () => new Response(JSON.stringify({ diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index a3573b4..2d6f395 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -90,12 +90,14 @@ export class FidesClient { } readonly registry = { + start: () => this.post('/registry/start', {}), publish: (body: Record) => this.post('/registry/publish', body), search: (body: Record) => this.post('/registry/search', body), index: () => this.get('/registry/index'), } readonly relay = { + start: () => this.post('/relay/start', {}), register: (body: Record) => this.post('/relay/register', body), discover: (body: Record) => this.post('/relay/discover', body), } diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 89eeaec..4544f3f 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -51,9 +51,11 @@ describe('FidesClient', () => { await client.sessions.request({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.sessions.get('sess_1') await client.sessions.verify('sess_1') + await client.registry.start() await client.registry.publish({ agentCardId: 'did:fides:agent' }) await client.registry.search({ capability: 'invoice.reconcile' }) await client.registry.index() + await client.relay.start() await client.relay.register({ agentId: 'did:fides:agent' }) await client.relay.discover({ capability: 'invoice.reconcile' }) await client.dht.start() @@ -99,9 +101,11 @@ describe('FidesClient', () => { 'http://localhost:4817/sessions', 'http://localhost:4817/sessions/sess_1', 'http://localhost:4817/sessions/sess_1/verify', + 'http://localhost:4817/registry/start', 'http://localhost:4817/registry/publish', 'http://localhost:4817/registry/search', 'http://localhost:4817/registry/index', + 'http://localhost:4817/relay/start', 'http://localhost:4817/relay/register', 'http://localhost:4817/relay/discover', 'http://localhost:4817/dht/start', @@ -149,12 +153,14 @@ describe('FidesClient', () => { 'POST', 'POST', 'POST', + 'POST', 'GET', 'POST', 'POST', 'POST', 'POST', 'POST', + 'POST', 'GET', 'GET', 'GET', diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 0089339..a368da0 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -1639,6 +1639,24 @@ app.get('/registry/index', (c) => { }) }) +app.post('/registry/start', (c) => { + return c.json({ + started: true, + mode: 'local_mock_registry', + records: localRegistryRecords.size, + authorityGranted: false, + }) +}) + +app.post('/relay/start', (c) => { + return c.json({ + started: true, + mode: 'local_mock_relay', + records: localRelayRecords.size, + authorityGranted: false, + }) +}) + app.post('/relay/register', async (c) => { const body = await c.req.json().catch(() => ({})) const agentId = typeof body.agentId === 'string' diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 1c8beac..f574ba5 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -916,6 +916,14 @@ describe('Agentd Service Routes', () => { expect.objectContaining({ agentId: identity.did }), ])) + const registryStart = await app.request('/registry/start', { method: 'POST' }) + expect(registryStart.status).toBe(200) + expect((await registryStart.json()).authorityGranted).toBe(false) + + const relayStart = await app.request('/relay/start', { method: 'POST' }) + expect(relayStart.status).toBe(200) + expect((await relayStart.json()).authorityGranted).toBe(false) + const relayRegister = await app.request('/relay/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, From cf23db731b16188eadcc2e50d127fe3fd69605bd Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:38:44 +0300 Subject: [PATCH 039/282] feat(evidence): wire root local evidence ledger --- docs/api-reference.md | 15 ++- services/agentd/src/index.ts | 147 ++++++++++++++++++++++++++-- services/agentd/test/routes.test.ts | 62 +++++++++++- 3 files changed, 210 insertions(+), 14 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 0c9e680..d81c0ff 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -81,10 +81,16 @@ Current implementation anchors: - `POST /dht/find` - `POST /demo/run` - `POST /simulate/adversarial` +- `POST /evidence` +- `GET /evidence` +- `GET /evidence/:eventId` - `POST /evidence/verify` - `POST /evidence/export` -The alias endpoints currently provide local mock/demo behavior and should be hardened into durable API routes. +The root v2 endpoints are local-first daemon surfaces. Registry and relay are +mock/local providers, while root evidence uses an in-memory hash-chained ledger +for the current daemon process and should be backed by durable storage before +production use. `POST /registry/start`, `POST /registry/publish`, `POST /registry/search`, and `GET /registry/index` provide a local mock registry over registered AgentCards. @@ -95,6 +101,13 @@ policy evaluation or scoped session grants. `GET /.well-known/fides.json`, `GET /.well-known/agents.json`, and `GET /.well-known/agents/:id.json` expose local well-known metadata for same-host discovery. +`POST /evidence`, `GET /evidence`, `GET /evidence/:eventId`, +`POST /evidence/verify`, and `POST /evidence/export` expose the root local +EvidenceEvent ledger. Sensitive inputs and outputs are not stored directly by +default; the daemon records `sha256:` hashes and metadata under `hash_only` +privacy unless another privacy mode is explicitly requested. Root evidence is +tamper-evident inside the process, not yet durable across daemon restarts. + `POST /identities` creates local in-memory identities for the daemon prototype and returns only public identity data. Private keys are retained inside the daemon process and are not returned by `POST /identities`, `GET /identities`, or diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index a368da0..440c043 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -11,7 +11,16 @@ import { cors } from 'hono/cors' import { bodyLimit } from 'hono/body-limit' import { resolveTxt } from 'node:dns/promises' import { rateLimitMiddleware, MetricsCollector, metricsMiddleware } from '@fides/sdk' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain } from '@fides/evidence' +import { + appendEvidenceEvent, + appendEvidenceEventV2, + createEvidenceChain, + createEvidenceEventV2, + verifyEvidenceChain, + verifyEvidenceEventsV2, + type EvidenceEventV2, + type EvidenceEventV2Input, +} from '@fides/evidence' import { MockTEEProvider as RuntimeMockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateFidesPolicy, evaluatePolicy, type FidesPolicyDecision, type PolicyBundle } from '@fides/policy' import { createTrustContext, evaluateGuard } from '@fides/guard' @@ -119,6 +128,7 @@ const localKillSwitchRules = new Map() const localRevocationRecords = new Map() const localIncidentRecords = new Map() const localRuntimeAttestations = new Map() +let localEvidenceEvents: EvidenceEventV2[] = [] interface LocalSessionRecord { session: SessionGrantV2 policy: FidesPolicyDecision @@ -1052,6 +1062,23 @@ app.post('/sessions', async (c) => { }) if (policy.decision !== 'allow' && policy.decision !== 'dry_run_only') { + const deniedEvidence = appendRootEvidence({ + type: 'session.denied', + actor: requesterAgentId, + subject: targetAgentId, + principal: principalId, + capability: capabilityId, + policy: policy, + decision: policy.decision, + risk_level: found.capability.riskLevel, + privacy_mode: 'hash_only', + metadata: { + reason_codes: policy.reason_codes, + kill_switch: activeKillSwitch?.id, + revocation: activeRevocation?.id, + incident: activeIncident?.id, + }, + }) return c.json({ authorized: false, authorityGranted: false, @@ -1060,6 +1087,7 @@ app.post('/sessions', async (c) => { killSwitch: activeKillSwitch, revocation: activeRevocation, incident: activeIncident, + evidenceRefs: [deniedEvidence.event_id], }, 409) } @@ -1080,6 +1108,21 @@ app.post('/sessions', async (c) => { expiresAt, }) localSessionGrants.set(session.session_id, { session, policy, trust }) + const sessionEvidence = appendRootEvidence({ + type: 'session.granted', + actor: requesterAgentId, + subject: targetAgentId, + principal: principalId, + capability: capabilityId, + policy: policy, + decision: policy.decision, + risk_level: found.capability.riskLevel, + privacy_mode: 'hash_only', + metadata: { + session_id: session.session_id, + authority_granted: policy.decision === 'allow', + }, + }) return c.json({ authorized: true, @@ -1087,6 +1130,7 @@ app.post('/sessions', async (c) => { session, policy, trust, + evidenceRefs: [sessionEvidence.event_id], }, 201) }) @@ -1147,13 +1191,47 @@ app.post('/invoke', async (c) => { request, policyDecision: record.policy, }) + const invokedEvidence = appendRootEvidence({ + type: 'capability.invoked', + actor: record.session.requester_agent_id, + subject: record.session.target_agent_id, + principal: record.session.principal_id, + capability: record.session.capability, + input: body.input ?? {}, + policy_hash: record.session.policy_hash, + decision: record.policy.decision, + privacy_mode: 'hash_only', + metadata: { + session_id: record.session.session_id, + invocation_request_id: request.id, + dry_run: request.dry_run, + }, + }) + const status = preflight.can_execute ? 'completed' : preflight.status + const completedEvidence = appendRootEvidence({ + type: preflight.can_execute ? 'capability.completed' : 'capability.failed', + actor: record.session.target_agent_id, + subject: record.session.requester_agent_id, + principal: record.session.principal_id, + capability: record.session.capability, + output: preflight.can_execute ? { ok: true, capability: record.session.capability } : undefined, + policy_hash: record.session.policy_hash, + decision: status, + privacy_mode: 'hash_only', + metadata: { + session_id: record.session.session_id, + invocation_request_id: request.id, + preflight_status: preflight.status, + can_execute: preflight.can_execute, + }, + }) const result = createInvocationResult({ issuer: record.session.target_agent_id, invocationRequestId: request.id, - status: preflight.can_execute ? 'completed' : preflight.status, + status, output: preflight.can_execute ? { ok: true, capability: record.session.capability } : undefined, errorCode: preflight.can_execute ? undefined : preflight.reason_codes[0], - evidenceRefs: [`evt_${request.id}`], + evidenceRefs: [invokedEvidence.event_id, completedEvidence.event_id], }) return c.json({ @@ -1741,18 +1819,57 @@ app.get('/.well-known/agents/*', (c) => { }) }) +function appendRootEvidence(input: EvidenceEventV2Input): EvidenceEventV2 { + const previousHash = localEvidenceEvents.at(-1)?.event_hash ?? '0' + const event = createEvidenceEventV2(input, previousHash) + localEvidenceEvents = appendEvidenceEventV2(localEvidenceEvents, event) + return event +} + +app.post('/evidence', async (c) => { + const body = await c.req.json().catch(() => ({})) + const type = typeof body.type === 'string' ? body.type : undefined + const actor = typeof body.actor === 'string' ? body.actor : undefined + if (!type || !actor) { + return c.json({ error: 'type and actor are required' }, 400) + } + const event = appendRootEvidence({ + type: type as EvidenceEventV2Input['type'], + actor, + subject: typeof body.subject === 'string' ? body.subject : undefined, + principal: typeof body.principal === 'string' ? body.principal : undefined, + capability: typeof body.capability === 'string' ? body.capability : undefined, + input: body.input, + output: body.output, + policy: body.policy, + decision: typeof body.decision === 'string' ? body.decision : undefined, + risk_level: typeof body.riskLevel === 'string' ? body.riskLevel as EvidenceEventV2['risk_level'] : undefined, + privacy_mode: body.privacyMode === 'public' || body.privacyMode === 'private' || body.privacyMode === 'redacted' || body.privacyMode === 'hash_only' + ? body.privacyMode + : 'hash_only', + metadata: typeof body.metadata === 'object' && body.metadata !== null ? body.metadata as Record : undefined, + }) + return c.json({ accepted: true, event, authorityGranted: false }, 201) +}) + app.get('/evidence', (c) => { + const valid = verifyEvidenceEventsV2(localEvidenceEvents) return c.json({ - events: [], - count: 0, - note: 'Use /v1/evidence/:did for local authority evidence chains.', + events: localEvidenceEvents, + count: localEvidenceEvents.length, + valid, + lastHash: localEvidenceEvents.at(-1)?.event_hash ?? null, + authorityGranted: false, }) }) app.post('/evidence/verify', (c) => { + const valid = verifyEvidenceEventsV2(localEvidenceEvents) return c.json({ - valid: true, - scope: 'local-authority-store', + valid, + count: localEvidenceEvents.length, + lastHash: localEvidenceEvents.at(-1)?.event_hash ?? null, + scope: 'root-local-evidence-ledger', checkedAt: new Date().toISOString(), }) }) @@ -1761,11 +1878,21 @@ app.post('/evidence/export', (c) => { return c.json({ format: 'json', exportedAt: new Date().toISOString(), - events: [], - note: 'Per-DID evidence export is available through /v1/evidence/:did.', + valid: verifyEvidenceEventsV2(localEvidenceEvents), + count: localEvidenceEvents.length, + events: localEvidenceEvents, }) }) +app.get('/evidence/:eventId', (c) => { + const eventId = c.req.param('eventId') + const event = localEvidenceEvents.find(item => item.event_id === eventId) + if (!event) { + return c.json({ error: 'evidence event not found', eventId }, 404) + } + return c.json({ event, authorityGranted: false }) +}) + app.post('/demo/run', (c) => { return c.json({ status: 'spec-complete', diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index f574ba5..ec88593 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -536,6 +536,24 @@ describe('Agentd Service Routes', () => { expect(invocationData.preflight.can_execute).toBe(true) expect(invocationData.result.status).toBe('completed') expect(invocationData.authorityGranted).toBe(true) + expect(invocationData.result.evidence_refs).toHaveLength(2) + + const evidence = await app.request('/evidence') + expect(evidence.status).toBe(200) + const evidenceData = await evidence.json() + expect(evidenceData.valid).toBe(true) + expect(evidenceData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event_id: invocationData.result.evidence_refs[0], + type: 'capability.invoked', + input_hash: expect.stringMatching(/^sha256:/), + }), + expect.objectContaining({ + event_id: invocationData.result.evidence_refs[1], + type: 'capability.completed', + output_hash: expect.stringMatching(/^sha256:/), + }), + ])) }) it('serves root approval request and decision lifecycle', async () => { @@ -980,14 +998,52 @@ describe('Agentd Service Routes', () => { expect(data.preflight.status).toBe('denied') }) - it('serves root evidence verify/export aliases', async () => { + it('serves root evidence append, inspect, verify, and export aliases', async () => { + const appended = await app.request('/evidence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'capability.invoked', + actor: 'did:fides:requester:evidence-root', + subject: 'did:fides:agent:evidence-root', + principal: 'did:fides:principal:evidence-root', + capability: 'invoice.reconcile', + input: { invoiceId: 'inv_123', secret: 'do-not-store' }, + decision: 'allow', + }), + }) + expect(appended.status).toBe(201) + const appendedData = await appended.json() + expect(appendedData.authorityGranted).toBe(false) + expect(appendedData.event.input_hash).toMatch(/^sha256:/) + expect(JSON.stringify(appendedData)).not.toContain('do-not-store') + + const listed = await app.request('/evidence') + expect(listed.status).toBe(200) + const listedData = await listed.json() + expect(listedData.valid).toBe(true) + expect(listedData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ event_id: appendedData.event.event_id }), + ])) + + const inspected = await app.request(`/evidence/${encodeURIComponent(appendedData.event.event_id)}`) + expect(inspected.status).toBe(200) + expect((await inspected.json()).event.event_hash).toBe(appendedData.event.event_hash) + const verify = await app.request('/evidence/verify', { method: 'POST' }) expect(verify.status).toBe(200) - expect((await verify.json()).valid).toBe(true) + const verified = await verify.json() + expect(verified.valid).toBe(true) + expect(verified.count).toBeGreaterThanOrEqual(1) const exported = await app.request('/evidence/export', { method: 'POST' }) expect(exported.status).toBe(200) - expect((await exported.json()).format).toBe('json') + const exportedData = await exported.json() + expect(exportedData.format).toBe('json') + expect(exportedData.valid).toBe(true) + expect(exportedData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ event_id: appendedData.event.event_id }), + ])) }) }) From e4994341f956fd77d23ce614a5a3bc57b45b027a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:39:57 +0300 Subject: [PATCH 040/282] fix(api): require auth for root evidence writes --- services/agentd/src/index.ts | 5 +++++ services/agentd/src/middleware/auth.ts | 4 +++- services/agentd/test/routes.test.ts | 29 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 440c043..12c78b5 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -389,6 +389,11 @@ app.use('/dht/*', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/evidence', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.use('/evidence/*', async (c, next) => { if (c.req.method === 'GET') return next() const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) diff --git a/services/agentd/src/middleware/auth.ts b/services/agentd/src/middleware/auth.ts index c06ff15..617eda5 100644 --- a/services/agentd/src/middleware/auth.ts +++ b/services/agentd/src/middleware/auth.ts @@ -47,7 +47,9 @@ export function agentdScopeForRequest(method: string, path: string): string { return AGENTD_API_SCOPES.authorityWrite } if (path === '/v1/authorize') return AGENTD_API_SCOPES.authorizeWrite - if (path === '/v1/evidence' || path === '/evidence/verify' || path === '/evidence/export') return AGENTD_API_SCOPES.evidenceWrite + if (path === '/v1/evidence' || path === '/evidence' || path === '/evidence/verify' || path === '/evidence/export') { + return AGENTD_API_SCOPES.evidenceWrite + } if (path === '/v1/attest') return AGENTD_API_SCOPES.attestWrite if (path === '/v1/killswitch/engage' || path === '/v1/killswitch/disengage') { return AGENTD_API_SCOPES.killSwitchWrite diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index ec88593..98cd686 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -169,6 +169,24 @@ describe('Agentd Service Routes', () => { expect((await res.json()).error).toContain('SERVICE_API_KEY is required in production') }) + it('fails closed for root evidence creation in production when API key is not configured', async () => { + process.env.NODE_ENV = 'production' + delete process.env.SERVICE_API_KEY + + const res = await app.request('/evidence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'capability.invoked', + actor: TEST_DID, + input: { secret: 'blocked' }, + }), + }) + + expect(res.status).toBe(503) + expect((await res.json()).error).toContain('SERVICE_API_KEY is required in production') + }) + it('enforces scoped agentd API keys when configured', async () => { process.env.AGENTD_API_KEYS = JSON.stringify([ { key: 'evidence-key', scopes: ['agentd:evidence:write'] }, @@ -186,6 +204,17 @@ describe('Agentd Service Routes', () => { }) expect(accepted.status).toBe(201) + const rootAccepted = await app.request('/evidence', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'evidence-key' }, + body: JSON.stringify({ + type: 'capability.invoked', + actor: TEST_DID, + input: { redacted: true }, + }), + }) + expect(rootAccepted.status).toBe(201) + const forbidden = await app.request('/v1/killswitch/engage', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'evidence-key' }, From 96d6b72c3427885b42a57f7a48017455338c47b9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:42:14 +0300 Subject: [PATCH 041/282] feat(sdk): expose root evidence append inspect --- docs/sdk-reference.md | 10 ++++++++++ packages/sdk/README.md | 14 +++++++++++++- packages/sdk/src/fides-client.ts | 2 ++ packages/sdk/test/fides-client.test.ts | 6 ++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index f267c65..a5b4f4c 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -116,6 +116,16 @@ const invocation = await client.invoke({ sessionId: session.session.session_id, input: { invoiceId: 'inv_123' }, }) +const evidence = await client.evidence.append({ + type: 'capability.invoked', + actor: 'did:fides:requester', + subject: identity.identity.did, + capability: 'invoice.reconcile', + input: { invoiceId: 'inv_123' }, +}) +await client.evidence.inspect(evidence.event.event_id) +await client.evidence.verify() +await client.evidence.export() ``` `identity.createAgent`, `identity.list`, and `identity.show` target the root diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 6a79cef..228112d 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -132,6 +132,16 @@ const invocation = await client.invoke({ sessionId: session.session.session_id, input: { invoiceId: 'inv_123' }, }) +const evidence = await client.evidence.append({ + type: 'capability.invoked', + actor: 'did:fides:requester', + subject: identity.identity.did, + capability: 'invoice.reconcile', + input: { invoiceId: 'inv_123' }, +}) +await client.evidence.inspect(evidence.event.event_id) +await client.evidence.verify() +await client.evidence.export() ``` The local identity API returns public identity data only; it does not return @@ -145,7 +155,9 @@ controls, with active kill switch rules overriding normal policy. Revocation and incident helpers expose local governance records that feed root session policy decisions. Runtime attestation helpers issue and verify local MockTEE attestations that can satisfy high-risk session policy when passed as an -`attestationId`. +`attestationId`. Evidence helpers append hash-only events by default, inspect +individual events, verify the root hash chain, and export the current local +ledger. ```typescript import { AgentdClient } from '@fides/sdk' diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 2d6f395..80a5f58 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -115,7 +115,9 @@ export class FidesClient { } readonly evidence = { + append: (body: Record) => this.post('/evidence', body), list: () => this.get('/evidence'), + inspect: (eventId: string) => this.get(`/evidence/${encodeURIComponent(eventId)}`), verify: () => this.post('/evidence/verify', {}), export: () => this.post('/evidence/export', {}), } diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 4544f3f..f6a8d51 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -64,7 +64,9 @@ describe('FidesClient', () => { await client.wellKnown.fides() await client.wellKnown.agents() await client.wellKnown.agent('did:fides:agent') + await client.evidence.append({ type: 'capability.invoked', actor: 'did:fides:agent' }) await client.evidence.list() + await client.evidence.inspect('evt_1') await client.evidence.verify() await client.evidence.export() await client.demo.run() @@ -115,6 +117,8 @@ describe('FidesClient', () => { 'http://localhost:4817/.well-known/agents.json', 'http://localhost:4817/.well-known/agents/did%3Afides%3Aagent.json', 'http://localhost:4817/evidence', + 'http://localhost:4817/evidence', + 'http://localhost:4817/evidence/evt_1', 'http://localhost:4817/evidence/verify', 'http://localhost:4817/evidence/export', 'http://localhost:4817/demo/run', @@ -164,6 +168,8 @@ describe('FidesClient', () => { 'GET', 'GET', 'GET', + 'POST', + 'GET', 'GET', 'POST', 'POST', From b74637666f78c9b41922072a70ff43c70fcf641e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:43:34 +0300 Subject: [PATCH 042/282] fix(api): require auth for root discovery writes --- services/agentd/src/index.ts | 15 +++++++++++++++ services/agentd/test/routes.test.ts | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 12c78b5..759e8e7 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -389,6 +389,16 @@ app.use('/dht/*', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/registry/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) +app.use('/relay/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.use('/evidence', async (c, next) => { if (c.req.method === 'GET') return next() const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) @@ -459,6 +469,11 @@ app.use('/policy/*', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/delegations', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.use('/sessions', async (c, next) => { if (c.req.method === 'GET') return next() const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 98cd686..5235260 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -187,6 +187,27 @@ describe('Agentd Service Routes', () => { expect((await res.json()).error).toContain('SERVICE_API_KEY is required in production') }) + it('fails closed for root discovery and delegation mutations in production when API key is not configured', async () => { + process.env.NODE_ENV = 'production' + delete process.env.SERVICE_API_KEY + + const requests = [ + app.request('/delegations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ delegator: 'did:fides:principal', delegatee: TEST_DID, capabilities: ['invoice.reconcile'] }), + }), + app.request('/registry/start', { method: 'POST' }), + app.request('/relay/start', { method: 'POST' }), + ] + + const responses = await Promise.all(requests) + for (const res of responses) { + expect(res.status).toBe(503) + expect((await res.json()).error).toContain('SERVICE_API_KEY is required in production') + } + }) + it('enforces scoped agentd API keys when configured', async () => { process.env.AGENTD_API_KEYS = JSON.stringify([ { key: 'evidence-key', scopes: ['agentd:evidence:write'] }, From fc6a5c9f7998ff887551e914c5ed2bcdc94cf3d5 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:47:34 +0300 Subject: [PATCH 043/282] feat(discovery): add provider-specific root aliases --- docs/api-reference.md | 18 ++++-- docs/sdk-reference.md | 4 ++ packages/sdk/README.md | 4 ++ packages/sdk/src/fides-client.ts | 5 ++ packages/sdk/test/fides-client.test.ts | 15 +++++ services/agentd/src/index.ts | 80 ++++++++++++++++++++++++-- services/agentd/test/routes.test.ts | 66 +++++++++++++++++++++ 7 files changed, 181 insertions(+), 11 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index d81c0ff..be07214 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -21,6 +21,11 @@ Current implementation anchors: - `GET /agents` - `GET /agents/:id` - `POST /discover` +- `POST /discover/local` +- `POST /discover/well-known` +- `POST /discover/registry` +- `POST /discover/relay` +- `POST /discover/dht` - `POST /trust/evaluate` - `GET /trust/:agentId` - `POST /reputation/update` @@ -123,11 +128,14 @@ durable SQLite-backed identity/card storage. `POST /agents/register` registers a locally stored AgentCard as a discovery candidate. `GET /agents` and `GET /agents/:id` expose local registration state -and the associated AgentCard. `POST /discover` searches registered local agents -by capability. Discovery responses always include `authorityGranted: false`; -discovery is candidate resolution only, and invocation authority still requires -policy evaluation and scoped session grants. Local discovery does not require -an endpoint URL; daemon-held AgentCards can resolve by capability with +and the associated AgentCard. `POST /discover` and `POST /discover/local` +search registered local agents by capability. `POST /discover/well-known`, +`POST /discover/registry`, `POST /discover/relay`, and `POST /discover/dht` +expose provider-specific discovery aliases over the daemon's local state. +Discovery responses always include `authorityGranted: false`; discovery is +candidate resolution only, and invocation authority still requires policy +evaluation and scoped session grants. Local discovery does not require an +endpoint URL; daemon-held AgentCards can resolve by capability with `resolution.urlRequired: false`. Endpoint URLs remain optional transport metadata, not authority. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index a5b4f4c..3d6f803 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -29,6 +29,10 @@ await client.agents.register({ agentCardId: identity.identity.did }) await client.agents.list() await client.agents.inspect(identity.identity.did) const results = await client.discovery.find({ capability: 'invoice.reconcile' }) +await client.discovery.local({ capability: 'invoice.reconcile' }) +await client.discovery.registry({ capability: 'invoice.reconcile' }) +await client.discovery.relay({ capability: 'invoice.reconcile' }) +await client.discovery.dht({ capability: 'invoice.reconcile' }) const trust = await client.trust.evaluate({ agentId: identity.identity.did, capability: 'invoice.reconcile', diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 228112d..8efeb06 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -65,6 +65,10 @@ await client.agents.register({ agentCardId: identity.identity.did }) const agents = await client.agents.list() const candidateAgent = await client.agents.inspect(identity.identity.did) const candidates = await client.discovery.find({ capability: 'invoice.reconcile' }) +await client.discovery.local({ capability: 'invoice.reconcile' }) +await client.discovery.registry({ capability: 'invoice.reconcile' }) +await client.discovery.relay({ capability: 'invoice.reconcile' }) +await client.discovery.dht({ capability: 'invoice.reconcile' }) const trust = await client.trust.evaluate({ agentId: identity.identity.did, capability: 'invoice.reconcile', diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 80a5f58..24d78e1 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -31,6 +31,11 @@ export class FidesClient { readonly discovery = { find: (query: Record) => this.post('/discover', query), + local: (query: Record) => this.post('/discover/local', query), + wellKnown: (query: Record) => this.post('/discover/well-known', query), + registry: (query: Record) => this.post('/discover/registry', query), + relay: (query: Record) => this.post('/discover/relay', query), + dht: (query: Record) => this.post('/discover/dht', query), } readonly trust = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index f6a8d51..0b2ef15 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -23,6 +23,11 @@ describe('FidesClient', () => { await client.cards.sign({ id: 'card_1' }) await client.agents.register({ id: 'card_1' }) await client.discovery.find({ capability: 'invoice.reconcile' }) + await client.discovery.local({ capability: 'invoice.reconcile' }) + await client.discovery.wellKnown({ capability: 'invoice.reconcile' }) + await client.discovery.registry({ capability: 'invoice.reconcile' }) + await client.discovery.relay({ capability: 'invoice.reconcile' }) + await client.discovery.dht({ capability: 'invoice.reconcile' }) await client.trust.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.reputation.update({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.policy.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) @@ -79,6 +84,11 @@ describe('FidesClient', () => { 'http://localhost:4817/agent-cards/card_1/sign', 'http://localhost:4817/agents/register', 'http://localhost:4817/discover', + 'http://localhost:4817/discover/local', + 'http://localhost:4817/discover/well-known', + 'http://localhost:4817/discover/registry', + 'http://localhost:4817/discover/relay', + 'http://localhost:4817/discover/dht', 'http://localhost:4817/trust/evaluate', 'http://localhost:4817/reputation/update', 'http://localhost:4817/policy/evaluate', @@ -136,6 +146,11 @@ describe('FidesClient', () => { 'POST', 'POST', 'POST', + 'POST', + 'POST', + 'POST', + 'POST', + 'POST', 'GET', 'POST', 'POST', diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 759e8e7..8d59b10 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -454,6 +454,11 @@ app.use('/discover', async (c, next) => { const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) return auth(c, next) }) +app.use('/discover/*', async (c, next) => { + if (c.req.method === 'GET') return next() + const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) + return auth(c, next) +}) app.use('/trust/*', async (c, next) => { if (c.req.method === 'GET') return next() const auth = apiKeyAuth(agentdScopeForRequest(c.req.method, new URL(c.req.url).pathname)) @@ -792,11 +797,10 @@ app.get('/agents/:id', (c) => { }) }) -app.post('/discover', async (c) => { - const body = await c.req.json().catch(() => ({})) +function localDiscoveryResult(body: Record, provider = 'local') { const capability = typeof body.capability === 'string' ? body.capability : undefined if (!capability) { - return c.json({ error: 'capability is required' }, 400) + return { error: 'capability is required' as const } } const candidates = Array.from(localAgents.values()).flatMap((record) => { @@ -813,7 +817,8 @@ app.post('/discover', async (c) => { signed: localSignedAgentCards.has(record.cardId), authorityGranted: false, resolution: { - mode: 'local_agent_card', + mode: provider === 'well-known' ? 'local_well_known_agent_card' : 'local_agent_card', + provider, urlRequired: false, authorityGranted: false, hint: 'Local discovery resolves from daemon-held AgentCards; endpoint URLs are optional transport metadata.', @@ -828,13 +833,35 @@ app.post('/discover', async (c) => { }] }) - return c.json({ + return { query: body, + provider, candidates, count: candidates.length, authorityGranted: false, explanation: 'Discovery returns candidates only. Policy evaluation and scoped session grants are required before invocation.', - }) + } +} + +app.post('/discover', async (c) => { + const body = await c.req.json().catch(() => ({})) + const result = localDiscoveryResult(body) + if ('error' in result) return c.json({ error: result.error }, 400) + return c.json(result) +}) + +app.post('/discover/local', async (c) => { + const body = await c.req.json().catch(() => ({})) + const result = localDiscoveryResult(body, 'local') + if ('error' in result) return c.json({ error: result.error }, 400) + return c.json(result) +}) + +app.post('/discover/well-known', async (c) => { + const body = await c.req.json().catch(() => ({})) + const result = localDiscoveryResult(body, 'well-known') + if ('error' in result) return c.json({ error: result.error }, 400) + return c.json(result) }) app.post('/trust/evaluate', async (c) => { @@ -1683,6 +1710,17 @@ app.post('/dht/find', async (c) => { return c.json(findLocalDhtPointers(capability)) }) +app.post('/discover/dht', async (c) => { + const body = await c.req.json().catch(() => ({})) + const capability = typeof body.capability === 'string' ? body.capability : undefined + return c.json({ + provider: 'dht', + ...findLocalDhtPointers(capability), + authorityGranted: false, + explanation: 'DHT discovery returns signed pointer candidates only; trust, policy, and session grants are evaluated separately.', + }) +}) + function localRegistryRecordFor(cardId: string, mode: 'public' | 'private' = 'public') { const card = localAgentCards.get(cardId) const registered = card ? localAgents.get(card.identity.did) : undefined @@ -1729,6 +1767,21 @@ app.post('/registry/search', async (c) => { return c.json({ capability: capability ?? null, records, authorityGranted: false }) }) +app.post('/discover/registry', async (c) => { + const body = await c.req.json().catch(() => ({})) + const capability = typeof body.capability === 'string' ? body.capability : undefined + const records = Array.from(localRegistryRecords.values()).filter((record) => ( + !capability || (record.capabilities as string[] | undefined)?.includes(capability) + )) + return c.json({ + provider: 'registry', + capability: capability ?? null, + records, + authorityGranted: false, + explanation: 'Registry discovery returns registry records only; registration does not grant invocation authority.', + }) +}) + app.get('/registry/index', (c) => { return c.json({ mode: 'local_mock_registry', @@ -1794,6 +1847,21 @@ app.post('/relay/discover', async (c) => { return c.json({ capability: capability ?? null, records, authorityGranted: false }) }) +app.post('/discover/relay', async (c) => { + const body = await c.req.json().catch(() => ({})) + const capability = typeof body.capability === 'string' ? body.capability : undefined + const records = Array.from(localRelayRecords.values()).filter((record) => ( + !capability || (record.capabilities as string[] | undefined)?.includes(capability) + )) + return c.json({ + provider: 'relay', + capability: capability ?? null, + records, + authorityGranted: false, + explanation: 'Relay discovery returns presence records only; relay presence is not authority.', + }) +}) + app.get('/.well-known/fides.json', (c) => { return c.json({ schema_version: 'fides.well_known.v1', diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 5235260..f50737c 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -419,6 +419,30 @@ describe('Agentd Service Routes', () => { }), ])) expect(discoveredData.candidates[0].reasons).toContain('url_not_required_for_local_discovery') + + const localDiscovered = await app.request('/discover/local', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'invoice.reconcile' }), + }) + expect(localDiscovered.status).toBe(200) + expect(await localDiscovered.json()).toMatchObject({ + provider: 'local', + authorityGranted: false, + count: 1, + }) + + const wellKnownDiscovered = await app.request('/discover/well-known', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'invoice.reconcile' }), + }) + expect(wellKnownDiscovered.status).toBe(200) + expect(await wellKnownDiscovered.json()).toMatchObject({ + provider: 'well-known', + authorityGranted: false, + count: 1, + }) }) it('evaluates root trust, reputation, and policy for a registered local candidate', async () => { @@ -934,6 +958,20 @@ describe('Agentd Service Routes', () => { expect((await postFind.json()).pointers).toEqual(expect.arrayContaining([ expect.objectContaining({ agentId: 'did:fides:agent' }), ])) + + const discoverDht = await app.request('/discover/dht', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'invoice.reconcile' }), + }) + expect(discoverDht.status).toBe(200) + expect(await discoverDht.json()).toMatchObject({ + provider: 'dht', + authorityGranted: false, + pointers: expect.arrayContaining([ + expect.objectContaining({ agentId: 'did:fides:agent' }), + ]), + }) }) it('serves local registry, relay, and well-known discovery aliases without authority', async () => { @@ -978,6 +1016,20 @@ describe('Agentd Service Routes', () => { expect.objectContaining({ agentId: identity.did }), ])) + const discoverRegistry = await app.request('/discover/registry', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'calendar.schedule' }), + }) + expect(discoverRegistry.status).toBe(200) + expect(await discoverRegistry.json()).toMatchObject({ + provider: 'registry', + authorityGranted: false, + records: expect.arrayContaining([ + expect.objectContaining({ agentId: identity.did }), + ]), + }) + const index = await app.request('/registry/index') expect(index.status).toBe(200) expect((await index.json()).records).toEqual(expect.arrayContaining([ @@ -1010,6 +1062,20 @@ describe('Agentd Service Routes', () => { expect.objectContaining({ agentId: identity.did }), ])) + const discoverRelay = await app.request('/discover/relay', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'calendar.schedule' }), + }) + expect(discoverRelay.status).toBe(200) + expect(await discoverRelay.json()).toMatchObject({ + provider: 'relay', + authorityGranted: false, + records: expect.arrayContaining([ + expect.objectContaining({ agentId: identity.did }), + ]), + }) + const wellKnown = await app.request('/.well-known/fides.json') expect(wellKnown.status).toBe(200) expect((await wellKnown.json()).endpoints.discovery).toBe('/discover') From d5ded4a309b07eef93413e9ba24b37f57ad3e75a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:49:45 +0300 Subject: [PATCH 044/282] feat(cli): add provider discovery options --- docs/cli-reference.md | 15 ++++- packages/cli/src/commands/discover.ts | 77 ++++++++++++++++++++++++- packages/cli/test/commands.test.ts | 83 +++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 5 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3a1f112..9272e39 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -38,6 +38,11 @@ agentd identity list agentd identity show did:fides:... agentd identity domain challenge example.com did:fides:... agentd identity domain verify example.com did:fides:... +agentd discover "reconcile invoices" --capability invoice.reconcile --provider local +agentd discover --capability invoice.reconcile --provider registry +agentd discover --capability invoice.reconcile --provider relay +agentd discover --capability invoice.reconcile --provider dht +agentd discover --capability invoice.reconcile --all-providers agentd demo run agentd simulate adversarial agentd registry start @@ -55,6 +60,12 @@ Local identity files are stored under `~/.fides/identities` by default. Set `identity show` and `identity list` do not print private keys; private keys stay inside the local identity file. +`discover --capability` targets local `agentd` capability discovery. Use +`--provider local`, `well-known`, `registry`, `relay`, `dht`, or +`--all-providers` to choose the provider surface. These commands return +candidates, registry records, relay presence records, or DHT pointers only; +they do not grant invocation authority. + `registry`, `relay`, and `dht` commands target local `agentd` discovery -surfaces by default. They return registry records, relay presence records, or -DHT pointers only; they do not grant invocation authority. +surfaces by default. They expose provider-specific publish/start/search +operations and keep authority separate from discovery. diff --git a/packages/cli/src/commands/discover.ts b/packages/cli/src/commands/discover.ts index d25d0f3..b28bcbb 100644 --- a/packages/cli/src/commands/discover.ts +++ b/packages/cli/src/commands/discover.ts @@ -2,15 +2,32 @@ import { Command } from 'commander'; import { DiscoveryClient, TrustClient } from '@fides/sdk'; import { loadConfig } from '../utils/config.js'; import { error, info, formatScore } from '../utils/output.js'; +import { postJson, printResult } from './authority-utils.js'; + +const DISCOVERY_PROVIDERS = ['local', 'well-known', 'registry', 'relay', 'dht'] as const +type DiscoveryProviderName = typeof DISCOVERY_PROVIDERS[number] export function createDiscoverCommand(): Command { const cmd = new Command('discover'); cmd - .description('Discover and resolve an agent identity') - .argument('', 'DID or domain to discover') - .action(async (input) => { + .description('Discover agent identities or capability candidates') + .argument('[agent-did-or-domain-or-intent]', 'DID/domain to resolve, or an intent when --capability is provided') + .option('--capability ', 'Capability to discover, e.g. invoice.reconcile') + .option('--provider ', 'Discovery provider: local, well-known, registry, relay, dht, all', 'local') + .option('--all-providers', 'Query all local agentd discovery providers') + .option('--constraints ', 'Discovery constraints as a JSON object') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (input, options) => { try { + if (options.capability) { + await discoverCapability(input, options); + return; + } + if (!input) { + throw new Error('agent DID/domain is required unless --capability is provided'); + } await discoverAgent(input); } catch (err) { error(`Failed to discover agent: ${err instanceof Error ? err.message : String(err)}`); @@ -21,6 +38,60 @@ export function createDiscoverCommand(): Command { return cmd; } +async function discoverCapability( + intent: string | undefined, + options: { + capability: string + provider?: string + allProviders?: boolean + constraints?: string + agentdUrl: string + json?: boolean + } +): Promise { + const providers = options.allProviders || options.provider === 'all' + ? DISCOVERY_PROVIDERS + : [normalizeProvider(options.provider ?? 'local')] + const constraints = options.constraints ? parseObject(options.constraints, '--constraints') : undefined + const query = { + ...(intent ? { intent } : {}), + capability: options.capability, + ...(constraints ? { constraints } : {}), + } + const results = await Promise.all(providers.map(async (provider) => { + const path = provider === 'local' ? '/discover/local' : `/discover/${provider}` + return { + provider, + result: await postJson(`${baseUrl(options.agentdUrl)}${path}`, query), + } + })) + + printResult('Discovery candidates:', { + query, + authorityGranted: false, + results, + }, options) +} + +function normalizeProvider(provider: string): DiscoveryProviderName { + if ((DISCOVERY_PROVIDERS as readonly string[]).includes(provider)) { + return provider as DiscoveryProviderName + } + throw new Error(`unsupported discovery provider: ${provider}`) +} + +function parseObject(value: string, label: string): Record { + const parsed = JSON.parse(value) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${label} must be a JSON object`) + } + return parsed as Record +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} + async function discoverAgent(input: string): Promise { const config = loadConfig(); const discoveryClient = new DiscoveryClient({ baseUrl: config.discoveryUrl }); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 04fe9da..559eaca 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -428,6 +428,89 @@ describe('CLI Commands', () => { expect(mockDiscoveryClient.resolve).toHaveBeenCalledWith('did:fides:test123'); expect(mockTrustClient.getScore).toHaveBeenCalledWith(mockIdentity.did); }); + + it('discovers capabilities through a selected local agentd provider', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + provider: 'registry', + authorityGranted: false, + records: [{ agentId: 'did:fides:agent' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createDiscoverCommand } = await import('../src/commands/discover.js'); + const cmd = createDiscoverCommand(); + + await cmd.parseAsync([ + 'reconcile invoices', + '--capability', + 'invoice.reconcile', + '--provider', + 'registry', + '--constraints', + '{"tenant":"acme"}', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://agentd.test/discover/registry', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + intent: 'reconcile invoices', + capability: 'invoice.reconcile', + constraints: { tenant: 'acme' }, + }), + }) + ); + }); + + it('discovers capabilities through every local agentd provider', async () => { + const mockFetch = vi.fn(async (url: string | URL | Request) => new Response(JSON.stringify({ + provider: String(url).split('/').at(-1), + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createDiscoverCommand } = await import('../src/commands/discover.js'); + const cmd = createDiscoverCommand(); + + await cmd.parseAsync([ + '--capability', + 'calendar.schedule', + '--all-providers', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/discover/local', + expect.objectContaining({ method: 'POST' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'http://agentd.test/discover/well-known', + expect.objectContaining({ method: 'POST' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'http://agentd.test/discover/registry', + expect.objectContaining({ method: 'POST' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + 'http://agentd.test/discover/relay', + expect.objectContaining({ method: 'POST' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 5, + 'http://agentd.test/discover/dht', + expect.objectContaining({ method: 'POST' }) + ); + }); }); describe('identity domain commands', () => { From 5e5e2dfe0612f0e6ace5afbb30a4fb0e9fa2c313 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 01:56:06 +0300 Subject: [PATCH 045/282] feat(demo): execute local trust fabric scenario --- docs/api-reference.md | 7 + examples/full-demo/README.md | 17 +- examples/full-demo/run.ts | 11 +- services/agentd/src/index.ts | 395 +++++++++++++++++++++++++++- services/agentd/test/routes.test.ts | 16 +- 5 files changed, 431 insertions(+), 15 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index be07214..32c7a3d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -113,6 +113,13 @@ default; the daemon records `sha256:` hashes and metadata under `hash_only` privacy unless another privacy mode is explicitly requested. Root evidence is tamper-evident inside the process, not yet durable across daemon restarts. +`POST /demo/run` executes the local FIDES v2 trust-fabric scenario in the +current daemon process. It creates demo identities and signed AgentCards, +publishes candidates through local registry, relay, and DHT surfaces, performs +provider discovery, verifies AgentCards, evaluates trust/reputation/policy, +issues scoped sessions, invokes invoice and payment dry-run flows, records an +incident and revocation, and verifies the local EvidenceEvent hash chain. + `POST /identities` creates local in-memory identities for the daemon prototype and returns only public identity data. Private keys are retained inside the daemon process and are not returned by `POST /identities`, `GET /identities`, or diff --git a/examples/full-demo/README.md b/examples/full-demo/README.md index f516511..485918e 100644 --- a/examples/full-demo/README.md +++ b/examples/full-demo/README.md @@ -1,6 +1,6 @@ # Full Demo -This directory captures the target full-demo contract for FIDES v2. +This directory captures the executable full-demo contract for FIDES v2. Run the manifest: @@ -8,10 +8,21 @@ Run the manifest: pnpm exec tsx examples/full-demo/run.ts ``` -Run the local daemon prototype endpoint: +Run the local daemon endpoint: ```bash agentd demo run ``` -The current manifest is spec-complete. The daemon endpoint is a working local prototype. The remaining hardening step is to execute every manifest step against real local agentd state and evidence events. +`agentd demo run` creates local demo identities, signs and registers AgentCards, +publishes candidates through local registry/relay/DHT surfaces, runs discovery, +computes trust and reputation, evaluates policy, issues scoped sessions, invokes +the invoice and payment dry-run paths, records incident and revocation state, and +verifies the local EvidenceEvent hash chain. + +Current limitations: + +- DHT, relay, and registry are local mock providers. +- Demo state is held in the current daemon process. +- Payment execution remains Sardis-specific; FIDES only demonstrates dry-run + payment preparation authority. diff --git a/examples/full-demo/run.ts b/examples/full-demo/run.ts index e561f9d..42296b4 100644 --- a/examples/full-demo/run.ts +++ b/examples/full-demo/run.ts @@ -1,9 +1,9 @@ /** - * Full FIDES v2 demo manifest. + * Full FIDES v2 demo contract. * * This is a deterministic, local-first scenario description that mirrors the - * `agentd demo run` flow. It is intentionally side-effect-light so docs, - * tests, and future scripts can share the same ordered contract. + * executing `agentd demo run` flow. It is intentionally side-effect-light so + * docs, tests, and future scripts can share the same ordered contract. * * Run: pnpm exec tsx examples/full-demo/run.ts */ @@ -46,9 +46,10 @@ export const fullDemoSteps = [ 'print_final_trust_graph', ] as const -export function describeFullDemo(): { status: 'spec-complete'; steps: readonly string[] } { +export function describeFullDemo(): { status: 'manifest'; execution: string; steps: readonly string[] } { return { - status: 'spec-complete', + status: 'manifest', + execution: 'agentd demo run executes this contract against local daemon state', steps: fullDemoSteps, } } diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 8d59b10..3304862 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -1981,29 +1981,412 @@ app.get('/evidence/:eventId', (c) => { return c.json({ event, authorityGranted: false }) }) -app.post('/demo/run', (c) => { - return c.json({ - status: 'spec-complete', +async function createDemoAgent(input: { + name: string + capability: ReturnType + publisher?: PublisherIdentity + runtimeAttestations?: RuntimeAttestation[] +}): Promise<{ identity: LocalIdentityRecord; card: AgentCard; signed: SignedAgentCard; registration: LocalRegisteredAgent }> { + const identity = await createLocalIdentity('agent', { name: input.name }) + localIdentities.set(identity.identity.did, identity) + const now = new Date().toISOString() + const card: AgentCard = { + schema_version: 'fides.agent_card.v1', + id: identity.identity.did, + agent_id: identity.identity.did, + identity: identity.identity as AgentIdentity, + ...(input.publisher ? { publisher: input.publisher } : {}), + capabilities: [input.capability], + endpoints: [], + policies: [{ + requiresRuntimeAttestation: input.capability.requiresRuntimeAttestation, + requiresApproval: input.capability.requiresApproval, + }], + publicKeys: [{ id: `${identity.identity.did}#ed25519`, type: 'Ed25519', publicKey: identity.publicKeyHex }], + runtimeAttestations: input.runtimeAttestations ?? [], + protocolVersions: ['fides.v2.0'], + createdAt: now, + updatedAt: now, + } + localAgentCards.set(card.id, card) + const signed = await signAgentCard(card, Buffer.from(identity.privateKeyHex, 'hex'), card.identity.did) + localSignedAgentCards.set(card.id, signed) + const registration = { + agentId: card.identity.did, + cardId: card.id, + registeredAt: now, + signed: true, + } + localAgents.set(registration.agentId, registration) + return { identity, card, signed, registration } +} + +async function runLocalFullDemo() { + const principal = await createLocalIdentity('principal', { name: 'Demo Principal' }) + const publisher = await createLocalIdentity('publisher', { name: 'Demo Publisher', domain: 'demo.fides.local' }) + const requester = await createLocalIdentity('agent', { name: 'Requester Agent' }) + localIdentities.set(principal.identity.did, principal) + localIdentities.set(publisher.identity.did, publisher) + localIdentities.set(requester.identity.did, requester) + + const calendarCapability = createCapabilityDescriptor({ + id: 'calendar.schedule', + riskLevel: 'low', + requiredScopes: ['calendar:write'], + supportedControls: ['dry_run', 'policy_proof'], + supportsDryRun: true, + supportsPolicyProof: true, + }) + const invoiceCapability = createCapabilityDescriptor({ + id: 'invoice.reconcile', + riskLevel: 'medium', + requiredScopes: ['invoice:read'], + supportedControls: ['dry_run', 'policy_proof'], + supportsDryRun: true, + supportsPolicyProof: true, + }) + const paymentCapability = createCapabilityDescriptor({ + id: 'payments.prepare', + riskLevel: 'high', + requiredScopes: ['payments:prepare'], + supportedControls: ['dry_run', 'human_approval', 'runtime_attestation', 'policy_proof'], + supportsDryRun: true, + supportsHumanApproval: true, + supportsPolicyProof: true, + }) + + const calendar = await createDemoAgent({ name: 'Calendar Agent', capability: calendarCapability, publisher: publisher.identity as PublisherIdentity }) + const invoice = await createDemoAgent({ name: 'Invoice Agent', capability: invoiceCapability, publisher: publisher.identity as PublisherIdentity }) + const payment = await createDemoAgent({ name: 'Payment Agent', capability: paymentCapability, publisher: publisher.identity as PublisherIdentity }) + + const registryRecord = localRegistryRecordFor(invoice.card.id) + if (registryRecord) localRegistryRecords.set(String(registryRecord.id), registryRecord) + const relayRecord = { + id: `relay_${calendar.card.identity.did}`, + agentId: calendar.card.identity.did, + cardId: calendar.card.id, + capabilities: calendar.card.capabilities.map(capability => capability.id), + endpointHints: ['local://calendar-agent'], + online: true, + registeredAt: new Date().toISOString(), + authorityGranted: false, + source: 'agentd-local-relay', + } + localRelayRecords.set(calendar.card.identity.did, relayRecord) + const dhtPointer = { + id: crypto.randomUUID(), + capability: paymentCapability.id, + agentId: payment.card.identity.did, + agentCardUrl: `local://agent-cards/${payment.card.id}`, + publishedAt: new Date().toISOString(), + source: 'agentd-in-memory-dht', + } + localDhtPointers.push(dhtPointer) + + const calendarDiscovery = localDiscoveryResult({ capability: calendarCapability.id }, 'local') + const invoiceRegistryRecords = Array.from(localRegistryRecords.values()).filter((record) => ( + (record.capabilities as string[] | undefined)?.includes(invoiceCapability.id) + )) + const paymentDhtPointers = findLocalDhtPointers(paymentCapability.id) + const verifiedCards = await Promise.all([calendar.signed, invoice.signed, payment.signed].map(verifySignedAgentCard)) + + const invoiceTrust = computeLocalTrustResult(invoice.card.identity.did, invoiceCapability.id) + const invoiceReputation = computeCapabilityReputation({ + agentId: invoice.card.identity.did, + publisherId: publisher.identity.did, + capability: invoiceCapability.id, + successfulInvocations: 8, + failedInvocations: 1, + incidentCount: 0, + publisherWeight: 0.7, + }) + localReputationRecords.set(localCapabilityKey(invoice.card.identity.did, invoiceCapability.id), invoiceReputation) + + const invoiceSessionTrust = computeLocalTrustResult(invoice.card.identity.did, invoiceCapability.id) + const invoicePolicy = evaluateFidesPolicy({ + principalId: principal.identity.did, + requesterAgentId: requester.identity.did, + targetAgentId: invoice.card.identity.did, + capability: invoiceCapability, + trustResult: invoiceSessionTrust!, + requestedScopes: ['invoice:read'], + }) + const invoiceSession = createSessionGrantV2({ + requesterAgentId: requester.identity.did, + targetAgentId: invoice.card.identity.did, + principalId: principal.identity.did, + capability: invoiceCapability.id, + scopes: ['invoice:read'], + constraints: {}, + policyHash: hashProtocolPayload(invoicePolicy), + trustResultHash: hashProtocolPayload(invoiceSessionTrust), + audience: [invoice.card.identity.did], + issuer: 'did:fides:agentd:local', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }) + localSessionGrants.set(invoiceSession.session_id, { session: invoiceSession, policy: invoicePolicy, trust: invoiceSessionTrust! }) + const invoiceSessionEvidence = appendRootEvidence({ + type: 'session.granted', + actor: requester.identity.did, + subject: invoice.card.identity.did, + principal: principal.identity.did, + capability: invoiceCapability.id, + policy: invoicePolicy, + decision: invoicePolicy.decision, + risk_level: invoiceCapability.riskLevel, + privacy_mode: 'hash_only', + metadata: { session_id: invoiceSession.session_id, demo: true }, + }) + + const invoiceRequest = createInvocationRequest({ + issuer: requester.identity.did, + sessionGrant: invoiceSession, + input: { invoiceId: 'inv_demo_001' }, + dryRun: false, + inputSchema: invoiceCapability.inputSchema, + outputSchema: invoiceCapability.outputSchema, + }) + const invoicePreflight = evaluateInvocationPreflight({ request: invoiceRequest, policyDecision: invoicePolicy }) + const invoiceInvokeEvidence = appendRootEvidence({ + type: 'capability.invoked', + actor: requester.identity.did, + subject: invoice.card.identity.did, + principal: principal.identity.did, + capability: invoiceCapability.id, + input: { invoiceId: 'inv_demo_001' }, + policy_hash: invoiceSession.policy_hash, + decision: invoicePolicy.decision, + privacy_mode: 'hash_only', + metadata: { session_id: invoiceSession.session_id, demo: true }, + }) + const invoiceCompleteEvidence = appendRootEvidence({ + type: 'capability.completed', + actor: invoice.card.identity.did, + subject: requester.identity.did, + principal: principal.identity.did, + capability: invoiceCapability.id, + output: { reconciled: true }, + policy_hash: invoiceSession.policy_hash, + decision: invoicePreflight.can_execute ? 'completed' : invoicePreflight.status, + privacy_mode: 'hash_only', + metadata: { session_id: invoiceSession.session_id, demo: true }, + }) + + const paymentTrustMissingAttestation = computeLocalTrustResult(payment.card.identity.did, paymentCapability.id)! + const paymentPolicyWithoutAttestation = evaluateFidesPolicy({ + principalId: principal.identity.did, + requesterAgentId: requester.identity.did, + targetAgentId: payment.card.identity.did, + capability: paymentCapability, + trustResult: paymentTrustMissingAttestation, + requestedScopes: ['payments:prepare'], + }) + const paymentDeniedEvidence = appendRootEvidence({ + type: 'session.denied', + actor: requester.identity.did, + subject: payment.card.identity.did, + principal: principal.identity.did, + capability: paymentCapability.id, + policy: paymentPolicyWithoutAttestation, + decision: paymentPolicyWithoutAttestation.decision, + risk_level: paymentCapability.riskLevel, + privacy_mode: 'hash_only', + metadata: { demo: true, reason: 'missing_runtime_attestation' }, + }) + + const paymentAttestation = await runtimeAttestationProvider.issue({ + agentId: payment.card.identity.did, + codeHash: `sha256:${'a'.repeat(64)}`, + runtimeHash: `sha256:${'b'.repeat(64)}`, + policyHash: `sha256:${'c'.repeat(64)}`, + }) + localRuntimeAttestations.set(paymentAttestation.attestation_id, paymentAttestation) + const paymentCard = { + ...payment.card, + runtimeAttestations: [paymentAttestation], + updatedAt: new Date().toISOString(), + } + localAgentCards.set(paymentCard.id, paymentCard) + + const paymentTrust = computeLocalTrustResult(payment.card.identity.did, paymentCapability.id)! + const paymentPolicy = evaluateFidesPolicy({ + principalId: principal.identity.did, + requesterAgentId: requester.identity.did, + targetAgentId: payment.card.identity.did, + capability: paymentCapability, + trustResult: paymentTrust, + requestedScopes: ['payments:prepare'], + runtimeAttestationValid: await runtimeAttestationProvider.verify(paymentAttestation), + }) + const paymentSession = createSessionGrantV2({ + requesterAgentId: requester.identity.did, + targetAgentId: payment.card.identity.did, + principalId: principal.identity.did, + capability: paymentCapability.id, + scopes: ['payments:prepare'], + constraints: { dryRunOnly: true }, + policyHash: hashProtocolPayload(paymentPolicy), + trustResultHash: hashProtocolPayload(paymentTrust), + audience: [payment.card.identity.did], + issuer: 'did:fides:agentd:local', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }) + localSessionGrants.set(paymentSession.session_id, { session: paymentSession, policy: paymentPolicy, trust: paymentTrust }) + const paymentDryRunRequest = createInvocationRequest({ + issuer: requester.identity.did, + sessionGrant: paymentSession, + input: { paymentId: 'pay_demo_001', amount: 100, currency: 'USD' }, + dryRun: true, + inputSchema: paymentCapability.inputSchema, + outputSchema: paymentCapability.outputSchema, + }) + const paymentDryRunPreflight = evaluateInvocationPreflight({ request: paymentDryRunRequest, policyDecision: paymentPolicy }) + const paymentDryRunEvidence = appendRootEvidence({ + type: paymentDryRunPreflight.can_execute ? 'capability.completed' : 'capability.failed', + actor: payment.card.identity.did, + subject: requester.identity.did, + principal: principal.identity.did, + capability: paymentCapability.id, + output: paymentDryRunPreflight.can_execute ? { dryRun: true, prepared: true } : undefined, + policy_hash: paymentSession.policy_hash, + decision: paymentDryRunPreflight.can_execute ? 'completed' : paymentDryRunPreflight.status, + privacy_mode: 'hash_only', + metadata: { session_id: paymentSession.session_id, demo: true }, + }) + + const malicious = await createDemoAgent({ + name: 'Malicious Fake Agent', + capability: createCapabilityDescriptor({ id: 'calendar.schedule', riskLevel: 'low', requiredScopes: ['calendar:write'] }), + publisher: publisher.identity as PublisherIdentity, + }) + const incident = createIncidentRecordV2({ + reporter: principal.identity.did, + targetAgentId: malicious.card.identity.did, + severity: 'critical', + category: 'unauthorized_action', + description: 'Demo malicious agent attempted to launder a high-risk action through a low-risk capability.', + evidenceRefs: [paymentDeniedEvidence.event_id], + }) + localIncidentRecords.set(incident.id, incident) + const maliciousReputation = computeCapabilityReputation({ + agentId: malicious.card.identity.did, + publisherId: publisher.identity.did, + capability: 'calendar.schedule', + successfulInvocations: 0, + failedInvocations: 3, + incidentCount: 1, + publisherWeight: 0.1, + contextBoundaryMismatch: true, + }) + localReputationRecords.set(localCapabilityKey(malicious.card.identity.did, 'calendar.schedule'), maliciousReputation) + const revocation = createRevocationRecordV2({ + issuer: principal.identity.did, + targetType: 'agent', + targetId: malicious.card.identity.did, + reason: 'Demo malicious behavior.', + evidenceRefs: [incident.id], + }) + localRevocationRecords.set(revocation.id, revocation) + const revokedTrust = computeLocalTrustResult(malicious.card.identity.did, 'calendar.schedule') + const revokedPolicy = evaluateFidesPolicy({ + principalId: principal.identity.did, + requesterAgentId: requester.identity.did, + targetAgentId: malicious.card.identity.did, + capability: malicious.card.capabilities[0]!, + trustResult: revokedTrust!, + requestedScopes: ['calendar:write'], + revocationActive: true, + incidentsActive: true, + }) + + const evidenceValid = verifyEvidenceEventsV2(localEvidenceEvents) + return { + status: 'executed', mode: 'local-first', steps: fullDemoSteps, + identities: { + principal: principal.identity.did, + publisher: publisher.identity.did, + requester: requester.identity.did, + calendar: calendar.card.identity.did, + invoice: invoice.card.identity.did, + payment: payment.card.identity.did, + malicious: malicious.card.identity.did, + }, + discovery: { + local: calendarDiscovery, + registry: { provider: 'registry', records: invoiceRegistryRecords, authorityGranted: false }, + dht: { provider: 'dht', ...paymentDhtPointers, authorityGranted: false }, + relay: { provider: 'relay', records: [relayRecord], authorityGranted: false }, + }, + verification: { + agentCardsVerified: verifiedCards.every(Boolean), + evidenceHashChainValid: evidenceValid, + evidenceEventCount: localEvidenceEvents.length, + evidenceExport: { + format: 'json', + lastHash: localEvidenceEvents.at(-1)?.event_hash ?? null, + }, + }, + trust: { + invoice: invoiceTrust, + payment: paymentTrust, + maliciousAfterIncident: revokedTrust, + }, + reputation: { + invoice: invoiceReputation, + malicious: maliciousReputation, + }, + policy: { + invoice: invoicePolicy, + paymentWithoutAttestation: paymentPolicyWithoutAttestation, + paymentWithAttestation: paymentPolicy, + revokedMalicious: revokedPolicy, + }, + sessions: { + invoice: invoiceSession, + paymentDryRun: paymentSession, + }, + invocation: { + invoice: { + preflight: invoicePreflight, + evidenceRefs: [invoiceSessionEvidence.event_id, invoiceInvokeEvidence.event_id, invoiceCompleteEvidence.event_id], + }, + paymentDryRun: { + preflight: paymentDryRunPreflight, + evidenceRefs: [paymentDryRunEvidence.event_id], + }, + }, + governance: { + incident, + revocation, + }, authority: { discoveryGrantsAuthority: false, identityEqualsTrust: false, trustScoreEqualsPermission: false, policyBeforeExecution: true, - evidenceProduced: true, + evidenceProduced: localEvidenceEvents.length > 0, }, surfaces: { local: true, - registry: 'mock', - relay: 'mock', + registry: 'local_mock', + relay: 'local_mock', dht: 'in_memory_pointer_records', payments: 'dry_run_only', }, limitations: [ 'Uses local mock services for DHT, relay, and registry flows.', 'Payment execution remains Sardis-specific and is not executed by FIDES.', + 'Demo state is held in the current daemon process.', ], + } +} + +app.post('/demo/run', async (c) => { + const result = await runLocalFullDemo() + return c.json({ + ...result, }) }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index f50737c..0cd8cb3 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1095,13 +1095,27 @@ describe('Agentd Service Routes', () => { const demo = await app.request('/demo/run', { method: 'POST' }) expect(demo.status).toBe(200) const demoData = await demo.json() - expect(demoData.status).toBe('spec-complete') + expect(demoData.status).toBe('executed') expect(demoData.steps).toContain('discover_payment_through_dht') expect(demoData.steps).toContain('verify_evidence_hash_chain') expect(demoData.authority).toMatchObject({ discoveryGrantsAuthority: false, policyBeforeExecution: true, }) + expect(demoData.verification).toMatchObject({ + agentCardsVerified: true, + evidenceHashChainValid: true, + }) + expect(demoData.policy.paymentWithoutAttestation.decision).toBe('require_approval') + expect(demoData.policy.revokedMalicious.decision).toBe('deny') + expect(demoData.discovery.registry.records).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentId: demoData.identities.invoice }), + ])) + expect(demoData.discovery.dht.pointers).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentId: demoData.identities.payment }), + ])) + expect(demoData.invocation.invoice.preflight.can_execute).toBe(true) + expect(demoData.verification.evidenceEventCount).toBeGreaterThan(0) const sim = await app.request('/simulate/adversarial', { method: 'POST' }) expect(sim.status).toBe(200) From 957ea3f22a57f8d27fa4bc12dd226c9143e6f343 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:00:56 +0300 Subject: [PATCH 046/282] feat(sim): execute local adversarial harness --- docs/adversarial-simulation.md | 30 ++- services/agentd/src/index.ts | 404 ++++++++++++++++++++++++---- services/agentd/test/routes.test.ts | 7 +- 3 files changed, 380 insertions(+), 61 deletions(-) diff --git a/docs/adversarial-simulation.md b/docs/adversarial-simulation.md index 38b7a5f..5a2e54c 100644 --- a/docs/adversarial-simulation.md +++ b/docs/adversarial-simulation.md @@ -1,6 +1,8 @@ # Adversarial Simulation -FIDES includes a local adversarial simulation endpoint and CLI command. +FIDES includes a local adversarial simulation endpoint and CLI command. The +current harness executes against local daemon state and protocol primitives; it +does not just return a static scenario manifest. Current implementation anchors: @@ -26,4 +28,28 @@ agentd simulate adversarial - high-risk capability abuse - broken evidence chain -Current behavior is a working local prototype. The next hardening step is to wire each scenario to real protocol objects and evidence events. +## Current Behavior + +The simulation creates local identities and an adversarial AgentCard, then runs +each scenario through the relevant FIDES primitive: + +- policy and trust evaluation for fake agents and fake publishers +- signed DHT pointer verification for malicious pointer tampering +- canonical AgentCard signature verification for tampered cards +- MockTEE verification for expired runtime attestations +- revocation-aware policy denial for revoked agents +- trust scoring with peer-signal downweighting for collusion +- capability-specific reputation and context-boundary penalties +- high-risk policy gating for payment execution attempts +- EvidenceEvent hash-chain verification for broken evidence chains + +Each scenario emits a local EvidenceEvent reference. The endpoint reports +`status: "detected"` only when every scenario is detected by the relevant +primitive. + +Limitations: + +- The harness uses local daemon memory, not durable storage. +- DHT, relay, and registry transport behavior is still local/mock. +- Payment execution remains Sardis-specific; FIDES only demonstrates authority + denial, approval gating, and dry-run control. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 3304862..a3da55c 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -39,16 +39,20 @@ import { createInvocationResult, createKillSwitchRule, createDelegationToken, + createDHTPointerRecord, createPrincipalIdentity, createPublisherIdentity, createRevocationRecordV2, createSessionGrantV2, + hashAgentCard, hashProtocolPayload, isKillSwitchRuleActive, MockTEEProvider as CoreMockTEEProvider, resolveIncidentRecordV2, signAgentCard, + signDHTPointerRecord, validateAgentCard, + verifyDHTPointerRecord, verifySignedAgentCard, verifyDelegationTokenSignature, verifyDomainDid, @@ -2390,57 +2394,332 @@ app.post('/demo/run', async (c) => { }) }) -app.post('/simulate/adversarial', (c) => { +async function runLocalAdversarialSimulation() { + const principal = await createLocalIdentity('principal', { name: 'Simulation Principal' }) + const publisher = await createLocalIdentity('publisher', { name: 'Simulation Publisher' }) + const requester = await createLocalIdentity('agent', { name: 'Simulation Requester' }) + localIdentities.set(principal.identity.did, principal) + localIdentities.set(publisher.identity.did, publisher) + localIdentities.set(requester.identity.did, requester) + const capability = createCapabilityDescriptor({ id: 'payments.execute', + riskLevel: 'critical', requiredScopes: ['payments:execute'], supportedControls: ['human_approval', 'runtime_attestation', 'policy_proof'], + supportsHumanApproval: true, + supportsPolicyProof: true, }) - const incident = createIncidentRecordV2({ - reporter: 'did:fides:principal', - targetAgentId: 'did:fides:malicious-agent', - severity: 'critical', - category: 'unauthorized_action', - description: 'Agent attempted to launder payment execution as a low-risk calendar action.', - evidenceRefs: ['evt_malicious_1'], + const launderingCapability = createCapabilityDescriptor({ + id: 'calendar.schedule', + riskLevel: 'low', + requiredScopes: ['calendar:write'], + supportedControls: ['dry_run'], + supportsDryRun: true, }) - const reputation = computeCapabilityReputation({ - agentId: 'did:fides:malicious-agent', + const malicious = await createDemoAgent({ + name: 'Adversarial Payment Agent', + capability, + publisher: publisher.identity as PublisherIdentity, + }) + + const scenarioEvents: Record = {} + const recordScenario = ( + name: string, + decision: string, + evidenceInput: Omit + ) => { + const event = appendRootEvidence({ + type: 'policy.evaluated', + actor: requester.identity.did, + subject: malicious.card.identity.did, + principal: principal.identity.did, + capability: capability.id, + decision, + privacy_mode: 'hash_only', + metadata: { simulation: 'adversarial', scenario: name }, + ...evidenceInput, + }) + scenarioEvents[name] = event.event_id + return event + } + + const fakeAgentTrust = computeTrustResult({ + agentId: 'did:fides:fake-agent', + capability, + components: { + identity: 0, + publisher: 0, + trustAnchors: 0, + capabilityFit: 0.2, + evidence: 0, + policyCompliance: 0, + runtimeSafety: 0, + peerAttestation: 0, + incidentPenalty: 0.4, + noveltyPenalty: 1, + contextBoundaryPenalty: 0.5, + }, + }) + const fakeAgentPolicy = evaluateFidesPolicy({ + principalId: principal.identity.did, + requesterAgentId: requester.identity.did, + targetAgentId: 'did:fides:fake-agent', + capability, + trustResult: fakeAgentTrust, + requestedScopes: ['payments:execute'], + }) + recordScenario('fake_agent', fakeAgentPolicy.decision, { policy: fakeAgentPolicy, risk_level: capability.riskLevel }) + + const fakePublisherReputation = computeCapabilityReputation({ + agentId: malicious.card.identity.did, publisherId: 'did:fides:fake-publisher', capability: capability.id, successfulInvocations: 0, - failedInvocations: 4, + failedInvocations: 2, incidentCount: 1, - publisherWeight: 0.1, - contextBoundaryMismatch: true, + publisherWeight: 0, }) - const trust = computeTrustResult({ - agentId: 'did:fides:malicious-agent', + const fakePublisherTrust = computeTrustResult({ + agentId: malicious.card.identity.did, + capability, + components: { + identity: 0.8, + publisher: 0, + trustAnchors: 0, + capabilityFit: 0.7, + evidence: 0.1, + policyCompliance: 0.1, + runtimeSafety: 0, + peerAttestation: 0, + incidentPenalty: fakePublisherReputation.incident_count, + noveltyPenalty: 0.8, + contextBoundaryPenalty: 0, + }, + }) + recordScenario('fake_publisher', 'trust_penalty', { output: fakePublisherTrust, risk_level: capability.riskLevel }) + + const validPointer = await signDHTPointerRecord(createDHTPointerRecord({ + capability: capability.id, + agentId: malicious.card.identity.did, + agentCardUrl: `local://agent-cards/${malicious.card.id}`, + agentCardHash: hashAgentCard(malicious.card), + publisherId: publisher.identity.did, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }), Buffer.from(publisher.privateKeyHex, 'hex'), publisher.identity.did) + const maliciousPointer = { ...validPointer, capability: 'payments.refund' } + const maliciousPointerResult = await verifyDHTPointerRecord(maliciousPointer, { + card: malicious.card, + verificationMethod: publisher.identity.did, + }) + recordScenario('malicious_dht_pointer', maliciousPointerResult.valid ? 'accepted' : 'rejected', { + output: maliciousPointerResult, + risk_level: capability.riskLevel, + }) + + const tamperedSignedCard: SignedAgentCard = { + ...malicious.signed, + payload: { + ...malicious.signed.payload, + capabilities: [createCapabilityDescriptor({ + id: 'payments.execute', + riskLevel: 'critical', + requiredScopes: [], + })], + }, + } + const tamperedAgentCardValid = await verifySignedAgentCard(tamperedSignedCard) + recordScenario('tampered_agent_card', tamperedAgentCardValid ? 'accepted' : 'signature_rejected', { + output: { valid: tamperedAgentCardValid }, + risk_level: capability.riskLevel, + }) + + const expiredRuntimeAttestation = await runtimeAttestationProvider.issue({ + agentId: malicious.card.identity.did, + codeHash: `sha256:${'1'.repeat(64)}`, + runtimeHash: `sha256:${'2'.repeat(64)}`, + policyHash: `sha256:${'3'.repeat(64)}`, + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }) + const expiredRuntimeAttestationValid = await runtimeAttestationProvider.verify(expiredRuntimeAttestation) + const expiredAttestationPolicy = evaluateFidesPolicy({ + principalId: principal.identity.did, + requesterAgentId: requester.identity.did, + targetAgentId: malicious.card.identity.did, + capability, + trustResult: computeLocalTrustResult(malicious.card.identity.did, capability.id)!, + requestedScopes: ['payments:execute'], + runtimeAttestationValid: expiredRuntimeAttestationValid, + }) + recordScenario('expired_runtime_attestation', expiredAttestationPolicy.decision, { + policy: expiredAttestationPolicy, + output: { valid: expiredRuntimeAttestationValid, attestation_id: expiredRuntimeAttestation.attestation_id }, + risk_level: capability.riskLevel, + }) + + const revocation = createRevocationRecordV2({ + issuer: principal.identity.did, + targetType: 'agent', + targetId: malicious.card.identity.did, + reason: 'Adversarial simulation revoked malicious agent.', + evidenceRefs: [scenarioEvents.tampered_agent_card], + }) + localRevocationRecords.set(revocation.id, revocation) + const revokedPolicy = evaluateFidesPolicy({ + principalId: principal.identity.did, + requesterAgentId: requester.identity.did, + targetAgentId: malicious.card.identity.did, + capability, + trustResult: computeLocalTrustResult(malicious.card.identity.did, capability.id)!, + requestedScopes: ['payments:execute'], + revocationActive: true, + }) + recordScenario('revoked_agent', revokedPolicy.decision, { + policy: revokedPolicy, + output: revocation, + risk_level: capability.riskLevel, + }) + + const collusiveTrust = computeTrustResult({ + agentId: malicious.card.identity.did, capability, - evidenceRefs: incident.evidence_refs, components: { identity: 0.2, publisher: 0.1, trustAnchors: 0, capabilityFit: 0.4, - evidence: 0.1, + evidence: 0, policyCompliance: 0, runtimeSafety: 0, + peerAttestation: 1, + incidentPenalty: 0.5, + noveltyPenalty: 0.7, + contextBoundaryPenalty: 0.2, + }, + }) + recordScenario('collusive_trust_attestations', 'peer_signal_downweighted', { + output: collusiveTrust, + risk_level: capability.riskLevel, + }) + + const contextReputation = computeCapabilityReputation({ + agentId: malicious.card.identity.did, + publisherId: publisher.identity.did, + capability: launderingCapability.id, + successfulInvocations: 4, + failedInvocations: 3, + incidentCount: 1, + publisherWeight: 0.1, + contextBoundaryMismatch: true, + }) + const contextTrust = computeTrustResult({ + agentId: malicious.card.identity.did, + capability: launderingCapability, + components: { + identity: 0.8, + publisher: 0.2, + trustAnchors: 0.1, + capabilityFit: 0.3, + evidence: contextReputation.score, + policyCompliance: 0.1, + runtimeSafety: 0.2, peerAttestation: 0.1, - incidentPenalty: incident.trust_penalty, - noveltyPenalty: 0.4, - contextBoundaryPenalty: reputation.context_boundary_penalty, + incidentPenalty: 0.4, + noveltyPenalty: 0.2, + contextBoundaryPenalty: contextReputation.context_boundary_penalty, }, }) + recordScenario('context_laundering', 'context_boundary_penalty', { + output: { reputation: contextReputation, trust: contextTrust }, + risk_level: launderingCapability.riskLevel, + }) + + const highRiskPolicy = evaluateFidesPolicy({ + principalId: principal.identity.did, + requesterAgentId: requester.identity.did, + targetAgentId: malicious.card.identity.did, + capability, + trustResult: computeTrustResult({ + agentId: malicious.card.identity.did, + capability, + components: { + identity: 1, + publisher: 0.8, + trustAnchors: 0.8, + capabilityFit: 1, + evidence: 0.8, + policyCompliance: 0.8, + runtimeSafety: 0.2, + peerAttestation: 0.4, + incidentPenalty: 0, + noveltyPenalty: 0, + contextBoundaryPenalty: 0, + }, + }), + requestedScopes: ['payments:execute'], + }) + recordScenario('high_risk_capability_abuse', highRiskPolicy.decision, { + policy: highRiskPolicy, + risk_level: capability.riskLevel, + }) + + const firstEvidence = createEvidenceEventV2({ + type: 'capability.invoked', + actor: requester.identity.did, + subject: malicious.card.identity.did, + principal: principal.identity.did, + capability: capability.id, + input: { amount: 1000 }, + privacy_mode: 'hash_only', + }, '0') + const brokenEvidence = { + ...createEvidenceEventV2({ + type: 'capability.completed', + actor: malicious.card.identity.did, + subject: requester.identity.did, + principal: principal.identity.did, + capability: capability.id, + output: { status: 'forged' }, + privacy_mode: 'hash_only', + }, firstEvidence.event_hash), + prev_event_hash: 'sha256:forged-previous', + } + const brokenEvidenceChainValid = verifyEvidenceEventsV2([firstEvidence, brokenEvidence]) + recordScenario('broken_evidence_chain', brokenEvidenceChainValid ? 'verified' : 'evidence_verification_failed', { + output: { valid: brokenEvidenceChainValid }, + risk_level: capability.riskLevel, + }) + + const incident = createIncidentRecordV2({ + reporter: principal.identity.did, + targetAgentId: malicious.card.identity.did, + severity: 'critical', + category: 'unauthorized_action', + description: 'Agent attempted to launder payment execution as a low-risk calendar action.', + evidenceRefs: Object.values(scenarioEvents), + }) + localIncidentRecords.set(incident.id, incident) + const incidentEvidence = appendRootEvidence({ + type: 'incident.reported', + actor: principal.identity.did, + subject: malicious.card.identity.did, + principal: principal.identity.did, + capability: capability.id, + decision: 'reported', + risk_level: capability.riskLevel, + privacy_mode: 'hash_only', + metadata: { simulation: 'adversarial', incident_id: incident.id }, + }) + const preflight = evaluateInvocationPreflight({ request: { schema_version: 'fides.invocation.request.v1', id: 'inv_req_malicious', - issuer: 'did:fides:requester', + issuer: requester.identity.did, session_id: 'missing-session', - requester_agent_id: 'did:fides:requester', - target_agent_id: 'did:fides:malicious-agent', - principal_id: 'did:fides:principal', + requester_agent_id: requester.identity.did, + target_agent_id: malicious.card.identity.did, + principal_id: principal.identity.did, capability: capability.id, scopes: ['payments:execute'], dry_run: false, @@ -2448,43 +2727,52 @@ app.post('/simulate/adversarial', (c) => { issued_at: new Date().toISOString(), payload_hash: 'sha256:payload', }, - policyDecision: { - decision: 'deny', - reason_codes: ['REVOCATION_ACTIVE', 'TRUST_BELOW_THRESHOLD'], - }, - }) + policyDecision: revokedPolicy, + }) + + const scenarios = [ + { name: 'fake_agent', detected: fakeAgentPolicy.decision === 'risk_limit' || fakeAgentPolicy.decision === 'dry_run_only', outcome: 'policy_limited', evidenceRef: scenarioEvents.fake_agent, policy: fakeAgentPolicy, trust: fakeAgentTrust }, + { name: 'fake_publisher', detected: fakePublisherTrust.band === 'unknown' || fakePublisherTrust.band === 'low', outcome: 'trust_penalty', evidenceRef: scenarioEvents.fake_publisher, reputation: fakePublisherReputation, trust: fakePublisherTrust }, + { name: 'malicious_dht_pointer', detected: !maliciousPointerResult.valid, outcome: 'pointer_rejected', evidenceRef: scenarioEvents.malicious_dht_pointer, errors: maliciousPointerResult.errors }, + { name: 'tampered_agent_card', detected: !tamperedAgentCardValid, outcome: 'signature_rejected', evidenceRef: scenarioEvents.tampered_agent_card }, + { name: 'expired_runtime_attestation', detected: !expiredRuntimeAttestationValid && expiredAttestationPolicy.decision === 'require_approval', outcome: 'approval_required_or_denied', evidenceRef: scenarioEvents.expired_runtime_attestation, policy: expiredAttestationPolicy }, + { name: 'revoked_agent', detected: revokedPolicy.decision === 'deny', outcome: 'revocation_denied', evidenceRef: scenarioEvents.revoked_agent, policy: revokedPolicy }, + { name: 'collusive_trust_attestations', detected: collusiveTrust.band === 'unknown' || collusiveTrust.band === 'low', outcome: 'peer_signal_downweighted', evidenceRef: scenarioEvents.collusive_trust_attestations, trust: collusiveTrust }, + { name: 'context_laundering', detected: contextTrust.risk_flags.includes('context_boundary'), outcome: 'context_boundary_penalty', evidenceRef: scenarioEvents.context_laundering, reputation: contextReputation, trust: contextTrust }, + { name: 'high_risk_capability_abuse', detected: highRiskPolicy.decision === 'require_approval', outcome: 'approval_required', evidenceRef: scenarioEvents.high_risk_capability_abuse, policy: highRiskPolicy }, + { name: 'broken_evidence_chain', detected: !brokenEvidenceChainValid, outcome: 'evidence_verification_failed', evidenceRef: scenarioEvents.broken_evidence_chain }, + ] - return c.json({ - status: 'detected', - detections: [ - 'fake_agent', - 'fake_publisher', - 'malicious_dht_pointer', - 'tampered_agent_card', - 'expired_runtime_attestation', - 'revoked_agent', - 'collusive_trust_attestations', - 'context_laundering', - 'high_risk_capability_abuse', - 'broken_evidence_chain', - ], - scenarios: [ - { name: 'fake_agent', detected: true, outcome: 'policy_denied' }, - { name: 'fake_publisher', detected: true, outcome: 'trust_penalty' }, - { name: 'malicious_dht_pointer', detected: true, outcome: 'pointer_rejected' }, - { name: 'tampered_agent_card', detected: true, outcome: 'signature_rejected' }, - { name: 'expired_runtime_attestation', detected: true, outcome: 'approval_required_or_denied' }, - { name: 'revoked_agent', detected: true, outcome: 'revocation_denied' }, - { name: 'collusive_trust_attestations', detected: true, outcome: 'peer_signal_downweighted' }, - { name: 'context_laundering', detected: true, outcome: 'context_boundary_penalty' }, - { name: 'high_risk_capability_abuse', detected: true, outcome: 'approval_required' }, - { name: 'broken_evidence_chain', detected: true, outcome: 'evidence_verification_failed' }, - ], + return { + status: scenarios.every(scenario => scenario.detected) ? 'detected' : 'partial', + mode: 'local-first', + detections: scenarios.map(scenario => scenario.name), + scenarios, incident, - reputation, - trust, + revocation, preflight, - }) + evidence: { + scenarioEvents, + incidentEvidenceRef: incidentEvidence.event_id, + rootChainValid: verifyEvidenceEventsV2(localEvidenceEvents), + rootEventCount: localEvidenceEvents.length, + brokenEvidenceChainValid, + }, + authority: { + discoveryGrantsAuthority: false, + policyBeforeExecution: true, + evidenceProduced: Object.keys(scenarioEvents).length === scenarios.length, + }, + limitations: [ + 'Simulation uses local daemon state and mock provider primitives.', + 'DHT, relay, and registry transport behavior is not networked in this harness.', + ], + } +} + +app.post('/simulate/adversarial', async (c) => { + const result = await runLocalAdversarialSimulation() + return c.json(result) }) // ─── Identity Resolution (proxy to discovery) ───────────────────── diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 0cd8cb3..e4c3b60 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1124,7 +1124,12 @@ describe('Agentd Service Routes', () => { expect(data.scenarios.map((scenario: any) => scenario.name)).toContain('tampered_agent_card') expect(data.scenarios.every((scenario: any) => scenario.detected)).toBe(true) expect(data.detections).toContain('context_laundering') - expect(data.trust.band).toBe('unknown') + expect(data.scenarios.find((scenario: any) => scenario.name === 'fake_agent').policy.decision).toBe('dry_run_only') + expect(data.scenarios.find((scenario: any) => scenario.name === 'malicious_dht_pointer').errors).toContain('DHT pointer signature is invalid') + expect(data.scenarios.find((scenario: any) => scenario.name === 'tampered_agent_card').outcome).toBe('signature_rejected') + expect(data.scenarios.find((scenario: any) => scenario.name === 'broken_evidence_chain').outcome).toBe('evidence_verification_failed') + expect(data.evidence.rootChainValid).toBe(true) + expect(data.evidence.brokenEvidenceChainValid).toBe(false) expect(data.preflight.status).toBe('denied') }) From 0b403358b4b2d83344be8bb008685ae97f80d6e7 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:08:24 +0300 Subject: [PATCH 047/282] feat(agentd): persist local state in sqlite --- docs/api-reference.md | 41 +++--- docs/deployment.md | 22 +++- docs/getting-started.md | 5 + services/agentd/src/index.ts | 151 +++++++++++++++++++++- services/agentd/src/storage.ts | 184 +++++++++++++++++++++++++++ services/agentd/test/routes.test.ts | 3 + services/agentd/test/storage.test.ts | 31 +++++ 7 files changed, 417 insertions(+), 20 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 32c7a3d..22d3b33 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -93,9 +93,12 @@ Current implementation anchors: - `POST /evidence/export` The root v2 endpoints are local-first daemon surfaces. Registry and relay are -mock/local providers, while root evidence uses an in-memory hash-chained ledger -for the current daemon process and should be backed by durable storage before -production use. +mock/local providers, and the root daemon persists its local v2 state through a +SQLite-backed snapshot store by default outside tests. The default path is +`~/.fides/fides.sqlite`; set `AGENTD_SQLITE_PATH` to override it or +`AGENTD_LOCAL_STATE=memory` to disable persistence for ephemeral local runs. +This store is a daemon snapshot, not the final normalized SQLite table model for +production hardening. `POST /registry/start`, `POST /registry/publish`, `POST /registry/search`, and `GET /registry/index` provide a local mock registry over registered AgentCards. @@ -111,7 +114,8 @@ local well-known metadata for same-host discovery. EvidenceEvent ledger. Sensitive inputs and outputs are not stored directly by default; the daemon records `sha256:` hashes and metadata under `hash_only` privacy unless another privacy mode is explicitly requested. Root evidence is -tamper-evident inside the process, not yet durable across daemon restarts. +tamper-evident through the hash chain and is persisted in the local daemon +snapshot when SQLite state is enabled. `POST /demo/run` executes the local FIDES v2 trust-fabric scenario in the current daemon process. It creates demo identities and signed AgentCards, @@ -120,18 +124,22 @@ provider discovery, verifies AgentCards, evaluates trust/reputation/policy, issues scoped sessions, invokes invoice and payment dry-run flows, records an incident and revocation, and verifies the local EvidenceEvent hash chain. -`POST /identities` creates local in-memory identities for the daemon prototype -and returns only public identity data. Private keys are retained inside the -daemon process and are not returned by `POST /identities`, `GET /identities`, or -`GET /identities/:id`. This route is protected by the same production API-key +`POST /identities` creates local daemon identities and returns only public +identity data. Private keys are retained in local daemon state for prototype +signing and are not returned by `POST /identities`, `GET /identities`, or +`GET /identities/:id`. When SQLite state is enabled, that local signing material +is included in the daemon snapshot and must be protected by filesystem controls; +OS-backed encryption or hardware-backed key storage remains production +hardening work. This route is protected by the same production API-key fail-closed behavior as other mutating `agentd` routes. -`POST /agent-cards` creates local in-memory AgentCards bound to local daemon -identities. `POST /agent-cards/:id/sign` signs the stored card with the local -agent identity key using the canonical AgentCard signing model, and +`POST /agent-cards` creates local AgentCards bound to local daemon identities. +`POST /agent-cards/:id/sign` signs the stored card with the local agent identity +key using the canonical AgentCard signing model, and `POST /agent-cards/:id/verify` verifies the signed card when present. These -routes are prototype-local until the daemon storage layer is migrated to -durable SQLite-backed identity/card storage. +routes are durable across daemon restarts when SQLite local state is enabled, +but remain prototype-local until the daemon state is migrated from snapshot +storage to normalized identity/card tables. `POST /agents/register` registers a locally stored AgentCard as a discovery candidate. `GET /agents` and `GET /agents/:id` expose local registration state @@ -160,9 +168,10 @@ returns `authorityGranted: false`; it must still be signed and converted into a policy-checked SessionGrant before invocation. `POST /sessions` issues a local `SessionGrant` only after policy allows or limits the action to dry-run. `POST /invoke` verifies the session, runs the policy preflight path, validates -the capability context, and returns an `InvocationResult`. The current root -implementation is in-memory and intended for local daemon DX; durable storage -and signed invocation results remain follow-up hardening work. +the capability context, and returns an `InvocationResult`. Invocation state and +result evidence are persisted in the local daemon snapshot when SQLite state is +enabled; signed invocation results and normalized durable tables remain +follow-up hardening work. `POST /approvals` creates an approval request and records approval decisions through `/approvals/:id/approve` or `/approvals/:id/deny`. Approval records do diff --git a/docs/deployment.md b/docs/deployment.md index 7f04457..ce90611 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -16,7 +16,7 @@ | `policy-engine` | 3300 | None | Deterministic policy evaluation | | `registry` | 7346 | PostgreSQL or file | AgentCard registry with durable hosted storage | | `relay` | 7347 | File or memory | Message relay for NAT/firewall traversal | -| `agentd` | 7345 | PostgreSQL or file | Local daemon unifying all services and durable authority state | +| `agentd` | 7345 | PostgreSQL, file, and local SQLite | Local daemon unifying all services, durable authority state, and local v2 agent state | | `platform-api` | 3600 | None | Platform health, version, and topology metadata | --- @@ -65,6 +65,26 @@ cp .env.example .env | `AGENTD_STATE_STORE_PATH` | _(empty)_ | no | File authority store path. Defaults to `~/.fides/agentd/authority-store.json`. | | `AGENTD_REQUIRE_AUTHORITY_SIGNATURE_VERIFICATION` | `true` in production, `false` otherwise | no | When `true`, agentd rejects delegation, revocation, and incident writes unless the request includes the corresponding signer public key for canonical signature verification. Set `false` only for transitional deployments that cannot yet send signer public keys. | +### Agentd Local V2 State + +This store is separate from the legacy authority store above. It persists the +root v2 local daemon surfaces: identities, AgentCards, registered agents, +registry/relay/DHT local records, trust and reputation results, approvals, kill +switch rules, revocations, incidents, runtime attestations, sessions, and +EvidenceEvents. + +| Variable | Default | Required | Description | +| -------------------- | ------- | -------- | ----------- | +| `AGENTD_LOCAL_STATE` | `sqlite` outside tests, `memory` in tests | no | `sqlite` persists the root v2 local daemon snapshot. `memory` keeps the root v2 prototype ephemeral for local test runs. | +| `AGENTD_SQLITE_PATH` | `~/.fides/fides.sqlite` | no | SQLite file path for the root v2 local daemon snapshot store. | + +The SQLite store currently writes a single schema-versioned snapshot row plus a +local migration ledger. It is durable across daemon restarts, but it is not yet +the final normalized table layout. Local identity private key material used for +prototype signing is included in this snapshot and should be protected by local +filesystem permissions; OS-backed encryption or hardware-backed key storage is a +production hardening item. + ### Registry Store | Variable | Default | Required | Description | diff --git a/docs/getting-started.md b/docs/getting-started.md index 564505c..b668523 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -83,6 +83,11 @@ This starts: - **Agent Daemon** on `http://localhost:7345` - **Platform API** on `http://localhost:3600` +The local Agent Daemon stores root v2 prototype state in +`~/.fides/fides.sqlite` by default outside tests. Set +`AGENTD_SQLITE_PATH=/path/to/fides.sqlite` to use a different file, or +`AGENTD_LOCAL_STATE=memory` for an ephemeral daemon run. + **Verify services are running:** ```bash curl http://localhost:3100/.well-known/fides.json diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index a3da55c..30a4c42 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -78,11 +78,12 @@ import { type SignedAgentCard, type TrustResult, } from '@fides/core' -import { createAuthorityStore } from './storage.js' +import { createAuthorityStore, createLocalDaemonStateStore, emptyLocalDaemonStateSnapshot } from './storage.js' import type { AuthorityPropagationRecord, AuthorityPropagationRecordType, AuthorityPropagationStatus, + LocalDaemonStateSnapshot, } from './storage.js' import { logger } from './middleware/logger.js' import { securityHeaders } from './middleware/security.js' @@ -102,6 +103,7 @@ const teeProvider = new RuntimeMockTEEProvider() const runtimeAttestationProvider = new CoreMockTEEProvider() const killSwitch = new InMemoryKillSwitch() const authorityStore = createAuthorityStore() +const localStateStore = createLocalDaemonStateStore() const localDhtPointers: Array> = [] type LocalIdentityType = 'agent' | 'publisher' | 'principal' interface LocalIdentityRecord { @@ -139,6 +141,7 @@ interface LocalSessionRecord { trust: TrustResult } const localSessionGrants = new Map() +let localStateLoaded = false const fullDemoSteps = [ 'initialize_daemon', 'create_principal_identity', @@ -194,6 +197,138 @@ function bytesToHex(bytes: Uint8Array): string { return Buffer.from(bytes).toString('hex') } +function mapValues(items: T[]): Map { + return new Map(items.flatMap((item) => { + const id = typeof item === 'object' && item !== null + ? (item as Record).id + : undefined + return typeof id === 'string' ? [[id, item] as const] : [] + })) +} + +function hydrateLocalState(snapshot: LocalDaemonStateSnapshot): void { + localIdentities.clear() + for (const record of snapshot.identities as LocalIdentityRecord[]) { + if (record?.identity?.did) localIdentities.set(record.identity.did, record) + } + localAgentCards.clear() + for (const card of snapshot.agentCards as AgentCard[]) { + if (card?.id) localAgentCards.set(card.id, card) + } + localSignedAgentCards.clear() + for (const signed of snapshot.signedAgentCards as SignedAgentCard[]) { + if (signed?.payload?.id) localSignedAgentCards.set(signed.payload.id, signed) + } + localAgents.clear() + for (const record of snapshot.agents as LocalRegisteredAgent[]) { + if (record?.agentId) localAgents.set(record.agentId, record) + } + localDhtPointers.length = 0 + localDhtPointers.push(...snapshot.dhtPointers as Array>) + localRegistryRecords.clear() + for (const record of snapshot.registryRecords as Array>) { + if (typeof record.id === 'string') localRegistryRecords.set(record.id, record) + } + localRelayRecords.clear() + for (const record of snapshot.relayRecords as Array>) { + const id = typeof record.agentId === 'string' ? record.agentId : typeof record.id === 'string' ? record.id : undefined + if (id) localRelayRecords.set(id, record) + } + localTrustResults.clear() + for (const trust of snapshot.trustResults as TrustResult[]) { + if (trust?.agent_id && trust?.capability) localTrustResults.set(localCapabilityKey(trust.agent_id, trust.capability), trust) + } + localReputationRecords.clear() + for (const record of snapshot.reputationRecords as ReputationRecord[]) { + if (record?.agent_id && record?.capability) localReputationRecords.set(localCapabilityKey(record.agent_id, record.capability), record) + } + localDelegationTokens.clear() + for (const token of snapshot.delegationTokens as DelegationToken[]) { + if (token?.id) localDelegationTokens.set(token.id, token) + } + replaceMap(localApprovals, mapValues(snapshot.approvals as ApprovalRequest[])) + replaceMap(localApprovalDecisions, mapValues(snapshot.approvalDecisions as ApprovalDecision[])) + replaceMap(localKillSwitchRules, mapValues(snapshot.killSwitchRules as KillSwitchRule[])) + replaceMap(localRevocationRecords, mapValues(snapshot.revocationRecords as RevocationRecordV2[])) + replaceMap(localIncidentRecords, mapValues(snapshot.incidentRecords as IncidentRecordV2[])) + localRuntimeAttestations.clear() + for (const attestation of snapshot.runtimeAttestations as RuntimeAttestation[]) { + if (attestation?.attestation_id) localRuntimeAttestations.set(attestation.attestation_id, attestation) + } + localEvidenceEvents = snapshot.evidenceEvents as EvidenceEventV2[] + localSessionGrants.clear() + for (const record of snapshot.sessionGrants as LocalSessionRecord[]) { + if (record?.session?.session_id) localSessionGrants.set(record.session.session_id, record) + } +} + +function replaceMap(target: Map, source: Map): void { + target.clear() + for (const [key, value] of source) target.set(key, value) +} + +function localStateSnapshot(): LocalDaemonStateSnapshot { + return { + ...emptyLocalDaemonStateSnapshot(), + identities: Array.from(localIdentities.values()), + agentCards: Array.from(localAgentCards.values()), + signedAgentCards: Array.from(localSignedAgentCards.values()), + agents: Array.from(localAgents.values()), + dhtPointers: [...localDhtPointers], + registryRecords: Array.from(localRegistryRecords.values()), + relayRecords: Array.from(localRelayRecords.values()), + trustResults: Array.from(localTrustResults.values()), + reputationRecords: Array.from(localReputationRecords.values()), + delegationTokens: Array.from(localDelegationTokens.values()), + approvals: Array.from(localApprovals.values()), + approvalDecisions: Array.from(localApprovalDecisions.values()), + killSwitchRules: Array.from(localKillSwitchRules.values()), + revocationRecords: Array.from(localRevocationRecords.values()), + incidentRecords: Array.from(localIncidentRecords.values()), + runtimeAttestations: Array.from(localRuntimeAttestations.values()), + evidenceEvents: localEvidenceEvents, + sessionGrants: Array.from(localSessionGrants.values()), + } +} + +async function ensureLocalStateLoaded(): Promise { + if (localStateLoaded) return + const snapshot = await localStateStore.load() + if (snapshot) hydrateLocalState(snapshot) + localStateLoaded = true +} + +async function persistLocalState(): Promise { + if (!localStateLoaded) return + await localStateStore.save(localStateSnapshot()) +} + +function shouldPersistLocalState(method: string, path: string, status: number): boolean { + if (status >= 500) return false + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false + return [ + '/identities', + '/agent-cards', + '/agents', + '/trust', + '/reputation', + '/delegations', + '/sessions', + '/invoke', + '/approvals', + '/killswitch', + '/revocations', + '/incidents', + '/attestations', + '/dht', + '/registry', + '/relay', + '/evidence', + '/demo', + '/simulate', + ].some(prefix => path === prefix || path.startsWith(`${prefix}/`)) +} + async function createLocalIdentity( type: LocalIdentityType, input: { name?: string; domain?: string } @@ -382,6 +517,13 @@ app.use('*', cors({ origin: getCorsOrigin(), exposeHeaders: ['X-Request-Id'], })) +app.use('*', async (c, next) => { + await ensureLocalStateLoaded() + await next() + if (shouldPersistLocalState(c.req.method, new URL(c.req.url).pathname, c.res.status)) { + await persistLocalState() + } +}) // Auth on mutating endpoints (skip GET /health) app.use('/v1/*', async (c, next) => { if (c.req.method === 'GET') return next() @@ -573,14 +715,15 @@ app.get('/health', async (c) => { } } - const [discovery, trustGraph, registry, authority] = await Promise.all([ + const [discovery, trustGraph, registry, authority, localState] = await Promise.all([ probe(DISCOVERY_URL), probe(TRUST_GRAPH_URL), probe(REGISTRY_URL), authorityStore.healthCheck(), + localStateStore.healthCheck(), ]) - const allOk = discovery.reachable && trustGraph.reachable && registry.reachable && authority.ok + const allOk = discovery.reachable && trustGraph.reachable && registry.reachable && authority.ok && localState.ok const status = allOk ? 'healthy' : 'degraded' return c.json({ @@ -593,12 +736,14 @@ app.get('/health', async (c) => { trustGraph: trustGraph.reachable ? 'connected' : 'unreachable', registry: registry.reachable ? 'connected' : 'unreachable', authorityStore: authority.ok ? 'ready' : 'unready', + localStateStore: localState.ok ? 'ready' : 'unready', }, authorityStore: { kind: authority.kind, ok: authority.ok, detail: authority.detail, }, + localStateStore, }, allOk ? 200 : 503) }) diff --git a/services/agentd/src/storage.ts b/services/agentd/src/storage.ts index 382991a..acf7889 100644 --- a/services/agentd/src/storage.ts +++ b/services/agentd/src/storage.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' import postgres from 'postgres' import type { EvidenceChain } from '@fides/evidence' import type { @@ -33,6 +34,37 @@ export interface AuthorityStoreHealth { detail?: string } +export interface LocalDaemonStateSnapshot { + schemaVersion: 'fides.agentd.local_state.v1' + updatedAt: string + identities: unknown[] + agentCards: unknown[] + signedAgentCards: unknown[] + agents: unknown[] + dhtPointers: unknown[] + registryRecords: unknown[] + relayRecords: unknown[] + trustResults: unknown[] + reputationRecords: unknown[] + delegationTokens: unknown[] + approvals: unknown[] + approvalDecisions: unknown[] + killSwitchRules: unknown[] + revocationRecords: unknown[] + incidentRecords: unknown[] + runtimeAttestations: unknown[] + evidenceEvents: unknown[] + sessionGrants: unknown[] +} + +export interface LocalDaemonStateStore { + readonly kind: 'memory' | 'sqlite' + load(): Promise + save(snapshot: LocalDaemonStateSnapshot): Promise + healthCheck(): Promise<{ ok: boolean; kind: LocalDaemonStateStore['kind']; path?: string; detail?: string }> + close?(): Promise +} + export type AuthorityPropagationRecordType = 'revocation' | 'incident' export type AuthorityPropagationStatus = 'pending' | 'confirmed' | 'failed' @@ -627,6 +659,158 @@ export function createAuthorityStore(): AuthorityStore { return new FileAuthorityStore(process.env.AGENTD_STATE_STORE_PATH) } +export class InMemoryLocalDaemonStateStore implements LocalDaemonStateStore { + readonly kind = 'memory' as const + private snapshot: LocalDaemonStateSnapshot | null = null + + async load(): Promise { + return this.snapshot + } + + async save(snapshot: LocalDaemonStateSnapshot): Promise { + this.snapshot = snapshot + } + + async healthCheck(): Promise<{ ok: boolean; kind: 'memory' }> { + return { ok: true, kind: this.kind } + } +} + +export class SqliteLocalDaemonStateStore implements LocalDaemonStateStore { + readonly kind = 'sqlite' as const + private db: unknown + + constructor(readonly path = join(homedir(), '.fides', 'fides.sqlite')) {} + + async load(): Promise { + const db = await this.database() + const row = db.prepare('SELECT snapshot FROM agentd_local_state WHERE id = ?').get('root') as { snapshot?: string } | undefined + if (!row?.snapshot) return null + return normalizeLocalDaemonStateSnapshot(JSON.parse(row.snapshot)) + } + + async save(snapshot: LocalDaemonStateSnapshot): Promise { + const db = await this.database() + const normalized = normalizeLocalDaemonStateSnapshot(snapshot) + db.prepare(` + INSERT INTO agentd_local_state (id, snapshot, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET snapshot = excluded.snapshot, updated_at = excluded.updated_at + `).run('root', JSON.stringify(normalized), normalized.updatedAt) + } + + async healthCheck(): Promise<{ ok: boolean; kind: 'sqlite'; path: string; detail?: string }> { + try { + await this.database() + return { ok: true, kind: this.kind, path: this.path } + } catch (error) { + return { + ok: false, + kind: this.kind, + path: this.path, + detail: error instanceof Error ? error.message : String(error), + } + } + } + + async close(): Promise { + if (this.db && typeof (this.db as { close?: unknown }).close === 'function') { + ;(this.db as { close: () => void }).close() + this.db = undefined + } + } + + private async database(): Promise<{ + exec(statement: string): void + prepare(statement: string): { + get(...params: unknown[]): unknown + run(...params: unknown[]): unknown + } + close(): void + }> { + if (this.db) { + return this.db as Awaited> + } + + await mkdir(dirname(this.path), { recursive: true }) + const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as typeof import('node:sqlite') + const db = new DatabaseSync(this.path) + db.exec(` + CREATE TABLE IF NOT EXISTS agentd_local_state ( + id TEXT PRIMARY KEY, + snapshot TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS agentd_local_state_migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + INSERT OR IGNORE INTO agentd_local_state_migrations (id) VALUES ('001_local_state_snapshot'); + `) + this.db = db + return db as Awaited> + } +} + +export function emptyLocalDaemonStateSnapshot(updatedAt = new Date().toISOString()): LocalDaemonStateSnapshot { + return { + schemaVersion: 'fides.agentd.local_state.v1', + updatedAt, + identities: [], + agentCards: [], + signedAgentCards: [], + agents: [], + dhtPointers: [], + registryRecords: [], + relayRecords: [], + trustResults: [], + reputationRecords: [], + delegationTokens: [], + approvals: [], + approvalDecisions: [], + killSwitchRules: [], + revocationRecords: [], + incidentRecords: [], + runtimeAttestations: [], + evidenceEvents: [], + sessionGrants: [], + } +} + +export function normalizeLocalDaemonStateSnapshot(value: unknown): LocalDaemonStateSnapshot { + const input = value && typeof value === 'object' ? value as Partial : {} + const base = emptyLocalDaemonStateSnapshot(typeof input.updatedAt === 'string' ? input.updatedAt : undefined) + return { + ...base, + schemaVersion: 'fides.agentd.local_state.v1', + identities: Array.isArray(input.identities) ? input.identities : [], + agentCards: Array.isArray(input.agentCards) ? input.agentCards : [], + signedAgentCards: Array.isArray(input.signedAgentCards) ? input.signedAgentCards : [], + agents: Array.isArray(input.agents) ? input.agents : [], + dhtPointers: Array.isArray(input.dhtPointers) ? input.dhtPointers : [], + registryRecords: Array.isArray(input.registryRecords) ? input.registryRecords : [], + relayRecords: Array.isArray(input.relayRecords) ? input.relayRecords : [], + trustResults: Array.isArray(input.trustResults) ? input.trustResults : [], + reputationRecords: Array.isArray(input.reputationRecords) ? input.reputationRecords : [], + delegationTokens: Array.isArray(input.delegationTokens) ? input.delegationTokens : [], + approvals: Array.isArray(input.approvals) ? input.approvals : [], + approvalDecisions: Array.isArray(input.approvalDecisions) ? input.approvalDecisions : [], + killSwitchRules: Array.isArray(input.killSwitchRules) ? input.killSwitchRules : [], + revocationRecords: Array.isArray(input.revocationRecords) ? input.revocationRecords : [], + incidentRecords: Array.isArray(input.incidentRecords) ? input.incidentRecords : [], + runtimeAttestations: Array.isArray(input.runtimeAttestations) ? input.runtimeAttestations : [], + evidenceEvents: Array.isArray(input.evidenceEvents) ? input.evidenceEvents : [], + sessionGrants: Array.isArray(input.sessionGrants) ? input.sessionGrants : [], + } +} + +export function createLocalDaemonStateStore(): LocalDaemonStateStore { + if (process.env.AGENTD_LOCAL_STATE === 'memory' || process.env.NODE_ENV === 'test') { + return new InMemoryLocalDaemonStateStore() + } + return new SqliteLocalDaemonStateStore(process.env.AGENTD_SQLITE_PATH) +} + function requiredDatabaseUrl(): string { const url = process.env.AGENTD_DATABASE_URL || process.env.DATABASE_URL if (!url) { diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index e4c3b60..c072a11 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -283,7 +283,9 @@ describe('Agentd Service Routes', () => { expect(data.checks.trustGraph).toBe('connected') expect(data.checks.registry).toBe('connected') expect(data.checks.authorityStore).toBe('ready') + expect(data.checks.localStateStore).toBe('ready') expect(data.authorityStore.kind).toBe('memory') + expect(data.localStateStore.kind).toBe('memory') expect(data.status).toBe('healthy') }) @@ -298,6 +300,7 @@ describe('Agentd Service Routes', () => { expect(data.checks.trustGraph).toBe('unreachable') expect(data.checks.registry).toBe('unreachable') expect(data.checks.authorityStore).toBe('ready') + expect(data.checks.localStateStore).toBe('ready') }) }) diff --git a/services/agentd/test/storage.test.ts b/services/agentd/test/storage.test.ts index 7b392f9..e975529 100644 --- a/services/agentd/test/storage.test.ts +++ b/services/agentd/test/storage.test.ts @@ -11,6 +11,8 @@ import { InMemoryAuthorityStore, PostgresAuthorityStore, createAuthorityClient, + emptyLocalDaemonStateSnapshot, + SqliteLocalDaemonStateStore, runAuthorityMigrations, } from '../src/storage.js' @@ -139,6 +141,35 @@ describe('agentd authority stores', () => { expect((await store.getSession(grant.id))?.revocationReason).toBe('manual') }) + it('persists root local daemon state to sqlite', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fides-agentd-sqlite-')) + tempDirs.push(dir) + const path = join(dir, 'fides.sqlite') + const store = new SqliteLocalDaemonStateStore(path) + const snapshot = { + ...emptyLocalDaemonStateSnapshot('2026-01-01T00:00:00.000Z'), + identities: [{ did: 'did:fides:agent' }], + agentCards: [{ id: 'card-1' }], + agents: [{ agentId: 'did:fides:agent', cardId: 'card-1' }], + evidenceEvents: [{ event_id: 'evt-1' }], + } + + await store.save(snapshot) + await store.close?.() + + const reopened = new SqliteLocalDaemonStateStore(path) + const loaded = await reopened.load() + await reopened.close?.() + + expect(loaded).toMatchObject({ + schemaVersion: 'fides.agentd.local_state.v1', + identities: [{ did: 'did:fides:agent' }], + agentCards: [{ id: 'card-1' }], + agents: [{ agentId: 'did:fides:agent', cardId: 'card-1' }], + evidenceEvents: [{ event_id: 'evt-1' }], + }) + }) + it('rejects unsafe configured authority schema names', () => { const previousSchema = process.env.AGENTD_DB_SCHEMA From c52a2e617d6af7cae2bd36e1c7b0f922b3cd67e2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:10:38 +0300 Subject: [PATCH 048/282] feat(sdk): expose agentd local state health --- docs/cli-reference.md | 5 ++++ docs/sdk-reference.md | 4 +++- packages/cli/src/commands/daemon.ts | 19 +++++++++++++++ packages/cli/test/commands.test.ts | 16 +++++++++++++ packages/sdk/src/agentd/client.ts | 21 ++++++++++++++++ packages/sdk/src/index.ts | 2 ++ packages/sdk/test/agentd.test.ts | 37 +++++++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9272e39..796df48 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -53,6 +53,7 @@ agentd relay register did:fides:... agentd relay discover --capability invoice.reconcile agentd dht find --capability invoice.reconcile agentd evidence verify +agentd daemon status ``` Local identity files are stored under `~/.fides/identities` by default. Set @@ -69,3 +70,7 @@ they do not grant invocation authority. `registry`, `relay`, and `dht` commands target local `agentd` discovery surfaces by default. They expose provider-specific publish/start/search operations and keep authority separate from discovery. + +`daemon status` calls `GET /health` and prints upstream checks, the authority +store, and the root v2 local state store. When SQLite local state is enabled, +the status output includes the SQLite path used for the daemon snapshot. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 3d6f803..384e2aa 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -150,4 +150,6 @@ MockTEE attestations that can satisfy high-risk session policy when passed as an `attestationId`. Registry, relay, DHT, and well-known helpers expose the local mock discovery surfaces. They return candidate records or pointers only; they do not convert discovery into authority. -Advanced authority flows can use `AgentdClient`. +Advanced authority flows can use `AgentdClient`. `AgentdClient.health()` reads +`GET /health` and returns typed authority-store and local-state-store status, +including the SQLite snapshot path when the daemon exposes it. diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 4b2e477..31cc51c 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -18,6 +18,13 @@ interface AgentdHealth { authorityStore?: { kind?: string ok?: boolean + path?: string + detail?: string + } + localStateStore?: { + kind?: string + ok?: boolean + path?: string detail?: string } } @@ -148,10 +155,22 @@ function printAgentdHealth(agentdUrl: string, health: AgentdHealth): void { } if (health.authorityStore) { rows.push(['Authority Store:', `${health.authorityStore.kind ?? 'unknown'} (${health.authorityStore.ok ? 'ready' : 'unready'})`]) + if (health.authorityStore.path) { + rows.push(['Authority Path:', health.authorityStore.path]) + } if (health.authorityStore.detail) { rows.push(['Authority Detail:', health.authorityStore.detail]) } } + if (health.localStateStore) { + rows.push(['Local State Store:', `${health.localStateStore.kind ?? 'unknown'} (${health.localStateStore.ok ? 'ready' : 'unready'})`]) + if (health.localStateStore.path) { + rows.push(['Local State Path:', health.localStateStore.path]) + } + if (health.localStateStore.detail) { + rows.push(['Local State Detail:', health.localStateStore.detail]) + } + } for (const [name, status] of Object.entries(checks)) { rows.push([`Check ${name}:`, status]) } diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 559eaca..e278afb 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -664,11 +664,17 @@ describe('CLI Commands', () => { trustGraph: 'connected', registry: 'connected', authorityStore: 'ready', + localStateStore: 'ready', }, authorityStore: { kind: 'file', ok: true, }, + localStateStore: { + kind: 'sqlite', + ok: true, + path: '/tmp/fides.sqlite', + }, }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; vi.stubGlobal('fetch', mockFetch); @@ -678,6 +684,9 @@ describe('CLI Commands', () => { await cmd.parseAsync(['status', '--agentd-url', 'http://localhost:7345'], { from: 'user' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:7345/health'); + const output = vi.mocked(console.log).mock.calls.map(call => String(call[0])).join('\n'); + expect(output).toContain('Local State Store:'); + expect(output).toContain('sqlite (ready)'); expect(process.exitCode).toBeUndefined(); }); @@ -690,11 +699,17 @@ describe('CLI Commands', () => { trustGraph: 'connected', registry: 'connected', authorityStore: 'ready', + localStateStore: 'ready', }, authorityStore: { kind: 'postgres', ok: true, }, + localStateStore: { + kind: 'sqlite', + ok: true, + path: '/tmp/fides.sqlite', + }, }), { status: 503, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; vi.stubGlobal('fetch', mockFetch); @@ -705,6 +720,7 @@ describe('CLI Commands', () => { const output = JSON.parse(vi.mocked(console.log).mock.calls[0][0] as string); expect(output.status).toBe('degraded'); + expect(output.localStateStore).toMatchObject({ kind: 'sqlite', ok: true }); expect(process.exitCode).toBe(1); }); }); diff --git a/packages/sdk/src/agentd/client.ts b/packages/sdk/src/agentd/client.ts index 1107390..f42e9fe 100644 --- a/packages/sdk/src/agentd/client.ts +++ b/packages/sdk/src/agentd/client.ts @@ -17,6 +17,23 @@ export interface AgentdClientOptions { apiKey?: string } +export interface AgentdStoreHealth { + kind?: string + ok?: boolean + path?: string + detail?: string +} + +export interface AgentdHealthResponse { + status: 'healthy' | 'degraded' | string + service: string + timestamp?: string + uptime?: number + checks?: Record + authorityStore?: AgentdStoreHealth + localStateStore?: AgentdStoreHealth +} + export interface AuthorizationRequest { agentDid: string capabilityId: string @@ -232,6 +249,10 @@ export class AgentdError extends Error { export class AgentdClient { constructor(private options: AgentdClientOptions) {} + async health(): Promise { + return this.get('/health') + } + async createSession(request: SessionCreateRequest): Promise { return this.post('/v1/sessions', request) } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 58e5b20..07cd799 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -87,6 +87,8 @@ export { AgentdError, type AgentdCardResponse, type AgentdClientOptions, + type AgentdHealthResponse, + type AgentdStoreHealth, type AuthorizationDecision, type AuthorizationRequest, type AuthorityPropagationResponse, diff --git a/packages/sdk/test/agentd.test.ts b/packages/sdk/test/agentd.test.ts index 09c69f8..305961f 100644 --- a/packages/sdk/test/agentd.test.ts +++ b/packages/sdk/test/agentd.test.ts @@ -49,6 +49,43 @@ describe('AgentdClient', () => { client = new AgentdClient({ baseUrl: 'http://localhost:7345/', apiKey: 'sdk-key' }) }) + it('reads agentd health including local state store status', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + status: 'healthy', + service: 'agentd', + checks: { + authorityStore: 'ready', + localStateStore: 'ready', + }, + authorityStore: { + kind: 'memory', + ok: true, + }, + localStateStore: { + kind: 'sqlite', + ok: true, + path: '/tmp/fides.sqlite', + }, + }), + }) + + await expect(client.health()).resolves.toMatchObject({ + status: 'healthy', + localStateStore: { + kind: 'sqlite', + ok: true, + path: '/tmp/fides.sqlite', + }, + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:7345/health', + expect.objectContaining({ method: 'GET' }) + ) + }) + it('creates and reads delegated sessions', async () => { mockFetch .mockResolvedValueOnce({ From 81acb3bd1f96ba467fb9b88cd52d6499ef76c686 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:13:50 +0300 Subject: [PATCH 049/282] feat(discovery): filter incompatible protocol versions --- docs/api-reference.md | 6 ++- docs/protocol/discovery.md | 9 +++++ docs/protocol/version-negotiation.md | 3 ++ services/agentd/src/index.ts | 39 +++++++++++++++++++ services/agentd/test/routes.test.ts | 58 ++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 22d3b33..0598498 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -152,7 +152,11 @@ candidate resolution only, and invocation authority still requires policy evaluation and scoped session grants. Local discovery does not require an endpoint URL; daemon-held AgentCards can resolve by capability with `resolution.urlRequired: false`. Endpoint URLs remain optional transport -metadata, not authority. +metadata, not authority. Local and well-known discovery also negotiate protocol +compatibility between query `supported_versions` / `required_versions` and the +candidate AgentCard `protocolVersions`; incompatible candidates are omitted from +`candidates` and reported under `rejectedCandidates` with +`VERSION_INCOMPATIBLE`. `POST /trust/evaluate` computes a local capability-scoped trust result for a registered candidate. `POST /reputation/update` stores capability-specific diff --git a/docs/protocol/discovery.md b/docs/protocol/discovery.md index 53b7a20..1893178 100644 --- a/docs/protocol/discovery.md +++ b/docs/protocol/discovery.md @@ -25,3 +25,12 @@ Current implementation anchors: 10. Emit evidence. The current implementation supports capability-query providers and candidate explanations. Trust/policy/evidence integration remains an incremental hardening area. + +Root `agentd` local and well-known discovery now apply protocol version +negotiation before returning candidates. A query can send `supported_versions` +and `required_versions`; each matching AgentCard contributes its +`protocolVersions`. Compatible candidates include a `versionNegotiation` record. +Incompatible matches are filtered out of `candidates` and returned in +`rejectedCandidates` with a `VERSION_INCOMPATIBLE` error envelope. This keeps +discovery useful for explainability without treating an incompatible candidate +as invokable. diff --git a/docs/protocol/version-negotiation.md b/docs/protocol/version-negotiation.md index acd49d9..933f7c8 100644 --- a/docs/protocol/version-negotiation.md +++ b/docs/protocol/version-negotiation.md @@ -23,3 +23,6 @@ Current implementation anchors: 4. Reject with `VERSION_INCOMPATIBLE` when no overlap exists. Version compatibility is a discovery filter. It does not grant authority. +In root `agentd` local discovery, incompatible AgentCards are excluded from +`candidates` and surfaced as `rejectedCandidates` so callers can explain why a +capability match was not usable. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 30a4c42..46979f3 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -48,6 +48,7 @@ import { hashProtocolPayload, isKillSwitchRuleActive, MockTEEProvider as CoreMockTEEProvider, + negotiateProtocolVersion, resolveIncidentRecordV2, signAgentCard, signDHTPointerRecord, @@ -77,6 +78,7 @@ import { type SessionGrantV2, type SignedAgentCard, type TrustResult, + type VersionNegotiationRecord, } from '@fides/core' import { createAuthorityStore, createLocalDaemonStateStore, emptyLocalDaemonStateSnapshot } from './storage.js' import type { @@ -946,12 +948,29 @@ app.get('/agents/:id', (c) => { }) }) +function stringArray(value: unknown): string[] | undefined { + return Array.isArray(value) ? value.map(String) : undefined +} + +function discoveryVersionNegotiation( + body: Record, + card: AgentCard +): VersionNegotiationRecord { + return negotiateProtocolVersion({ + localSupported: stringArray(body.supported_versions ?? body.supportedVersions), + localRequired: stringArray(body.required_versions ?? body.requiredVersions), + peerSupported: card.protocolVersions?.length ? card.protocolVersions : ['fides.v2.0'], + peerRequired: stringArray((card as unknown as Record).required_versions), + }) +} + function localDiscoveryResult(body: Record, provider = 'local') { const capability = typeof body.capability === 'string' ? body.capability : undefined if (!capability) { return { error: 'capability is required' as const } } + const rejectedCandidates: Array> = [] const candidates = Array.from(localAgents.values()).flatMap((record) => { const card = localAgentCards.get(record.cardId) if (!card) return [] @@ -959,12 +978,30 @@ function localDiscoveryResult(body: Record, provider = 'local') const descriptor = card.capabilities.find(candidate => candidate.id === capability) if (!descriptor) return [] + const versionNegotiation = discoveryVersionNegotiation(body, card) + if (!versionNegotiation.compatible) { + rejectedCandidates.push({ + agentId: record.agentId, + cardId: record.cardId, + capability, + authorityGranted: false, + versionNegotiation, + reasons: [ + 'candidate_matched_capability', + 'protocol_version_incompatible', + 'discovery_does_not_grant_authority', + ], + }) + return [] + } + return [{ agentId: record.agentId, cardId: record.cardId, capability, signed: localSignedAgentCards.has(record.cardId), authorityGranted: false, + versionNegotiation, resolution: { mode: provider === 'well-known' ? 'local_well_known_agent_card' : 'local_agent_card', provider, @@ -976,6 +1013,7 @@ function localDiscoveryResult(body: Record, provider = 'local') card, reasons: [ 'candidate_matched_capability', + 'protocol_version_compatible', 'discovery_does_not_grant_authority', 'url_not_required_for_local_discovery', ], @@ -986,6 +1024,7 @@ function localDiscoveryResult(body: Record, provider = 'local') query: body, provider, candidates, + rejectedCandidates, count: candidates.length, authorityGranted: false, explanation: 'Discovery returns candidates only. Policy evaluation and scoped session grants are required before invocation.', diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index c072a11..abcfe46 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -414,6 +414,10 @@ describe('Agentd Service Routes', () => { agentId: identity.did, capability: 'invoice.reconcile', signed: true, + versionNegotiation: expect.objectContaining({ + compatible: true, + negotiated_version: 'fides.v2.0', + }), resolution: expect.objectContaining({ mode: 'local_agent_card', urlRequired: false, @@ -422,6 +426,7 @@ describe('Agentd Service Routes', () => { }), ])) expect(discoveredData.candidates[0].reasons).toContain('url_not_required_for_local_discovery') + expect(discoveredData.candidates[0].reasons).toContain('protocol_version_compatible') const localDiscovered = await app.request('/discover/local', { method: 'POST', @@ -448,6 +453,59 @@ describe('Agentd Service Routes', () => { }) }) + it('filters discovery candidates with incompatible protocol versions', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Legacy Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: 'legacy.reconcile', requiredScopes: ['invoice:read'] }], + endpoints: [], + protocolVersions: ['fides.v1'], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const discovered = await app.request('/discover/local', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + capability: 'legacy.reconcile', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }), + }) + + expect(discovered.status).toBe(200) + const data = await discovered.json() + expect(data.count).toBe(0) + expect(data.candidates).toEqual([]) + expect(data.rejectedCandidates).toEqual(expect.arrayContaining([ + expect.objectContaining({ + agentId: identity.did, + versionNegotiation: expect.objectContaining({ + compatible: false, + errors: expect.arrayContaining([ + expect.objectContaining({ code: 'VERSION_INCOMPATIBLE' }), + ]), + }), + reasons: expect.arrayContaining(['protocol_version_incompatible']), + }), + ])) + expect(data.authorityGranted).toBe(false) + }) + it('evaluates root trust, reputation, and policy for a registered local candidate', async () => { const identityResponse = await app.request('/identities', { method: 'POST', From 4a0ca45456cbf5023550c40695f995ff6f1b2244 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:16:29 +0300 Subject: [PATCH 050/282] feat(discovery): negotiate provider record versions --- docs/api-reference.md | 11 +-- docs/protocol/discovery.md | 17 +++-- docs/protocol/version-negotiation.md | 7 +- services/agentd/src/index.ts | 105 ++++++++++++++++++++++++--- services/agentd/test/routes.test.ts | 96 ++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 25 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 0598498..482a9b2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -152,11 +152,12 @@ candidate resolution only, and invocation authority still requires policy evaluation and scoped session grants. Local discovery does not require an endpoint URL; daemon-held AgentCards can resolve by capability with `resolution.urlRequired: false`. Endpoint URLs remain optional transport -metadata, not authority. Local and well-known discovery also negotiate protocol -compatibility between query `supported_versions` / `required_versions` and the -candidate AgentCard `protocolVersions`; incompatible candidates are omitted from -`candidates` and reported under `rejectedCandidates` with -`VERSION_INCOMPATIBLE`. +metadata, not authority. Local, well-known, registry, relay, and locally +resolvable DHT discovery also negotiate protocol compatibility between query +`supported_versions` / `required_versions` and the candidate AgentCard +`protocolVersions`; incompatible candidates are omitted from provider results +and reported under `rejectedCandidates`, `rejectedRecords`, or +`rejectedPointers` with `VERSION_INCOMPATIBLE`. `POST /trust/evaluate` computes a local capability-scoped trust result for a registered candidate. `POST /reputation/update` stores capability-specific diff --git a/docs/protocol/discovery.md b/docs/protocol/discovery.md index 1893178..a587d24 100644 --- a/docs/protocol/discovery.md +++ b/docs/protocol/discovery.md @@ -26,11 +26,12 @@ Current implementation anchors: The current implementation supports capability-query providers and candidate explanations. Trust/policy/evidence integration remains an incremental hardening area. -Root `agentd` local and well-known discovery now apply protocol version -negotiation before returning candidates. A query can send `supported_versions` -and `required_versions`; each matching AgentCard contributes its -`protocolVersions`. Compatible candidates include a `versionNegotiation` record. -Incompatible matches are filtered out of `candidates` and returned in -`rejectedCandidates` with a `VERSION_INCOMPATIBLE` error envelope. This keeps -discovery useful for explainability without treating an incompatible candidate -as invokable. +Root `agentd` local, well-known, registry, relay, and locally resolvable DHT +discovery now apply protocol version negotiation before returning provider +results. A query can send `supported_versions` and `required_versions`; each +matching local AgentCard contributes its `protocolVersions`. Compatible +candidates, records, and pointers include a `versionNegotiation` record. +Incompatible matches are filtered out of the active result set and returned in +`rejectedCandidates`, `rejectedRecords`, or `rejectedPointers` with a +`VERSION_INCOMPATIBLE` error envelope. This keeps discovery useful for +explainability without treating an incompatible candidate as invokable. diff --git a/docs/protocol/version-negotiation.md b/docs/protocol/version-negotiation.md index 933f7c8..ad0cde7 100644 --- a/docs/protocol/version-negotiation.md +++ b/docs/protocol/version-negotiation.md @@ -23,6 +23,7 @@ Current implementation anchors: 4. Reject with `VERSION_INCOMPATIBLE` when no overlap exists. Version compatibility is a discovery filter. It does not grant authority. -In root `agentd` local discovery, incompatible AgentCards are excluded from -`candidates` and surfaced as `rejectedCandidates` so callers can explain why a -capability match was not usable. +In root `agentd` discovery, incompatible local AgentCards are excluded from +active local, well-known, registry, relay, and locally resolvable DHT results. +They are surfaced as `rejectedCandidates`, `rejectedRecords`, or +`rejectedPointers` so callers can explain why a capability match was not usable. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 46979f3..89c1df3 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -964,6 +964,69 @@ function discoveryVersionNegotiation( }) } +function localCardForProviderRecord(record: Record): AgentCard | undefined { + const cardId = typeof record.cardId === 'string' + ? record.cardId + : typeof record.card_id === 'string' + ? record.card_id + : undefined + if (cardId) { + const card = localAgentCards.get(cardId) + if (card) return card + } + + const agentId = typeof record.agentId === 'string' + ? record.agentId + : typeof record.agent_id === 'string' + ? record.agent_id + : undefined + const registered = agentId ? localAgents.get(agentId) : undefined + return registered ? localAgentCards.get(registered.cardId) : undefined +} + +function filterVersionCompatibleProviderRecords( + body: Record, + records: Array>, + rejectedKey = 'rejectedRecords' +): { records: Array>; rejected: Array>; rejectedKey: string } { + const rejected: Array> = [] + const compatible: Array> = [] + for (const record of records) { + const card = localCardForProviderRecord(record) + if (!card) { + compatible.push({ ...record, protocolCompatibility: 'not_checked_card_unresolved' }) + continue + } + + const versionNegotiation = discoveryVersionNegotiation(body, card) + if (!versionNegotiation.compatible) { + rejected.push({ + ...record, + authorityGranted: false, + versionNegotiation, + reasons: [ + 'provider_record_matched_capability', + 'protocol_version_incompatible', + 'discovery_does_not_grant_authority', + ], + }) + continue + } + + compatible.push({ + ...record, + versionNegotiation, + reasons: [ + 'provider_record_matched_capability', + 'protocol_version_compatible', + 'discovery_does_not_grant_authority', + ], + }) + } + + return { records: compatible, rejected, rejectedKey } +} + function localDiscoveryResult(body: Record, provider = 'local') { const capability = typeof body.capability === 'string' ? body.capability : undefined if (!capability) { @@ -1901,9 +1964,17 @@ app.post('/dht/find', async (c) => { app.post('/discover/dht', async (c) => { const body = await c.req.json().catch(() => ({})) const capability = typeof body.capability === 'string' ? body.capability : undefined + const found = findLocalDhtPointers(capability) + const filtered = filterVersionCompatibleProviderRecords( + body, + found.pointers as Array>, + 'rejectedPointers' + ) return c.json({ provider: 'dht', - ...findLocalDhtPointers(capability), + capability: found.capability, + pointers: filtered.records, + [filtered.rejectedKey]: filtered.rejected, authorityGranted: false, explanation: 'DHT discovery returns signed pointer candidates only; trust, policy, and session grants are evaluated separately.', }) @@ -1949,22 +2020,30 @@ app.post('/registry/publish', async (c) => { app.post('/registry/search', async (c) => { const body = await c.req.json().catch(() => ({})) const capability = typeof body.capability === 'string' ? body.capability : undefined - const records = Array.from(localRegistryRecords.values()).filter((record) => ( + const matched = Array.from(localRegistryRecords.values()).filter((record) => ( !capability || (record.capabilities as string[] | undefined)?.includes(capability) )) - return c.json({ capability: capability ?? null, records, authorityGranted: false }) + const filtered = filterVersionCompatibleProviderRecords(body, matched) + return c.json({ + capability: capability ?? null, + records: filtered.records, + [filtered.rejectedKey]: filtered.rejected, + authorityGranted: false, + }) }) app.post('/discover/registry', async (c) => { const body = await c.req.json().catch(() => ({})) const capability = typeof body.capability === 'string' ? body.capability : undefined - const records = Array.from(localRegistryRecords.values()).filter((record) => ( + const matched = Array.from(localRegistryRecords.values()).filter((record) => ( !capability || (record.capabilities as string[] | undefined)?.includes(capability) )) + const filtered = filterVersionCompatibleProviderRecords(body, matched) return c.json({ provider: 'registry', capability: capability ?? null, - records, + records: filtered.records, + [filtered.rejectedKey]: filtered.rejected, authorityGranted: false, explanation: 'Registry discovery returns registry records only; registration does not grant invocation authority.', }) @@ -2029,22 +2108,30 @@ app.post('/relay/register', async (c) => { app.post('/relay/discover', async (c) => { const body = await c.req.json().catch(() => ({})) const capability = typeof body.capability === 'string' ? body.capability : undefined - const records = Array.from(localRelayRecords.values()).filter((record) => ( + const matched = Array.from(localRelayRecords.values()).filter((record) => ( !capability || (record.capabilities as string[] | undefined)?.includes(capability) )) - return c.json({ capability: capability ?? null, records, authorityGranted: false }) + const filtered = filterVersionCompatibleProviderRecords(body, matched) + return c.json({ + capability: capability ?? null, + records: filtered.records, + [filtered.rejectedKey]: filtered.rejected, + authorityGranted: false, + }) }) app.post('/discover/relay', async (c) => { const body = await c.req.json().catch(() => ({})) const capability = typeof body.capability === 'string' ? body.capability : undefined - const records = Array.from(localRelayRecords.values()).filter((record) => ( + const matched = Array.from(localRelayRecords.values()).filter((record) => ( !capability || (record.capabilities as string[] | undefined)?.includes(capability) )) + const filtered = filterVersionCompatibleProviderRecords(body, matched) return c.json({ provider: 'relay', capability: capability ?? null, - records, + records: filtered.records, + [filtered.rejectedKey]: filtered.rejected, authorityGranted: false, explanation: 'Relay discovery returns presence records only; relay presence is not authority.', }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index abcfe46..b30975b 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -506,6 +506,102 @@ describe('Agentd Service Routes', () => { expect(data.authorityGranted).toBe(false) }) + it('filters registry relay and dht discovery records with incompatible protocol versions', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Legacy Provider Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: 'legacy.provider', requiredScopes: ['legacy:read'] }], + endpoints: [], + protocolVersions: ['fides.v1'], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + await app.request('/registry/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + await app.request('/relay/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentId: identity.did }), + }) + await app.request('/dht/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + capability: 'legacy.provider', + agentId: identity.did, + agentCardUrl: 'local://legacy-provider-card', + }), + }) + + for (const path of ['/registry/search', '/discover/registry', '/relay/discover', '/discover/relay']) { + const response = await app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + capability: 'legacy.provider', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }), + }) + expect(response.status).toBe(200) + const data = await response.json() + expect(data.records).toEqual([]) + expect(data.rejectedRecords).toEqual(expect.arrayContaining([ + expect.objectContaining({ + agentId: identity.did, + versionNegotiation: expect.objectContaining({ + compatible: false, + errors: expect.arrayContaining([ + expect.objectContaining({ code: 'VERSION_INCOMPATIBLE' }), + ]), + }), + }), + ])) + expect(data.authorityGranted).toBe(false) + } + + const dht = await app.request('/discover/dht', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + capability: 'legacy.provider', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }), + }) + expect(dht.status).toBe(200) + const dhtData = await dht.json() + expect(dhtData.pointers).toEqual([]) + expect(dhtData.rejectedPointers).toEqual(expect.arrayContaining([ + expect.objectContaining({ + agentId: identity.did, + versionNegotiation: expect.objectContaining({ + compatible: false, + errors: expect.arrayContaining([ + expect.objectContaining({ code: 'VERSION_INCOMPATIBLE' }), + ]), + }), + }), + ])) + expect(dhtData.authorityGranted).toBe(false) + }) + it('evaluates root trust, reputation, and policy for a registered local candidate', async () => { const identityResponse = await app.request('/identities', { method: 'POST', From c1b9e7d1396d3fdff1ab8cd96978b6dd60e21047 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:21:54 +0300 Subject: [PATCH 051/282] feat(dht): sign and verify local pointer records --- docs/api-reference.md | 11 ++- docs/protocol/dht-discovery.md | 11 +++ services/agentd/src/index.ts | 134 +++++++++++++++++++++++++--- services/agentd/test/routes.test.ts | 134 ++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 13 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 482a9b2..821b760 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -147,13 +147,20 @@ and the associated AgentCard. `POST /discover` and `POST /discover/local` search registered local agents by capability. `POST /discover/well-known`, `POST /discover/registry`, `POST /discover/relay`, and `POST /discover/dht` expose provider-specific discovery aliases over the daemon's local state. +`POST /dht/publish` creates a signed DHT pointer when the referenced agent is +registered locally; callers may omit `agentCardUrl`, in which case the daemon +uses a `local://agent-cards/` pointer and signs it with the local +identity. Unresolved external DHT publishes remain local mock pointers and are +returned as unverified records. Discovery responses always include `authorityGranted: false`; discovery is candidate resolution only, and invocation authority still requires policy evaluation and scoped session grants. Local discovery does not require an endpoint URL; daemon-held AgentCards can resolve by capability with `resolution.urlRequired: false`. Endpoint URLs remain optional transport -metadata, not authority. Local, well-known, registry, relay, and locally -resolvable DHT discovery also negotiate protocol compatibility between query +metadata, not authority. DHT discovery also does not require an HTTP URL when a +local AgentCard can be resolved, but DHT remains a pointer layer rather than a +trust source. Local, well-known, registry, relay, and locally resolvable DHT +discovery also negotiate protocol compatibility between query `supported_versions` / `required_versions` and the candidate AgentCard `protocolVersions`; incompatible candidates are omitted from provider results and reported under `rejectedCandidates`, `rejectedRecords`, or diff --git a/docs/protocol/dht-discovery.md b/docs/protocol/dht-discovery.md index cda37b0..eff9325 100644 --- a/docs/protocol/dht-discovery.md +++ b/docs/protocol/dht-discovery.md @@ -6,11 +6,18 @@ Current implementation anchors: - `packages/core/src/dht.ts` - `packages/discovery/src/dht-provider.ts` +- `services/agentd/src/index.ts` ## Pointer Record DHT records point from capability hash to AgentCard location and hash. They include agent ID, publisher ID, expiry, sequence, and signature. +The local daemon can publish a signed DHT pointer from an already registered +local AgentCard without the caller supplying a URL. In that case it uses a +`local://agent-cards/` reference, hashes the daemon-held AgentCard, and +signs the pointer with the local agent identity. External or unresolved pointer +publishes are accepted only as local mock records and are marked unverified. + ## Flow 1. Hash the requested capability. @@ -20,4 +27,8 @@ DHT records point from capability hash to AgentCard location and hash. They incl 5. Verify AgentCard hash and signature. 6. Continue to trust and policy. +`/dht/find` and `/discover/dht` verify signed local pointer records before +returning them. Expired, tampered, or AgentCard-hash-mismatched pointers are +reported as rejected pointers and do not become authority. + The in-memory DHT simulator is local mock infrastructure. A libp2p/Kademlia adapter should implement the same provider contract later. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 89c1df3..eb76fc0 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -65,6 +65,7 @@ import { type ApprovalDecision, type ApprovalRequest, type DelegationConstraint, + type DHTPointerRecord, type IncidentRecord, type IncidentRecordV2, type KillSwitchRule, @@ -1027,6 +1028,22 @@ function filterVersionCompatibleProviderRecords( return { records: compatible, rejected, rejectedKey } } +function dhtPointerRecordOnly(pointer: Record): DHTPointerRecord { + return { + schema_version: 'fides.dht.pointer.v1', + record_type: 'capability_pointer', + capability: String(pointer.capability), + capability_hash: String(pointer.capability_hash), + agent_id: String(pointer.agent_id), + agent_card_url: String(pointer.agent_card_url), + agent_card_hash: String(pointer.agent_card_hash), + publisher_id: String(pointer.publisher_id), + expires_at: String(pointer.expires_at), + sequence: typeof pointer.sequence === 'number' ? pointer.sequence : Number(pointer.sequence ?? 1), + signature: String(pointer.signature ?? ''), + } +} + function localDiscoveryResult(body: Record, provider = 'local') { const capability = typeof body.capability === 'string' ? body.capability : undefined if (!capability) { @@ -1932,11 +1949,76 @@ app.post('/dht/publish', async (c) => { return c.json({ error: 'capability is required' }, 400) } + const capability = String(body.capability) + const cardId = typeof body.agentCardId === 'string' + ? body.agentCardId + : typeof body.cardId === 'string' + ? body.cardId + : undefined + const agentId = typeof body.agentId === 'string' + ? body.agentId + : typeof body.agent_id === 'string' + ? body.agent_id + : undefined + const registered = cardId + ? Array.from(localAgents.values()).find(record => record.cardId === cardId) + : agentId + ? localAgents.get(agentId) + : undefined + const card = registered ? localAgentCards.get(registered.cardId) : undefined + const identity = card ? localIdentities.get(card.identity.did) : undefined + + if (card && identity) { + if (!card.capabilities.some(candidate => candidate.id === capability)) { + return c.json({ error: 'AgentCard does not advertise capability', capability, cardId: card.id }, 400) + } + const pointer = await signDHTPointerRecord(createDHTPointerRecord({ + capability, + agentId: card.identity.did, + agentCardUrl: typeof body.agentCardUrl === 'string' + ? body.agentCardUrl + : typeof body.agent_card_url === 'string' + ? body.agent_card_url + : `local://agent-cards/${encodeURIComponent(card.id)}`, + agentCardHash: hashAgentCard(card), + publisherId: typeof body.publisherId === 'string' + ? body.publisherId + : typeof body.publisher_id === 'string' + ? body.publisher_id + : card.publisher?.did ?? card.identity.did, + expiresAt: typeof body.expiresAt === 'string' + ? body.expiresAt + : typeof body.expires_at === 'string' + ? body.expires_at + : new Date(Date.now() + 60 * 60 * 1000).toISOString(), + sequence: typeof body.sequence === 'number' ? body.sequence : undefined, + }), Buffer.from(identity.privateKeyHex, 'hex'), card.identity.did) + const storedPointer = { + ...pointer, + id: body.id ?? crypto.randomUUID(), + agentId: pointer.agent_id, + agentCardUrl: pointer.agent_card_url, + agentCardHash: pointer.agent_card_hash, + publisherId: pointer.publisher_id, + cardId: card.id, + signed: true, + publishedAt: new Date().toISOString(), + source: 'agentd-signed-dht-pointer', + } + localDhtPointers.push(storedPointer) + return c.json({ accepted: true, pointer: storedPointer }, 201) + } + const pointer = { id: body.id ?? crypto.randomUUID(), - capability: body.capability, - agentId: body.agentId ?? body.agent_id, + capability, + agentId: agentId, agentCardUrl: body.agentCardUrl ?? body.agent_card_url ?? body.agentCard, + signed: false, + verification: { + valid: false, + errors: ['DHT pointer signature is required'], + }, publishedAt: new Date().toISOString(), source: 'agentd-in-memory-dht', } @@ -1944,27 +2026,54 @@ app.post('/dht/publish', async (c) => { return c.json({ accepted: true, pointer }, 201) }) -function findLocalDhtPointers(capability?: string) { - const pointers = capability +async function findLocalDhtPointers(capability?: string) { + const matched = capability ? localDhtPointers.filter(pointer => pointer.capability === capability) : localDhtPointers - return { capability: capability ?? null, pointers } + const rejectedPointers: Array> = [] + const pointers: Array> = [] + for (const pointer of matched) { + if (pointer.schema_version === 'fides.dht.pointer.v1') { + const pointerRecord = dhtPointerRecordOnly(pointer) + const card = localCardForProviderRecord(pointer) + const verification = await verifyDHTPointerRecord(pointerRecord, { + ...(card && { card }), + verificationMethod: typeof pointer.agent_id === 'string' ? pointer.agent_id : undefined, + }) + const enriched = { ...pointer, verification } + if (!verification.valid) { + rejectedPointers.push({ + ...enriched, + authorityGranted: false, + reasons: [ + 'dht_pointer_verification_failed', + 'discovery_does_not_grant_authority', + ], + }) + continue + } + pointers.push(enriched) + continue + } + pointers.push(pointer) + } + return { capability: capability ?? null, pointers, rejectedPointers } } -app.get('/dht/find', (c) => { - return c.json(findLocalDhtPointers(c.req.query('capability'))) +app.get('/dht/find', async (c) => { + return c.json(await findLocalDhtPointers(c.req.query('capability'))) }) app.post('/dht/find', async (c) => { const body = await c.req.json().catch(() => ({})) const capability = typeof body.capability === 'string' ? body.capability : undefined - return c.json(findLocalDhtPointers(capability)) + return c.json(await findLocalDhtPointers(capability)) }) app.post('/discover/dht', async (c) => { const body = await c.req.json().catch(() => ({})) const capability = typeof body.capability === 'string' ? body.capability : undefined - const found = findLocalDhtPointers(capability) + const found = await findLocalDhtPointers(capability) const filtered = filterVersionCompatibleProviderRecords( body, found.pointers as Array>, @@ -1974,7 +2083,10 @@ app.post('/discover/dht', async (c) => { provider: 'dht', capability: found.capability, pointers: filtered.records, - [filtered.rejectedKey]: filtered.rejected, + rejectedPointers: [ + ...found.rejectedPointers, + ...filtered.rejected, + ], authorityGranted: false, explanation: 'DHT discovery returns signed pointer candidates only; trust, policy, and session grants are evaluated separately.', }) @@ -2362,7 +2474,7 @@ async function runLocalFullDemo() { const invoiceRegistryRecords = Array.from(localRegistryRecords.values()).filter((record) => ( (record.capabilities as string[] | undefined)?.includes(invoiceCapability.id) )) - const paymentDhtPointers = findLocalDhtPointers(paymentCapability.id) + const paymentDhtPointers = await findLocalDhtPointers(paymentCapability.id) const verifiedCards = await Promise.all([calendar.signed, invoice.signed, payment.signed].map(verifySignedAgentCard)) const invoiceTrust = computeLocalTrustResult(invoice.card.identity.did, invoiceCapability.id) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index b30975b..25b0cbe 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1131,6 +1131,140 @@ describe('Agentd Service Routes', () => { }) }) + it('publishes signed local DHT pointer records without requiring a caller URL', async () => { + const capability = `signed.dht.${crypto.randomUUID()}` + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Signed DHT Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: capability, requiredScopes: ['signed:dht'] }], + endpoints: [], + protocolVersions: ['fides.v2.0'], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const publish = await app.request('/dht/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + capability, + agentId: identity.did, + }), + }) + expect(publish.status).toBe(201) + const published = await publish.json() + expect(published.pointer).toMatchObject({ + schema_version: 'fides.dht.pointer.v1', + record_type: 'capability_pointer', + agent_id: identity.did, + agentId: identity.did, + agentCardUrl: `local://agent-cards/${encodeURIComponent(identity.did)}`, + signed: true, + }) + expect(published.pointer.agent_card_hash).toMatch(/^sha256:/) + expect(published.pointer.signature).toEqual(expect.any(String)) + + const find = await app.request(`/dht/find?capability=${encodeURIComponent(capability)}`) + expect(find.status).toBe(200) + const found = await find.json() + expect(found.pointers).toEqual(expect.arrayContaining([ + expect.objectContaining({ + agent_id: identity.did, + verification: expect.objectContaining({ valid: true }), + }), + ])) + + const discoverDht = await app.request('/discover/dht', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + capability, + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }), + }) + expect(discoverDht.status).toBe(200) + const discovery = await discoverDht.json() + expect(discovery.pointers).toEqual(expect.arrayContaining([ + expect.objectContaining({ + agent_id: identity.did, + verification: expect.objectContaining({ valid: true }), + versionNegotiation: expect.objectContaining({ compatible: true }), + }), + ])) + expect(discovery.rejectedPointers).toEqual([]) + expect(discovery.authorityGranted).toBe(false) + }) + + it('rejects expired signed local DHT pointer records during discovery', async () => { + const capability = `expired.dht.${crypto.randomUUID()}` + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Expired DHT Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: capability, requiredScopes: ['expired:dht'] }], + endpoints: [], + protocolVersions: ['fides.v2.0'], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const publish = await app.request('/dht/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + capability, + agentId: identity.did, + expiresAt: '2026-01-01T00:00:00.000Z', + }), + }) + expect(publish.status).toBe(201) + + const discoverDht = await app.request('/discover/dht', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability }), + }) + expect(discoverDht.status).toBe(200) + const discovery = await discoverDht.json() + expect(discovery.pointers).toEqual([]) + expect(discovery.rejectedPointers).toEqual(expect.arrayContaining([ + expect.objectContaining({ + agent_id: identity.did, + verification: expect.objectContaining({ + valid: false, + errors: expect.arrayContaining(['DHT pointer is expired']), + }), + }), + ])) + expect(discovery.authorityGranted).toBe(false) + }) + it('serves local registry, relay, and well-known discovery aliases without authority', async () => { const identityResponse = await app.request('/identities', { method: 'POST', From 219104118fe89940af161c4a793fe21468405b18 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:24:02 +0300 Subject: [PATCH 052/282] feat(relay): expose signed agent card references --- docs/api-reference.md | 9 +++-- docs/protocol/relay-discovery.md | 12 ++++++ services/agentd/src/index.ts | 57 ++++++++++++++++------------- services/agentd/test/routes.test.ts | 29 +++++++++++++-- 4 files changed, 74 insertions(+), 33 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 821b760..b7732be 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -103,9 +103,12 @@ production hardening. `POST /registry/start`, `POST /registry/publish`, `POST /registry/search`, and `GET /registry/index` provide a local mock registry over registered AgentCards. `POST /relay/start`, `POST /relay/register`, and `POST /relay/discover` provide -local mock relay presence and rendezvous. Both surfaces return candidates or -presence records only; they set `authorityGranted: false` and do not replace -policy evaluation or scoped session grants. `GET /.well-known/fides.json`, +local mock relay presence and rendezvous. Relay records for locally signed +AgentCards include `agentCardUrl`, `agentCardHash`, `signedAgentCard`, and +`agentCardProof` so callers can resolve and verify the card after rendezvous. +Both surfaces return candidates or presence records only; they set +`authorityGranted: false` and do not replace policy evaluation or scoped session +grants. `GET /.well-known/fides.json`, `GET /.well-known/agents.json`, and `GET /.well-known/agents/:id.json` expose local well-known metadata for same-host discovery. diff --git a/docs/protocol/relay-discovery.md b/docs/protocol/relay-discovery.md index 0619fd3..5bc0119 100644 --- a/docs/protocol/relay-discovery.md +++ b/docs/protocol/relay-discovery.md @@ -6,6 +6,7 @@ Current implementation anchors: - `packages/discovery/src/relay-provider.ts` - `services/relay/src/index.ts` +- `services/agentd/src/index.ts` - `packages/sdk/src/relay/client.ts` ## Relay Provides @@ -15,6 +16,17 @@ Current implementation anchors: - endpoint hints - signed AgentCard references +The local daemon relay alias builds relay records from registered local +AgentCards. When the card has been signed, the relay record includes: + +- `agentCardUrl`, using a local `local://agent-cards/` reference +- `agentCardHash`, the canonical AgentCard hash +- `signedAgentCard`, indicating whether the daemon has a signed AgentCard +- `agentCardProof`, the canonical AgentCard proof metadata + +These fields let a caller resolve and verify the AgentCard after rendezvous. +They do not make the relay a trust anchor. + ## Relay Must Not Provide - trust scores diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index eb76fc0..6e65f22 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -2111,6 +2111,30 @@ function localRegistryRecordFor(cardId: string, mode: 'public' | 'private' = 'pu } } +function localRelayRecordFor(agentId: string, endpointHints: unknown[] = []) { + const registered = localAgents.get(agentId) + const card = registered ? localAgentCards.get(registered.cardId) : undefined + if (!registered || !card) { + return null + } + const signedCard = localSignedAgentCards.get(card.id) + return { + id: `relay_${agentId}`, + agentId, + cardId: registered.cardId, + capabilities: card.capabilities.map(capability => capability.id), + endpointHints, + online: true, + agentCardUrl: `local://agent-cards/${encodeURIComponent(card.id)}`, + agentCardHash: hashAgentCard(card), + signedAgentCard: Boolean(signedCard), + agentCardProof: signedCard?.proof ?? null, + registeredAt: new Date().toISOString(), + authorityGranted: false, + source: 'agentd-local-relay', + } +} + app.post('/registry/publish', async (c) => { const body = await c.req.json().catch(() => ({})) const cardId = typeof body.agentCardId === 'string' @@ -2197,21 +2221,12 @@ app.post('/relay/register', async (c) => { if (!agentId) { return c.json({ error: 'agentId is required' }, 400) } - const registered = localAgents.get(agentId) - const card = registered ? localAgentCards.get(registered.cardId) : undefined - if (!registered || !card) { - return c.json({ error: 'registered local agent not found', agentId }, 404) - } - const record = { - id: `relay_${agentId}`, + const record = localRelayRecordFor( agentId, - cardId: registered.cardId, - capabilities: card.capabilities.map(capability => capability.id), - endpointHints: Array.isArray(body.endpointHints) ? body.endpointHints : [], - online: true, - registeredAt: new Date().toISOString(), - authorityGranted: false, - source: 'agentd-local-relay', + Array.isArray(body.endpointHints) ? body.endpointHints : [] + ) + if (!record) { + return c.json({ error: 'registered local agent not found', agentId }, 404) } localRelayRecords.set(agentId, record) return c.json({ accepted: true, record }, 201) @@ -2448,18 +2463,8 @@ async function runLocalFullDemo() { const registryRecord = localRegistryRecordFor(invoice.card.id) if (registryRecord) localRegistryRecords.set(String(registryRecord.id), registryRecord) - const relayRecord = { - id: `relay_${calendar.card.identity.did}`, - agentId: calendar.card.identity.did, - cardId: calendar.card.id, - capabilities: calendar.card.capabilities.map(capability => capability.id), - endpointHints: ['local://calendar-agent'], - online: true, - registeredAt: new Date().toISOString(), - authorityGranted: false, - source: 'agentd-local-relay', - } - localRelayRecords.set(calendar.card.identity.did, relayRecord) + const relayRecord = localRelayRecordFor(calendar.card.identity.did, ['local://calendar-agent']) + if (relayRecord) localRelayRecords.set(calendar.card.identity.did, relayRecord) const dhtPointer = { id: crypto.randomUUID(), capability: paymentCapability.id, diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 25b0cbe..b6c99a1 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1338,10 +1338,22 @@ describe('Agentd Service Routes', () => { const relayRegister = await app.request('/relay/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agentId: identity.did }), + body: JSON.stringify({ agentId: identity.did, endpointHints: ['local://calendar-agent'] }), }) expect(relayRegister.status).toBe(201) - expect((await relayRegister.json()).record.authorityGranted).toBe(false) + const relayRegistration = await relayRegister.json() + expect(relayRegistration.record).toMatchObject({ + agentId: identity.did, + agentCardUrl: `local://agent-cards/${encodeURIComponent(identity.did)}`, + signedAgentCard: true, + endpointHints: ['local://calendar-agent'], + authorityGranted: false, + }) + expect(relayRegistration.record.agentCardHash).toMatch(/^sha256:/) + expect(relayRegistration.record.agentCardProof).toMatchObject({ + type: 'Ed25519Signature2024', + verificationMethod: identity.did, + }) const relayDiscover = await app.request('/relay/discover', { method: 'POST', @@ -1350,7 +1362,11 @@ describe('Agentd Service Routes', () => { }) expect(relayDiscover.status).toBe(200) expect((await relayDiscover.json()).records).toEqual(expect.arrayContaining([ - expect.objectContaining({ agentId: identity.did }), + expect.objectContaining({ + agentId: identity.did, + agentCardHash: expect.stringMatching(/^sha256:/), + signedAgentCard: true, + }), ])) const discoverRelay = await app.request('/discover/relay', { @@ -1363,7 +1379,12 @@ describe('Agentd Service Routes', () => { provider: 'relay', authorityGranted: false, records: expect.arrayContaining([ - expect.objectContaining({ agentId: identity.did }), + expect.objectContaining({ + agentId: identity.did, + agentCardHash: expect.stringMatching(/^sha256:/), + signedAgentCard: true, + versionNegotiation: expect.objectContaining({ compatible: true }), + }), ]), }) From 4db5ecefaa992488afa124ac86c99f2c62c70622 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:26:14 +0300 Subject: [PATCH 053/282] feat(registry): sign local index records --- docs/api-reference.md | 4 ++ docs/protocol/registry-federation.md | 14 +++++ services/agentd/src/index.ts | 81 +++++++++++++++++++++++++--- services/agentd/test/routes.test.ts | 39 ++++++++++++-- 4 files changed, 127 insertions(+), 11 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index b7732be..194f732 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -102,6 +102,10 @@ production hardening. `POST /registry/start`, `POST /registry/publish`, `POST /registry/search`, and `GET /registry/index` provide a local mock registry over registered AgentCards. +Registry records for locally signed AgentCards include `agentCardUrl`, +`agentCardHash`, `registryIndexRecord`, `registryIndexProof`, and +`registryIndexVerified`; search and discovery verify signed local registry index +records before returning them. `POST /relay/start`, `POST /relay/register`, and `POST /relay/discover` provide local mock relay presence and rendezvous. Relay records for locally signed AgentCards include `agentCardUrl`, `agentCardHash`, `signedAgentCard`, and diff --git a/docs/protocol/registry-federation.md b/docs/protocol/registry-federation.md index c90dafe..2c96c28 100644 --- a/docs/protocol/registry-federation.md +++ b/docs/protocol/registry-federation.md @@ -6,6 +6,7 @@ Current implementation anchors: - `packages/core/src/registry.ts` - `services/registry/src/index.ts` +- `services/agentd/src/index.ts` ## Registry Modes @@ -18,4 +19,17 @@ Current implementation anchors: - `RegistryIndexRecord`: signed AgentCard index entry. - `RegistryPeerRecord`: signed registry peering metadata. +The local daemon registry alias now emits core-compatible signed +`RegistryIndexRecord` metadata for registered local AgentCards. A published +record includes: + +- `agentCardUrl`, using `local://agent-cards/` +- `agentCardHash`, matching the canonical AgentCard hash +- `registryIndexRecord`, the canonical index payload +- `registryIndexProof`, the canonical signature proof +- `registryIndexVerified`, the daemon's local verification result + +Search and discovery verify signed local registry index records before returning +them. A valid registry index record still does not grant invocation authority. + Federation peering records are adapter-ready and should not imply trust. Peers provide discovery and propagation surfaces; FIDES still verifies identity, signatures, revocations, incidents, trust, and policy. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 6e65f22..ab065db 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -40,6 +40,7 @@ import { createKillSwitchRule, createDelegationToken, createDHTPointerRecord, + createRegistryIndexRecord, createPrincipalIdentity, createPublisherIdentity, createRevocationRecordV2, @@ -52,8 +53,10 @@ import { resolveIncidentRecordV2, signAgentCard, signDHTPointerRecord, + signRegistryIndexRecord, validateAgentCard, verifyDHTPointerRecord, + verifySignedRegistryIndexRecord, verifySignedAgentCard, verifyDelegationTokenSignature, verifyDomainDid, @@ -77,6 +80,7 @@ import { type RevocationRecordV2, type RuntimeAttestation, type SessionGrantV2, + type SignedRegistryIndexRecord, type SignedAgentCard, type TrustResult, type VersionNegotiationRecord, @@ -2092,12 +2096,26 @@ app.post('/discover/dht', async (c) => { }) }) -function localRegistryRecordFor(cardId: string, mode: 'public' | 'private' = 'public') { +async function localRegistryRecordFor(cardId: string, mode: 'public' | 'private' = 'public') { const card = localAgentCards.get(cardId) const registered = card ? localAgents.get(card.identity.did) : undefined + const identity = card ? localIdentities.get(card.identity.did) : undefined if (!card || !registered) { return null } + const registryIndexRecord = createRegistryIndexRecord({ + issuer: card.identity.did, + mode, + agentCardId: card.id, + agentId: card.identity.did, + capabilityIds: card.capabilities.map(capability => capability.id), + agentCardHash: hashAgentCard(card), + registryUrl: 'local://registry', + supportedVersions: card.protocolVersions?.length ? card.protocolVersions : ['fides.v2.0'], + }) + const signedRegistryIndexRecord = identity + ? await signRegistryIndexRecord(registryIndexRecord, Buffer.from(identity.privateKeyHex, 'hex'), card.identity.did) + : null return { id: `reg_${card.id}`, agentId: card.identity.did, @@ -2105,12 +2123,53 @@ function localRegistryRecordFor(cardId: string, mode: 'public' | 'private' = 'pu mode, capabilities: card.capabilities.map(capability => capability.id), signed: localSignedAgentCards.has(card.id), + agentCardUrl: `local://agent-cards/${encodeURIComponent(card.id)}`, + agentCardHash: registryIndexRecord.agent_card_hash, + registryIndexRecord, + signedRegistryIndexRecord, + registryIndexProof: signedRegistryIndexRecord?.proof ?? null, + registryIndexVerified: signedRegistryIndexRecord ? await verifySignedRegistryIndexRecord(signedRegistryIndexRecord) : false, publishedAt: new Date().toISOString(), authorityGranted: false, source: 'agentd-local-registry', } } +function signedRegistryIndexFor(record: Record): SignedRegistryIndexRecord | undefined { + const signed = record.signedRegistryIndexRecord + if (!signed || typeof signed !== 'object') return undefined + const candidate = signed as Partial + if (!candidate.payload || !candidate.proof) return undefined + return candidate as SignedRegistryIndexRecord +} + +async function filterVerifiedLocalRegistryRecords(records: Array>) { + const verifiedRecords: Array> = [] + const rejectedRecords: Array> = [] + for (const record of records) { + const signed = signedRegistryIndexFor(record) + if (!signed) { + verifiedRecords.push({ ...record, registryIndexVerification: 'not_checked_unsigned_record' }) + continue + } + const valid = await verifySignedRegistryIndexRecord(signed) + const enriched = { ...record, registryIndexVerified: valid } + if (!valid) { + rejectedRecords.push({ + ...enriched, + authorityGranted: false, + reasons: [ + 'registry_index_signature_invalid', + 'discovery_does_not_grant_authority', + ], + }) + continue + } + verifiedRecords.push(enriched) + } + return { records: verifiedRecords, rejectedRecords } +} + function localRelayRecordFor(agentId: string, endpointHints: unknown[] = []) { const registered = localAgents.get(agentId) const card = registered ? localAgentCards.get(registered.cardId) : undefined @@ -2145,7 +2204,7 @@ app.post('/registry/publish', async (c) => { if (!cardId) { return c.json({ error: 'agentCardId is required' }, 400) } - const record = localRegistryRecordFor(cardId, body.mode === 'private' ? 'private' : 'public') + const record = await localRegistryRecordFor(cardId, body.mode === 'private' ? 'private' : 'public') if (!record) { return c.json({ error: 'registered local AgentCard not found', cardId }, 404) } @@ -2159,11 +2218,15 @@ app.post('/registry/search', async (c) => { const matched = Array.from(localRegistryRecords.values()).filter((record) => ( !capability || (record.capabilities as string[] | undefined)?.includes(capability) )) - const filtered = filterVersionCompatibleProviderRecords(body, matched) + const verified = await filterVerifiedLocalRegistryRecords(matched) + const filtered = filterVersionCompatibleProviderRecords(body, verified.records) return c.json({ capability: capability ?? null, records: filtered.records, - [filtered.rejectedKey]: filtered.rejected, + rejectedRecords: [ + ...verified.rejectedRecords, + ...filtered.rejected, + ], authorityGranted: false, }) }) @@ -2174,12 +2237,16 @@ app.post('/discover/registry', async (c) => { const matched = Array.from(localRegistryRecords.values()).filter((record) => ( !capability || (record.capabilities as string[] | undefined)?.includes(capability) )) - const filtered = filterVersionCompatibleProviderRecords(body, matched) + const verified = await filterVerifiedLocalRegistryRecords(matched) + const filtered = filterVersionCompatibleProviderRecords(body, verified.records) return c.json({ provider: 'registry', capability: capability ?? null, records: filtered.records, - [filtered.rejectedKey]: filtered.rejected, + rejectedRecords: [ + ...verified.rejectedRecords, + ...filtered.rejected, + ], authorityGranted: false, explanation: 'Registry discovery returns registry records only; registration does not grant invocation authority.', }) @@ -2461,7 +2528,7 @@ async function runLocalFullDemo() { const invoice = await createDemoAgent({ name: 'Invoice Agent', capability: invoiceCapability, publisher: publisher.identity as PublisherIdentity }) const payment = await createDemoAgent({ name: 'Payment Agent', capability: paymentCapability, publisher: publisher.identity as PublisherIdentity }) - const registryRecord = localRegistryRecordFor(invoice.card.id) + const registryRecord = await localRegistryRecordFor(invoice.card.id) if (registryRecord) localRegistryRecords.set(String(registryRecord.id), registryRecord) const relayRecord = localRelayRecordFor(calendar.card.identity.did, ['local://calendar-agent']) if (relayRecord) localRelayRecords.set(calendar.card.identity.did, relayRecord) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index b6c99a1..2fcc96e 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1293,7 +1293,26 @@ describe('Agentd Service Routes', () => { body: JSON.stringify({ agentCardId: identity.did }), }) expect(publish.status).toBe(201) - expect((await publish.json()).record.authorityGranted).toBe(false) + const publishedRegistry = await publish.json() + expect(publishedRegistry.record).toMatchObject({ + agentId: identity.did, + agentCardUrl: `local://agent-cards/${encodeURIComponent(identity.did)}`, + registryIndexVerified: true, + authorityGranted: false, + registryIndexRecord: expect.objectContaining({ + schema_version: 'fides.registry.index.v1', + issuer: identity.did, + agent_card_id: identity.did, + agent_id: identity.did, + capability_ids: ['calendar.schedule'], + registry_url: 'local://registry', + }), + }) + expect(publishedRegistry.record.agentCardHash).toMatch(/^sha256:/) + expect(publishedRegistry.record.registryIndexProof).toMatchObject({ + type: 'Ed25519Signature2024', + verificationMethod: identity.did, + }) const search = await app.request('/registry/search', { method: 'POST', @@ -1304,7 +1323,11 @@ describe('Agentd Service Routes', () => { const searchData = await search.json() expect(searchData.authorityGranted).toBe(false) expect(searchData.records).toEqual(expect.arrayContaining([ - expect.objectContaining({ agentId: identity.did }), + expect.objectContaining({ + agentId: identity.did, + registryIndexVerified: true, + agentCardHash: expect.stringMatching(/^sha256:/), + }), ])) const discoverRegistry = await app.request('/discover/registry', { @@ -1317,14 +1340,22 @@ describe('Agentd Service Routes', () => { provider: 'registry', authorityGranted: false, records: expect.arrayContaining([ - expect.objectContaining({ agentId: identity.did }), + expect.objectContaining({ + agentId: identity.did, + registryIndexVerified: true, + agentCardHash: expect.stringMatching(/^sha256:/), + }), ]), }) const index = await app.request('/registry/index') expect(index.status).toBe(200) expect((await index.json()).records).toEqual(expect.arrayContaining([ - expect.objectContaining({ agentId: identity.did }), + expect.objectContaining({ + agentId: identity.did, + registryIndexVerified: true, + registryIndexRecord: expect.objectContaining({ schema_version: 'fides.registry.index.v1' }), + }), ])) const registryStart = await app.request('/registry/start', { method: 'POST' }) From f7ec0913873a39d5cdaada84da39caab1cf7c85c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:28:58 +0300 Subject: [PATCH 054/282] feat(cli): expose discovery version controls --- docs/cli-reference.md | 19 +++++--- packages/cli/src/commands/dht.ts | 18 +++++++- packages/cli/src/commands/discover.ts | 8 +++- packages/cli/src/commands/registry.ts | 6 ++- packages/cli/src/commands/relay.ts | 6 ++- packages/cli/test/commands.test.ts | 64 ++++++++++++++++++++++++++- 6 files changed, 108 insertions(+), 13 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 796df48..39edc0a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -39,18 +39,19 @@ agentd identity show did:fides:... agentd identity domain challenge example.com did:fides:... agentd identity domain verify example.com did:fides:... agentd discover "reconcile invoices" --capability invoice.reconcile --provider local -agentd discover --capability invoice.reconcile --provider registry -agentd discover --capability invoice.reconcile --provider relay +agentd discover --capability invoice.reconcile --provider registry --supported-versions fides.v2.0 --required-versions fides.v2.0 +agentd discover --capability invoice.reconcile --provider relay --supported-versions fides.v2.0 agentd discover --capability invoice.reconcile --provider dht agentd discover --capability invoice.reconcile --all-providers agentd demo run agentd simulate adversarial agentd registry start agentd registry publish did:fides:... -agentd registry search --capability invoice.reconcile +agentd registry search --capability invoice.reconcile --supported-versions fides.v2.0 agentd relay start agentd relay register did:fides:... -agentd relay discover --capability invoice.reconcile +agentd relay discover --capability invoice.reconcile --supported-versions fides.v2.0 +agentd dht publish --capability invoice.reconcile --agent-id did:fides:... agentd dht find --capability invoice.reconcile agentd evidence verify agentd daemon status @@ -65,11 +66,17 @@ inside the local identity file. `--provider local`, `well-known`, `registry`, `relay`, `dht`, or `--all-providers` to choose the provider surface. These commands return candidates, registry records, relay presence records, or DHT pointers only; -they do not grant invocation authority. +they do not grant invocation authority. Use `--supported-versions` and +`--required-versions` to send protocol compatibility constraints to provider +discovery endpoints. `registry`, `relay`, and `dht` commands target local `agentd` discovery surfaces by default. They expose provider-specific publish/start/search -operations and keep authority separate from discovery. +operations and keep authority separate from discovery. `registry search` and +`relay discover` also accept protocol version constraints. `dht publish` can +publish an external pointer from an AgentCard path/URL, or publish a signed +local pointer without a URL by passing `--agent-id` or `--agent-card-id` with +`--capability`. `daemon status` calls `GET /health` and prints upstream checks, the authority store, and the root v2 local state store. When SQLite local state is enabled, diff --git a/packages/cli/src/commands/dht.ts b/packages/cli/src/commands/dht.ts index 173f4cc..e4b7913 100644 --- a/packages/cli/src/commands/dht.ts +++ b/packages/cli/src/commands/dht.ts @@ -7,15 +7,29 @@ export function createDhtCommand(): Command { cmd.command('publish') .description('Publish an AgentCard pointer to the local DHT service') - .argument('', 'AgentCard path or URL') + .argument('[agent-card]', 'AgentCard path or URL for external/unresolved pointers') .option('--capability ', 'Capability ID for the pointer') + .option('--agent-id ', 'Registered local agent DID for signed local pointer publish') + .option('--agent-card-id ', 'Registered local AgentCard ID for signed local pointer publish') + .option('--agent-card-url ', 'Optional AgentCard URL; omitted local records use local://agent-cards/') + .option('--expires-at ', 'Pointer expiry timestamp') .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') .option('--json', 'Print JSON only') .action(async (agentCard, options) => { try { + if (!options.capability) { + throw new Error('--capability is required') + } + if (!agentCard && !options.agentId && !options.agentCardId) { + throw new Error('provide an AgentCard path/URL, --agent-id, or --agent-card-id') + } const result = await postJson(`${baseUrl(options.agentdUrl)}/dht/publish`, { - agentCard, capability: options.capability, + ...(agentCard ? { agentCard } : {}), + ...(options.agentId ? { agentId: options.agentId } : {}), + ...(options.agentCardId ? { agentCardId: options.agentCardId } : {}), + ...(options.agentCardUrl ? { agentCardUrl: options.agentCardUrl } : {}), + ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}), }) printResult('DHT pointer published:', result, options) } catch (error) { diff --git a/packages/cli/src/commands/discover.ts b/packages/cli/src/commands/discover.ts index b28bcbb..099acce 100644 --- a/packages/cli/src/commands/discover.ts +++ b/packages/cli/src/commands/discover.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { DiscoveryClient, TrustClient } from '@fides/sdk'; import { loadConfig } from '../utils/config.js'; import { error, info, formatScore } from '../utils/output.js'; -import { postJson, printResult } from './authority-utils.js'; +import { parseList, postJson, printResult } from './authority-utils.js'; const DISCOVERY_PROVIDERS = ['local', 'well-known', 'registry', 'relay', 'dht'] as const type DiscoveryProviderName = typeof DISCOVERY_PROVIDERS[number] @@ -17,6 +17,8 @@ export function createDiscoverCommand(): Command { .option('--provider ', 'Discovery provider: local, well-known, registry, relay, dht, all', 'local') .option('--all-providers', 'Query all local agentd discovery providers') .option('--constraints ', 'Discovery constraints as a JSON object') + .option('--supported-versions ', 'Comma-separated FIDES protocol versions supported by the requester') + .option('--required-versions ', 'Comma-separated FIDES protocol versions required by the requester') .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') .option('--json', 'Print JSON only') .action(async (input, options) => { @@ -45,6 +47,8 @@ async function discoverCapability( provider?: string allProviders?: boolean constraints?: string + supportedVersions?: string + requiredVersions?: string agentdUrl: string json?: boolean } @@ -57,6 +61,8 @@ async function discoverCapability( ...(intent ? { intent } : {}), capability: options.capability, ...(constraints ? { constraints } : {}), + ...(options.supportedVersions ? { supported_versions: parseList(options.supportedVersions) } : {}), + ...(options.requiredVersions ? { required_versions: parseList(options.requiredVersions) } : {}), } const results = await Promise.all(providers.map(async (provider) => { const path = provider === 'local' ? '/discover/local' : `/discover/${provider}` diff --git a/packages/cli/src/commands/registry.ts b/packages/cli/src/commands/registry.ts index c1e215f..4b13534 100644 --- a/packages/cli/src/commands/registry.ts +++ b/packages/cli/src/commands/registry.ts @@ -1,5 +1,5 @@ import { Command } from 'commander' -import { getJson, postJson, printResult } from './authority-utils.js' +import { getJson, parseList, postJson, printResult } from './authority-utils.js' export function createRegistryCommand(): Command { const cmd = new Command('registry') @@ -41,12 +41,16 @@ export function createRegistryCommand(): Command { cmd.command('search') .description('Search local mock registry records by capability') .requiredOption('--capability ', 'Capability ID') + .option('--supported-versions ', 'Comma-separated FIDES protocol versions supported by the requester') + .option('--required-versions ', 'Comma-separated FIDES protocol versions required by the requester') .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') .option('--json', 'Print JSON only') .action(async (options) => { try { const result = await postJson(`${baseUrl(options.agentdUrl)}/registry/search`, { capability: options.capability, + ...(options.supportedVersions ? { supported_versions: parseList(options.supportedVersions) } : {}), + ...(options.requiredVersions ? { required_versions: parseList(options.requiredVersions) } : {}), }) printResult('Registry records:', result, options) } catch (error) { diff --git a/packages/cli/src/commands/relay.ts b/packages/cli/src/commands/relay.ts index c708a64..e191a6d 100644 --- a/packages/cli/src/commands/relay.ts +++ b/packages/cli/src/commands/relay.ts @@ -1,5 +1,5 @@ import { Command } from 'commander' -import { getJson, postJson, printResult } from './authority-utils.js' +import { getJson, parseList as parseCommaList, postJson, printResult } from './authority-utils.js' export function createRelayCommand(): Command { const cmd = new Command('relay') @@ -41,12 +41,16 @@ export function createRelayCommand(): Command { cmd.command('discover') .description('Discover local mock relay presence by capability') .requiredOption('--capability ', 'Capability ID') + .option('--supported-versions ', 'Comma-separated FIDES protocol versions supported by the requester') + .option('--required-versions ', 'Comma-separated FIDES protocol versions required by the requester') .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') .option('--json', 'Print JSON only') .action(async (options) => { try { const result = await postJson(`${baseUrl(options.agentdUrl)}/relay/discover`, { capability: options.capability, + ...(options.supportedVersions ? { supported_versions: parseCommaList(options.supportedVersions) } : {}), + ...(options.requiredVersions ? { required_versions: parseCommaList(options.requiredVersions) } : {}), }) printResult('Relay discovery records:', result, options) } catch (error) { diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index e278afb..eea12f1 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -448,6 +448,10 @@ describe('CLI Commands', () => { 'registry', '--constraints', '{"tenant":"acme"}', + '--supported-versions', + 'fides.v2.0,fides.v2.1', + '--required-versions', + 'fides.v2.0', '--agentd-url', 'http://agentd.test/', '--json', @@ -461,6 +465,8 @@ describe('CLI Commands', () => { intent: 'reconcile invoices', capability: 'invoice.reconcile', constraints: { tenant: 'acme' }, + supported_versions: ['fides.v2.0', 'fides.v2.1'], + required_versions: ['fides.v2.0'], }), }) ); @@ -1115,6 +1121,10 @@ describe('CLI Commands', () => { 'discover', '--capability', 'invoice.reconcile', + '--supported-versions', + 'fides.v2.0', + '--required-versions', + 'fides.v2.0', '--agentd-url', 'http://agentd.test/', '--json', @@ -1141,7 +1151,11 @@ describe('CLI Commands', () => { 'http://agentd.test/relay/discover', expect.objectContaining({ method: 'POST', - body: JSON.stringify({ capability: 'invoice.reconcile' }), + body: JSON.stringify({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }), }) ); }); @@ -1175,6 +1189,10 @@ describe('CLI Commands', () => { 'search', '--capability', 'invoice.reconcile', + '--supported-versions', + 'fides.v2.0', + '--required-versions', + 'fides.v2.0', '--agentd-url', 'http://agentd.test/', '--json', @@ -1198,7 +1216,49 @@ describe('CLI Commands', () => { 'http://agentd.test/registry/search', expect.objectContaining({ method: 'POST', - body: JSON.stringify({ capability: 'invoice.reconcile' }), + body: JSON.stringify({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }), + }) + ); + }); + + it('dht publish supports signed local pointer inputs without an AgentCard URL', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + accepted: true, + pointer: { + capability: 'invoice.reconcile', + agentId: 'did:fides:agent', + signed: true, + authorityGranted: false, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createDhtCommand } = await import('../src/commands/dht.js'); + const cmd = createDhtCommand(); + + await cmd.parseAsync([ + 'publish', + '--capability', + 'invoice.reconcile', + '--agent-id', + 'did:fides:agent', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://agentd.test/dht/publish', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + capability: 'invoice.reconcile', + agentId: 'did:fides:agent', + }), }) ); }); From a3098dc2f238298b87cedc479bc5da41c3082c45 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:30:58 +0300 Subject: [PATCH 055/282] feat(sdk): type discovery provider surfaces --- docs/sdk-reference.md | 28 +++++++-- packages/sdk/src/fides-client.ts | 87 ++++++++++++++++++++++---- packages/sdk/src/index.ts | 11 +++- packages/sdk/test/fides-client.test.ts | 36 +++++++++-- 4 files changed, 139 insertions(+), 23 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 384e2aa..23d1fd1 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -30,8 +30,15 @@ await client.agents.list() await client.agents.inspect(identity.identity.did) const results = await client.discovery.find({ capability: 'invoice.reconcile' }) await client.discovery.local({ capability: 'invoice.reconcile' }) -await client.discovery.registry({ capability: 'invoice.reconcile' }) -await client.discovery.relay({ capability: 'invoice.reconcile' }) +await client.discovery.registry({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], +}) +await client.discovery.relay({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], +}) await client.discovery.dht({ capability: 'invoice.reconcile' }) const trust = await client.trust.evaluate({ agentId: identity.identity.did, @@ -97,14 +104,19 @@ const attestation = await client.attestations.create({ await client.attestations.verify(attestation.attestation.attestation_id) await client.registry.start() await client.registry.publish({ agentCardId: identity.identity.did }) -await client.registry.search({ capability: 'invoice.reconcile' }) +await client.registry.search({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], +}) await client.relay.start() await client.relay.register({ agentId: identity.identity.did }) -await client.relay.discover({ capability: 'invoice.reconcile' }) +await client.relay.discover({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], +}) await client.dht.publish({ capability: 'invoice.reconcile', agentId: identity.identity.did, - agentCardUrl: 'local://invoice-agent-card', }) await client.dht.find({ capability: 'invoice.reconcile' }) await client.wellKnown.fides() @@ -149,7 +161,11 @@ session policy decisions. Runtime attestation helpers issue and verify local MockTEE attestations that can satisfy high-risk session policy when passed as an `attestationId`. Registry, relay, DHT, and well-known helpers expose the local mock discovery surfaces. They return candidate records or pointers only; -they do not convert discovery into authority. +they do not convert discovery into authority. Discovery, registry, and relay +helpers accept `supported_versions` and `required_versions` so callers can +request protocol compatibility filtering. `dht.publish` can publish a signed +local pointer without an AgentCard URL when `agentId` or `agentCardId` refers to +a registered local AgentCard. Advanced authority flows can use `AgentdClient`. `AgentdClient.health()` reads `GET /health` and returns typed authority-store and local-state-store status, including the SQLite snapshot path when the daemon exposes it. diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 24d78e1..58de024 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -7,6 +7,69 @@ export interface FidesRequestOptions { headers?: Record } +export interface FidesDiscoveryQuery { + intent?: string + capability: string + constraints?: Record + supported_versions?: string[] + required_versions?: string[] +} + +export interface FidesProviderRecord { + agentId?: string + agent_id?: string + cardId?: string + capability?: string + capabilities?: string[] + authorityGranted?: false + agentCardUrl?: string + agent_card_url?: string + agentCardHash?: string + agent_card_hash?: string + registryIndexVerified?: boolean + registryIndexRecord?: Record + registryIndexProof?: Record | null + signedAgentCard?: boolean + agentCardProof?: Record | null + verification?: Record + versionNegotiation?: Record + reasons?: string[] + [key: string]: unknown +} + +export interface FidesDiscoveryResponse { + provider?: string + capability?: string | null + candidates?: FidesProviderRecord[] + rejectedCandidates?: FidesProviderRecord[] + records?: FidesProviderRecord[] + rejectedRecords?: FidesProviderRecord[] + pointers?: FidesProviderRecord[] + rejectedPointers?: FidesProviderRecord[] + authorityGranted: false + explanation?: string + [key: string]: unknown +} + +export interface FidesRegistryPublishRequest { + agentCardId: string + mode?: 'public' | 'private' +} + +export interface FidesRelayRegisterRequest { + agentId: string + endpointHints?: string[] +} + +export interface FidesDhtPublishRequest { + capability: string + agentId?: string + agentCardId?: string + agentCard?: string + agentCardUrl?: string + expiresAt?: string +} + export class FidesClient { readonly identity = { createAgent: (body: Record = {}) => this.post('/identities', { ...body, type: 'agent' }), @@ -30,12 +93,12 @@ export class FidesClient { } readonly discovery = { - find: (query: Record) => this.post('/discover', query), - local: (query: Record) => this.post('/discover/local', query), - wellKnown: (query: Record) => this.post('/discover/well-known', query), - registry: (query: Record) => this.post('/discover/registry', query), - relay: (query: Record) => this.post('/discover/relay', query), - dht: (query: Record) => this.post('/discover/dht', query), + find: (query: FidesDiscoveryQuery): Promise => this.post('/discover', query) as Promise, + local: (query: FidesDiscoveryQuery): Promise => this.post('/discover/local', query) as Promise, + wellKnown: (query: FidesDiscoveryQuery): Promise => this.post('/discover/well-known', query) as Promise, + registry: (query: FidesDiscoveryQuery): Promise => this.post('/discover/registry', query) as Promise, + relay: (query: FidesDiscoveryQuery): Promise => this.post('/discover/relay', query) as Promise, + dht: (query: FidesDiscoveryQuery): Promise => this.post('/discover/dht', query) as Promise, } readonly trust = { @@ -96,21 +159,21 @@ export class FidesClient { readonly registry = { start: () => this.post('/registry/start', {}), - publish: (body: Record) => this.post('/registry/publish', body), - search: (body: Record) => this.post('/registry/search', body), + publish: (body: FidesRegistryPublishRequest) => this.post('/registry/publish', body), + search: (body: FidesDiscoveryQuery): Promise => this.post('/registry/search', body) as Promise, index: () => this.get('/registry/index'), } readonly relay = { start: () => this.post('/relay/start', {}), - register: (body: Record) => this.post('/relay/register', body), - discover: (body: Record) => this.post('/relay/discover', body), + register: (body: FidesRelayRegisterRequest) => this.post('/relay/register', body), + discover: (body: FidesDiscoveryQuery): Promise => this.post('/relay/discover', body) as Promise, } readonly dht = { start: () => this.post('/dht/start', {}), - publish: (body: Record) => this.post('/dht/publish', body), - find: (body: Record) => this.post('/dht/find', body), + publish: (body: FidesDhtPublishRequest) => this.post('/dht/publish', body), + find: (body: Pick) => this.post('/dht/find', body), } readonly wellKnown = { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 07cd799..2f215f3 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -136,7 +136,16 @@ export { metricsMiddleware } from './observability/metrics-middleware.js' // High-level API export { Fides } from './fides.js' -export { FidesClient, type FidesClientOptions } from './fides-client.js' +export { + FidesClient, + type FidesClientOptions, + type FidesDiscoveryQuery, + type FidesDiscoveryResponse, + type FidesProviderRecord, + type FidesRegistryPublishRequest, + type FidesRelayRegisterRequest, + type FidesDhtPublishRequest, +} from './fides-client.js' // Integration exports export { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 0b2ef15..1de0e75 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -25,8 +25,15 @@ describe('FidesClient', () => { await client.discovery.find({ capability: 'invoice.reconcile' }) await client.discovery.local({ capability: 'invoice.reconcile' }) await client.discovery.wellKnown({ capability: 'invoice.reconcile' }) - await client.discovery.registry({ capability: 'invoice.reconcile' }) - await client.discovery.relay({ capability: 'invoice.reconcile' }) + await client.discovery.registry({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }) + await client.discovery.relay({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], + }) await client.discovery.dht({ capability: 'invoice.reconcile' }) await client.trust.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.reputation.update({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) @@ -58,11 +65,18 @@ describe('FidesClient', () => { await client.sessions.verify('sess_1') await client.registry.start() await client.registry.publish({ agentCardId: 'did:fides:agent' }) - await client.registry.search({ capability: 'invoice.reconcile' }) + await client.registry.search({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }) await client.registry.index() await client.relay.start() await client.relay.register({ agentId: 'did:fides:agent' }) - await client.relay.discover({ capability: 'invoice.reconcile' }) + await client.relay.discover({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], + }) await client.dht.start() await client.dht.publish({ capability: 'invoice.reconcile', agentId: 'did:fides:agent' }) await client.dht.find({ capability: 'invoice.reconcile' }) @@ -192,6 +206,20 @@ describe('FidesClient', () => { 'POST', 'POST', ]) + expect(JSON.parse(calls[7].init?.body as string)).toEqual({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }) + expect(JSON.parse(calls[36].init?.body as string)).toEqual({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }) + expect(JSON.parse(calls[42].init?.body as string)).toEqual({ + capability: 'invoice.reconcile', + agentId: 'did:fides:agent', + }) }) it('uses the root identity API served by local agentd', async () => { From dd12c4ea14372ef1b36e6434057fc4ca72557c50 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:34:13 +0300 Subject: [PATCH 056/282] feat(demo): verify signed provider records --- examples/full-demo/README.md | 7 +++++++ examples/full-demo/run.ts | 5 ++++- services/agentd/src/index.ts | 18 +++++++++++++++--- services/agentd/test/routes.test.ts | 19 +++++++++++++++++-- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/examples/full-demo/README.md b/examples/full-demo/README.md index 485918e..49d3be2 100644 --- a/examples/full-demo/README.md +++ b/examples/full-demo/README.md @@ -20,6 +20,13 @@ computes trust and reputation, evaluates policy, issues scoped sessions, invokes the invoice and payment dry-run paths, records incident and revocation state, and verifies the local EvidenceEvent hash chain. +The demo also exercises the signed provider metadata surfaces: + +- registry discovery returns a verified signed `RegistryIndexRecord` +- relay discovery returns a signed AgentCard reference and AgentCard hash +- DHT discovery returns a signed pointer record with valid pointer verification +- all discovery/provider results keep `authorityGranted: false` + Current limitations: - DHT, relay, and registry are local mock providers. diff --git a/examples/full-demo/run.ts b/examples/full-demo/run.ts index 42296b4..b56457d 100644 --- a/examples/full-demo/run.ts +++ b/examples/full-demo/run.ts @@ -24,6 +24,9 @@ export const fullDemoSteps = [ 'publish_invoice_agent_to_registry', 'publish_calendar_agent_to_relay', 'publish_payment_pointer_to_dht', + 'verify_signed_registry_index_record', + 'verify_signed_relay_agent_card_reference', + 'verify_signed_dht_pointer_record', 'discover_calendar_locally', 'discover_invoice_through_registry', 'discover_payment_through_dht', @@ -49,7 +52,7 @@ export const fullDemoSteps = [ export function describeFullDemo(): { status: 'manifest'; execution: string; steps: readonly string[] } { return { status: 'manifest', - execution: 'agentd demo run executes this contract against local daemon state', + execution: 'agentd demo run executes this contract against local daemon state and verifies signed provider records without granting discovery authority', steps: fullDemoSteps, } } diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index ab065db..ab8a62f 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -2532,13 +2532,25 @@ async function runLocalFullDemo() { if (registryRecord) localRegistryRecords.set(String(registryRecord.id), registryRecord) const relayRecord = localRelayRecordFor(calendar.card.identity.did, ['local://calendar-agent']) if (relayRecord) localRelayRecords.set(calendar.card.identity.did, relayRecord) - const dhtPointer = { - id: crypto.randomUUID(), + const dhtPointerRecord = await signDHTPointerRecord(createDHTPointerRecord({ capability: paymentCapability.id, agentId: payment.card.identity.did, agentCardUrl: `local://agent-cards/${payment.card.id}`, + agentCardHash: hashAgentCard(payment.card), + publisherId: publisher.identity.did, + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), Buffer.from(payment.identity.privateKeyHex, 'hex'), payment.card.identity.did) + const dhtPointer = { + ...dhtPointerRecord, + id: crypto.randomUUID(), + agentId: dhtPointerRecord.agent_id, + agentCardUrl: dhtPointerRecord.agent_card_url, + agentCardHash: dhtPointerRecord.agent_card_hash, + publisherId: dhtPointerRecord.publisher_id, + cardId: payment.card.id, + signed: true, publishedAt: new Date().toISOString(), - source: 'agentd-in-memory-dht', + source: 'agentd-demo-signed-dht-pointer', } localDhtPointers.push(dhtPointer) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 2fcc96e..2ed71ef 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1452,10 +1452,25 @@ describe('Agentd Service Routes', () => { expect(demoData.policy.paymentWithoutAttestation.decision).toBe('require_approval') expect(demoData.policy.revokedMalicious.decision).toBe('deny') expect(demoData.discovery.registry.records).toEqual(expect.arrayContaining([ - expect.objectContaining({ agentId: demoData.identities.invoice }), + expect.objectContaining({ + agentId: demoData.identities.invoice, + registryIndexVerified: true, + agentCardHash: expect.stringMatching(/^sha256:/), + }), + ])) + expect(demoData.discovery.relay.records).toEqual(expect.arrayContaining([ + expect.objectContaining({ + agentId: demoData.identities.calendar, + signedAgentCard: true, + agentCardHash: expect.stringMatching(/^sha256:/), + }), ])) expect(demoData.discovery.dht.pointers).toEqual(expect.arrayContaining([ - expect.objectContaining({ agentId: demoData.identities.payment }), + expect.objectContaining({ + agentId: demoData.identities.payment, + signed: true, + verification: expect.objectContaining({ valid: true }), + }), ])) expect(demoData.invocation.invoice.preflight.can_execute).toBe(true) expect(demoData.verification.evidenceEventCount).toBeGreaterThan(0) From e45f4a05fd8ca0189db26998e679a6fc551774e2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:38:56 +0300 Subject: [PATCH 057/282] feat(core): propagate typed error envelopes --- docs/api-reference.md | 5 +- docs/protocol/error-vocabulary.md | 7 +++ docs/sdk-reference.md | 5 +- packages/core/src/errors.ts | 6 +++ packages/sdk/src/fides-client.ts | 26 +++++++++- packages/sdk/src/index.ts | 1 + packages/sdk/test/fides-client.test.ts | 44 +++++++++++++++- services/agentd/src/index.ts | 71 ++++++++++++++++++++++---- services/agentd/test/routes.test.ts | 47 +++++++++++++++-- 9 files changed, 196 insertions(+), 16 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 194f732..1a0b785 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -190,7 +190,10 @@ policy-checked SessionGrant before invocation. `POST /sessions` issues a local the capability context, and returns an `InvocationResult`. Invocation state and result evidence are persisted in the local daemon snapshot when SQLite state is enabled; signed invocation results and normalized durable tables remain -follow-up hardening work. +follow-up hardening work. Session issuance and invocation failures return a +stable `ErrorEnvelope` on the `error` field for policy denial, approval +required, active revocation, active kill switch, missing capability, missing +session, expired session, and invalid session-scope cases. `POST /approvals` creates an approval request and records approval decisions through `/approvals/:id/approve` or `/approvals/:id/deny`. Approval records do diff --git a/docs/protocol/error-vocabulary.md b/docs/protocol/error-vocabulary.md index a244c58..4b8fb4d 100644 --- a/docs/protocol/error-vocabulary.md +++ b/docs/protocol/error-vocabulary.md @@ -30,9 +30,16 @@ Examples: - `POLICY_DENIED` - `APPROVAL_REQUIRED` - `SESSION_EXPIRED` +- `SESSION_NOT_FOUND` - `ATTESTATION_INVALID` - `DHT_POINTER_TAMPERED` - `EVIDENCE_CHAIN_BROKEN` - `REVOCATION_ACTIVE` - `KILL_SWITCH_ACTIVE` - `VERSION_INCOMPATIBLE` + +Root `agentd` authority-critical failures return this envelope shape on the +`error` field for session issuance and invocation failures. Policy-blocked +session issuance maps kill switch, revocation, approval-required, and generic +policy denial states to stable machine codes while preserving the full +`policy.reason_codes` list in `error.details`. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 23d1fd1..511c750 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -165,7 +165,10 @@ they do not convert discovery into authority. Discovery, registry, and relay helpers accept `supported_versions` and `required_versions` so callers can request protocol compatibility filtering. `dht.publish` can publish a signed local pointer without an AgentCard URL when `agentId` or `agentCardId` refers to -a registered local AgentCard. +a registered local AgentCard. Failed SDK calls throw `FidesClientError`; when +the daemon returns a protocol `ErrorEnvelope`, the typed envelope is available +on `error.error` with stable `code`, `category`, `severity`, `retryable`, +`message`, and `details` fields. Advanced authority flows can use `AgentdClient`. `AgentdClient.health()` reads `GET /health` and returns typed authority-store and local-state-store status, including the SQLite snapshot path when the daemon exposes it. diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 7734b10..144d15f 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -72,6 +72,12 @@ export const FIDES_ERROR_CODES = { retryable: true, message: 'Session is expired', }, + SESSION_NOT_FOUND: { + category: 'session', + severity: 'error', + retryable: false, + message: 'Session was not found', + }, SESSION_SCOPE_INVALID: { category: 'session', severity: 'error', diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 58de024..0017555 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -1,3 +1,5 @@ +import { isErrorEnvelope, type ErrorEnvelope } from '@fides/core' + export interface FidesClientOptions { daemonUrl: string apiKey?: string @@ -70,6 +72,19 @@ export interface FidesDhtPublishRequest { expiresAt?: string } +export class FidesClientError extends Error { + readonly name = 'FidesClientError' + + constructor( + message: string, + readonly status: number, + readonly payload: unknown, + readonly error?: ErrorEnvelope + ) { + super(message) + } +} + export class FidesClient { readonly identity = { createAgent: (body: Record = {}) => this.post('/identities', { ...body, type: 'agent' }), @@ -233,8 +248,17 @@ export class FidesClient { const text = await response.text() const payload = text ? JSON.parse(text) : {} if (!response.ok) { - throw new Error(`FIDES request failed with HTTP ${response.status}: ${JSON.stringify(payload)}`) + const envelope = extractErrorEnvelope(payload) + const message = envelope?.message ?? `FIDES request failed with HTTP ${response.status}` + throw new FidesClientError(message, response.status, payload, envelope) } return payload } } + +function extractErrorEnvelope(payload: unknown): ErrorEnvelope | undefined { + if (isErrorEnvelope(payload)) return payload + if (!payload || typeof payload !== 'object') return undefined + const error = (payload as { error?: unknown }).error + return isErrorEnvelope(error) ? error : undefined +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 2f215f3..e2f9591 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -138,6 +138,7 @@ export { metricsMiddleware } from './observability/metrics-middleware.js' export { Fides } from './fides.js' export { FidesClient, + FidesClientError, type FidesClientOptions, type FidesDiscoveryQuery, type FidesDiscoveryResponse, diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 1de0e75..5db9930 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { FidesClient } from '../src/fides-client.js' +import { FidesClient, FidesClientError } from '../src/fides-client.js' afterEach(() => { vi.unstubAllGlobals() @@ -255,6 +255,48 @@ describe('FidesClient', () => { expect((calls[0].init?.headers as Headers).get('X-API-Key')).toBe('sdk-key') }) + it('throws typed client errors when agentd returns an ErrorEnvelope', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { + return new Response(JSON.stringify({ + error: { + code: 'APPROVAL_REQUIRED', + category: 'approval', + severity: 'warning', + retryable: true, + message: 'Human approval is required before execution', + details: { reason_codes: ['HIGH_RISK_REQUIRES_ATTESTATION_OR_APPROVAL'] }, + }, + }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + + await expect(client.sessions.request({ + agentId: 'did:fides:agent', + capability: 'payments.prepare', + })).rejects.toMatchObject({ + name: 'FidesClientError', + status: 409, + error: { + code: 'APPROVAL_REQUIRED', + retryable: true, + }, + }) + + try { + await client.sessions.request({ agentId: 'did:fides:agent', capability: 'payments.prepare' }) + } catch (error) { + expect(error).toBeInstanceOf(FidesClientError) + expect((error as FidesClientError).message).toBe('Human approval is required before execution') + expect((error as FidesClientError).error?.details).toEqual({ + reason_codes: ['HIGH_RISK_REQUIRES_ATTESTATION_OR_APPROVAL'], + }) + } + }) + it('uses the root AgentCard API served by local agentd', async () => { const calls: Array<{ url: string; init?: RequestInit }> = [] vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index ab8a62f..9840cd6 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -40,6 +40,7 @@ import { createKillSwitchRule, createDelegationToken, createDHTPointerRecord, + createErrorEnvelope, createRegistryIndexRecord, createPrincipalIdentity, createPublisherIdentity, @@ -509,6 +510,25 @@ function activeLocalIncidentFor(agentId: string): IncidentRecordV2 | undefined { }) } +function policyErrorEnvelope(policy: FidesPolicyDecision) { + const code = policy.reason_codes.includes('KILL_SWITCH_ACTIVE') + ? 'KILL_SWITCH_ACTIVE' + : policy.reason_codes.includes('REVOCATION_ACTIVE') + ? 'REVOCATION_ACTIVE' + : policy.reason_codes.includes('HIGH_RISK_REQUIRES_ATTESTATION_OR_APPROVAL') || policy.reason_codes.includes('CRITICAL_REQUIRES_EXPLICIT_APPROVAL') + ? 'APPROVAL_REQUIRED' + : 'POLICY_DENIED' + + return createErrorEnvelope(code, { + message: policy.human_reasons[0] ?? undefined, + details: { + decision: policy.decision, + reason_codes: policy.reason_codes, + required_controls: policy.required_controls, + }, + }) +} + async function verifyLocalRuntimeAttestation(attestationId: string | undefined, agentId: string): Promise { if (!attestationId) return undefined const attestation = localRuntimeAttestations.get(attestationId) @@ -1333,17 +1353,26 @@ app.post('/sessions', async (c) => { : undefined if (!targetAgentId || !capabilityId) { - return c.json({ error: 'agentId and capability are required' }, 400) + return c.json({ error: createErrorEnvelope('CAPABILITY_NOT_FOUND', { + message: 'agentId and capability are required', + details: { agentId: targetAgentId, capability: capabilityId }, + }) }, 400) } const found = findLocalCapability(targetAgentId, capabilityId) if (!found) { - return c.json({ error: 'registered agent capability not found', agentId: targetAgentId, capability: capabilityId }, 404) + return c.json({ error: createErrorEnvelope('CAPABILITY_NOT_FOUND', { + message: 'Registered agent capability was not found', + details: { agentId: targetAgentId, capability: capabilityId }, + }), agentId: targetAgentId, capability: capabilityId }, 404) } const trust = computeLocalTrustResult(targetAgentId, capabilityId) if (!trust) { - return c.json({ error: 'trust result unavailable', agentId: targetAgentId, capability: capabilityId }, 404) + return c.json({ error: createErrorEnvelope('TRUST_BELOW_THRESHOLD', { + message: 'Trust result is unavailable', + details: { agentId: targetAgentId, capability: capabilityId }, + }), agentId: targetAgentId, capability: capabilityId }, 404) } const requestedScopes = Array.isArray(body.requestedScopes) ? body.requestedScopes.map(String) : [] @@ -1401,6 +1430,7 @@ app.post('/sessions', async (c) => { return c.json({ authorized: false, authorityGranted: false, + error: policyErrorEnvelope(policy), policy, trust, killSwitch: activeKillSwitch, @@ -1456,7 +1486,10 @@ app.post('/sessions', async (c) => { app.get('/sessions/:id', (c) => { const record = localSessionGrants.get(c.req.param('id')) if (!record) { - return c.json({ error: 'session not found' }, 404) + return c.json({ error: createErrorEnvelope('SESSION_NOT_FOUND', { + message: 'Session was not found or is no longer available', + details: { sessionId: c.req.param('id') }, + }) }, 404) } return c.json({ session: record.session, policy: record.policy, trust: record.trust }) }) @@ -1464,7 +1497,13 @@ app.get('/sessions/:id', (c) => { app.post('/sessions/:id/verify', (c) => { const record = localSessionGrants.get(c.req.param('id')) if (!record) { - return c.json({ valid: false, error: 'session not found' }, 404) + return c.json({ + valid: false, + error: createErrorEnvelope('SESSION_NOT_FOUND', { + message: 'Session was not found or is no longer available', + details: { sessionId: c.req.param('id') }, + }), + }, 404) } return c.json({ @@ -1481,21 +1520,35 @@ app.post('/invoke', async (c) => { ? body.session_id : undefined if (!sessionId) { - return c.json({ error: 'sessionId is required' }, 400) + return c.json({ error: createErrorEnvelope('SESSION_SCOPE_INVALID', { + message: 'sessionId is required', + }) }, 400) } const record = localSessionGrants.get(sessionId) if (!record) { - return c.json({ error: 'session not found', sessionId }, 404) + return c.json({ error: createErrorEnvelope('SESSION_NOT_FOUND', { + message: 'Session was not found or is no longer available', + details: { sessionId }, + }), sessionId }, 404) } if (new Date(record.session.expires_at).getTime() <= Date.now()) { - return c.json({ error: 'session expired', sessionId, authorityGranted: false }, 409) + return c.json({ error: createErrorEnvelope('SESSION_EXPIRED', { + details: { sessionId, expires_at: record.session.expires_at }, + }), sessionId, authorityGranted: false }, 409) } const found = findLocalCapability(record.session.target_agent_id, record.session.capability) if (!found) { - return c.json({ error: 'registered agent capability not found', sessionId, authorityGranted: false }, 404) + return c.json({ error: createErrorEnvelope('CAPABILITY_NOT_FOUND', { + message: 'Registered agent capability was not found', + details: { + sessionId, + agentId: record.session.target_agent_id, + capability: record.session.capability, + }, + }), sessionId, authorityGranted: false }, 404) } const request = createInvocationRequest({ diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 2ed71ef..ed37c3a 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -787,6 +787,40 @@ describe('Agentd Service Routes', () => { ])) }) + it('returns typed error envelopes for root session and invocation failures', async () => { + const missingCapability = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'did:fides:agent:missing', + capability: 'invoice.reconcile', + }), + }) + expect(missingCapability.status).toBe(404) + await expect(missingCapability.json()).resolves.toMatchObject({ + error: { + code: 'CAPABILITY_NOT_FOUND', + category: 'capability', + retryable: false, + }, + }) + + const missingSession = await app.request('/invoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: 'sess_missing' }), + }) + expect(missingSession.status).toBe(404) + await expect(missingSession.json()).resolves.toMatchObject({ + error: { + code: 'SESSION_NOT_FOUND', + category: 'session', + retryable: false, + }, + sessionId: 'sess_missing', + }) + }) + it('serves root approval request and decision lifecycle', async () => { const request = await app.request('/approvals', { method: 'POST', @@ -873,6 +907,7 @@ describe('Agentd Service Routes', () => { expect(blocked.status).toBe(409) const blockedData = await blocked.json() expect(blockedData.policy.reason_codes).toContain('KILL_SWITCH_ACTIVE') + expect(blockedData.error.code).toBe('KILL_SWITCH_ACTIVE') expect(blockedData.authorityGranted).toBe(false) const disabled = await app.request(`/killswitch/${enabledData.rule.id}`, { method: 'DELETE' }) @@ -940,7 +975,9 @@ describe('Agentd Service Routes', () => { }), }) expect(blocked.status).toBe(409) - expect((await blocked.json()).policy.reason_codes).toContain('REVOCATION_ACTIVE') + const blockedData = await blocked.json() + expect(blockedData.policy.reason_codes).toContain('REVOCATION_ACTIVE') + expect(blockedData.error.code).toBe('REVOCATION_ACTIVE') }) it('serves root incident records and blocks scoped session issuance until resolved', async () => { @@ -1001,7 +1038,9 @@ describe('Agentd Service Routes', () => { }), }) expect(blocked.status).toBe(409) - expect((await blocked.json()).policy.reason_codes).toContain('INCIDENT_REQUIRES_REVIEW') + const blockedData = await blocked.json() + expect(blockedData.policy.reason_codes).toContain('INCIDENT_REQUIRES_REVIEW') + expect(blockedData.error.code).toBe('POLICY_DENIED') const resolved = await app.request(`/incidents/${incidentData.record.id}/resolve`, { method: 'POST', @@ -1046,7 +1085,9 @@ describe('Agentd Service Routes', () => { }), }) expect(withoutAttestation.status).toBe(409) - expect((await withoutAttestation.json()).policy.reason_codes).toContain('HIGH_RISK_REQUIRES_ATTESTATION_OR_APPROVAL') + const withoutAttestationData = await withoutAttestation.json() + expect(withoutAttestationData.policy.reason_codes).toContain('HIGH_RISK_REQUIRES_ATTESTATION_OR_APPROVAL') + expect(withoutAttestationData.error.code).toBe('APPROVAL_REQUIRED') const attestation = await app.request('/attestations', { method: 'POST', From dd0d003bc3316e0bf84f2de93278a04bba799ec7 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:41:19 +0300 Subject: [PATCH 058/282] chore(cli): add workspace binary scripts --- packages/cli/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8714f91..cc4442a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,6 +23,8 @@ "sideEffects": false, "scripts": { "build": "tsc", + "fides": "node dist/index.js", + "agentd": "node dist/index.js", "lint": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", From 6d96f4e2bef1daefb2db3610836446583ca7beb1 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:42:19 +0300 Subject: [PATCH 059/282] fix(cli): infer agentd binary name --- packages/cli/src/cli-name.ts | 7 +++++++ packages/cli/src/index.ts | 3 ++- packages/cli/test/commands.test.ts | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/cli-name.ts diff --git a/packages/cli/src/cli-name.ts b/packages/cli/src/cli-name.ts new file mode 100644 index 0000000..aaeb3a3 --- /dev/null +++ b/packages/cli/src/cli-name.ts @@ -0,0 +1,7 @@ +import { basename } from 'node:path' + +export function inferCliName(argv: readonly string[] = process.argv, lifecycleEvent = process.env.npm_lifecycle_event): 'fides' | 'agentd' { + if (lifecycleEvent === 'agentd') return 'agentd' + const invoked = basename(argv[1] ?? '') + return invoked === 'agentd' ? 'agentd' : 'fides' +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3f21d28..8d55fc4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -25,12 +25,13 @@ import { createDhtCommand } from './commands/dht.js'; import { createEvidenceCommand } from './commands/evidence.js'; import { createDemoCommand } from './commands/demo.js'; import { createSimulateCommand } from './commands/simulate.js'; +import { inferCliName } from './cli-name.js'; import packageJson from '../package.json' with { type: 'json' }; const program = new Command(); program - .name('fides') + .name(inferCliName()) .version(packageJson.version) .description('FIDES v2 - Agent Trust Fabric'); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index eea12f1..1c4f206 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -107,6 +107,22 @@ describe('CLI Commands', () => { vi.unstubAllGlobals(); }); + describe('binary name inference', () => { + it('uses agentd when invoked through the agentd workspace script or binary', async () => { + const { inferCliName } = await import('../src/cli-name.js'); + + expect(inferCliName(['/usr/local/bin/node', '/repo/packages/cli/dist/index.js'], 'agentd')).toBe('agentd'); + expect(inferCliName(['/usr/local/bin/node', '/usr/local/bin/agentd'])).toBe('agentd'); + }); + + it('defaults to fides for the fides binary and direct node execution', async () => { + const { inferCliName } = await import('../src/cli-name.js'); + + expect(inferCliName(['/usr/local/bin/node', '/usr/local/bin/fides'])).toBe('fides'); + expect(inferCliName(['/usr/local/bin/node', '/repo/packages/cli/dist/index.js'])).toBe('fides'); + }); + }); + describe('init command', () => { it('should create identity and save config', async () => { const mockKeyPair = { From 4e3b62ec554d40b686c351485d3b4452d469e776 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 02:45:08 +0300 Subject: [PATCH 060/282] feat(invocation): sign local invocation results --- docs/api-reference.md | 15 ++++++------ docs/protocol/delegation-and-sessions.md | 10 ++++++++ docs/sdk-reference.md | 6 +++++ packages/sdk/src/fides-client.ts | 30 +++++++++++++++++++++--- packages/sdk/src/index.ts | 2 ++ services/agentd/src/index.ts | 9 +++++++ services/agentd/test/routes.test.ts | 4 ++++ 7 files changed, 66 insertions(+), 10 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 1a0b785..cf32178 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -187,13 +187,14 @@ returns `authorityGranted: false`; it must still be signed and converted into a policy-checked SessionGrant before invocation. `POST /sessions` issues a local `SessionGrant` only after policy allows or limits the action to dry-run. `POST /invoke` verifies the session, runs the policy preflight path, validates -the capability context, and returns an `InvocationResult`. Invocation state and -result evidence are persisted in the local daemon snapshot when SQLite state is -enabled; signed invocation results and normalized durable tables remain -follow-up hardening work. Session issuance and invocation failures return a -stable `ErrorEnvelope` on the `error` field for policy denial, approval -required, active revocation, active kill switch, missing capability, missing -session, expired session, and invalid session-scope cases. +the capability context, and returns an `InvocationResult` plus a canonical +`signedResult` proof from the target agent identity when the target is locally +managed. Invocation state and result evidence are persisted in the local daemon +snapshot when SQLite state is enabled; normalized durable invocation tables +remain follow-up hardening work. Session issuance and invocation failures +return a stable `ErrorEnvelope` on the `error` field for policy denial, +approval required, active revocation, active kill switch, missing capability, +missing session, expired session, and invalid session-scope cases. `POST /approvals` creates an approval request and records approval decisions through `/approvals/:id/approve` or `/approvals/:id/deny`. Approval records do diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index 9edc51d..71c7bb8 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -6,6 +6,7 @@ Current implementation anchors: - `packages/core/src/delegation.ts` - `packages/core/src/session-store.ts` +- `packages/core/src/invocation.ts` ## SessionGrant Fields @@ -26,3 +27,12 @@ Current implementation anchors: - canonical signature Replay protection is required through nonce tracking. + +## Invocation Binding + +An invocation must bind to a scoped `SessionGrant`. The root local daemon +creates an `InvocationRequest`, performs policy preflight, emits hash-only +evidence events, creates an `InvocationResult`, and signs that result with the +target agent identity using the canonical object signing model. The signed +result is evidence that the target agent identity produced the invocation +outcome; it still does not bypass policy, revocation, or evidence verification. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 511c750..46736e3 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -132,6 +132,9 @@ const invocation = await client.invoke({ sessionId: session.session.session_id, input: { invoiceId: 'inv_123' }, }) +if (!invocation.signedResultVerified) { + throw new Error('Invocation result signature did not verify') +} const evidence = await client.evidence.append({ type: 'capability.invoked', actor: 'did:fides:requester', @@ -169,6 +172,9 @@ a registered local AgentCard. Failed SDK calls throw `FidesClientError`; when the daemon returns a protocol `ErrorEnvelope`, the typed envelope is available on `error.error` with stable `code`, `category`, `severity`, `retryable`, `message`, and `details` fields. +`client.invoke()` returns a typed invocation response including the +`InvocationResult`, the canonical `signedResult` proof when the local target +identity can sign it, and `signedResultVerified` from daemon-side verification. Advanced authority flows can use `AgentdClient`. `AgentdClient.health()` reads `GET /health` and returns typed authority-store and local-state-store status, including the SQLite snapshot path when the daemon exposes it. diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 0017555..d296acc 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -1,4 +1,11 @@ -import { isErrorEnvelope, type ErrorEnvelope } from '@fides/core' +import { + isErrorEnvelope, + type ErrorEnvelope, + type InvocationRequest, + type InvocationResult, + type SessionGrantV2, + type SignedInvocationResult, +} from '@fides/core' export interface FidesClientOptions { daemonUrl: string @@ -72,6 +79,23 @@ export interface FidesDhtPublishRequest { expiresAt?: string } +export interface FidesInvocationRequest { + sessionId?: string + session_id?: string + input?: unknown + dryRun?: boolean +} + +export interface FidesInvocationResponse { + authorityGranted: boolean + session: SessionGrantV2 + request: InvocationRequest + preflight: Record + result: InvocationResult + signedResult?: SignedInvocationResult + signedResultVerified?: boolean +} + export class FidesClientError extends Error { readonly name = 'FidesClientError' @@ -215,8 +239,8 @@ export class FidesClient { constructor(private readonly options: FidesClientOptions) {} - invoke(body: Record): Promise { - return this.post('/invoke', body) + invoke(body: FidesInvocationRequest): Promise { + return this.post('/invoke', body) as Promise } private async get(path: string): Promise { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e2f9591..0a80d56 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -146,6 +146,8 @@ export { type FidesRegistryPublishRequest, type FidesRelayRegisterRequest, type FidesDhtPublishRequest, + type FidesInvocationRequest, + type FidesInvocationResponse, } from './fides-client.js' // Integration exports diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 9840cd6..1606b30 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -55,8 +55,10 @@ import { signAgentCard, signDHTPointerRecord, signRegistryIndexRecord, + signInvocationResult, validateAgentCard, verifyDHTPointerRecord, + verifySignedInvocationResult, verifySignedRegistryIndexRecord, verifySignedAgentCard, verifyDelegationTokenSignature, @@ -1605,6 +1607,11 @@ app.post('/invoke', async (c) => { errorCode: preflight.can_execute ? undefined : preflight.reason_codes[0], evidenceRefs: [invokedEvidence.event_id, completedEvidence.event_id], }) + const targetIdentity = localIdentities.get(record.session.target_agent_id) + const signedResult = targetIdentity + ? await signInvocationResult(result, Buffer.from(targetIdentity.privateKeyHex, 'hex'), record.session.target_agent_id) + : undefined + const signedResultVerified = signedResult ? await verifySignedInvocationResult(signedResult) : false return c.json({ authorityGranted: preflight.can_execute, @@ -1612,6 +1619,8 @@ app.post('/invoke', async (c) => { request, preflight, result, + signedResult, + signedResultVerified, }) }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index ed37c3a..88d3a85 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -768,6 +768,10 @@ describe('Agentd Service Routes', () => { expect(invocationData.result.status).toBe('completed') expect(invocationData.authorityGranted).toBe(true) expect(invocationData.result.evidence_refs).toHaveLength(2) + expect(invocationData.signedResult.payload).toEqual(invocationData.result) + expect(invocationData.signedResult.proof.proofPurpose).toBe('capabilityInvocation') + expect(invocationData.signedResult.proof.verificationMethod).toBe(identity.did) + expect(invocationData.signedResultVerified).toBe(true) const evidence = await app.request('/evidence') expect(evidence.status).toBe(200) From 173712a442208c3a3fc8ee0ff0ab9fb44df51831 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 10:48:01 +0300 Subject: [PATCH 061/282] feat(invocation): verify signed invocation requests --- docs/api-reference.md | 19 +++--- docs/protocol/delegation-and-sessions.md | 11 +-- docs/sdk-reference.md | 9 ++- packages/sdk/src/fides-client.ts | 4 ++ services/agentd/src/index.ts | 49 +++++++++++++- services/agentd/test/routes.test.ts | 85 ++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 16 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index cf32178..e5308db 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -186,15 +186,18 @@ invocation. returns `authorityGranted: false`; it must still be signed and converted into a policy-checked SessionGrant before invocation. `POST /sessions` issues a local `SessionGrant` only after policy allows or limits the action to dry-run. -`POST /invoke` verifies the session, runs the policy preflight path, validates -the capability context, and returns an `InvocationResult` plus a canonical +`POST /invoke` verifies the session, verifies an optional caller-supplied +canonical `signedRequest`, runs the policy preflight path, validates the +capability context, and returns an `InvocationResult` plus a canonical `signedResult` proof from the target agent identity when the target is locally -managed. Invocation state and result evidence are persisted in the local daemon -snapshot when SQLite state is enabled; normalized durable invocation tables -remain follow-up hardening work. Session issuance and invocation failures -return a stable `ErrorEnvelope` on the `error` field for policy denial, -approval required, active revocation, active kill switch, missing capability, -missing session, expired session, and invalid session-scope cases. +managed. A supplied signed request must verify and match the session, input +hash, and dry-run mode before execution. Invocation state and result evidence +are persisted in the local daemon snapshot when SQLite state is enabled; +normalized durable invocation tables remain follow-up hardening work. Session +issuance and invocation failures return a stable `ErrorEnvelope` on the +`error` field for policy denial, approval required, active revocation, active +kill switch, missing capability, missing session, expired session, invalid +session-scope cases, and invalid invocation request signatures. `POST /approvals` creates an approval request and records approval decisions through `/approvals/:id/approve` or `/approvals/:id/deny`. Approval records do diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index 71c7bb8..b893b0d 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -30,9 +30,12 @@ Replay protection is required through nonce tracking. ## Invocation Binding -An invocation must bind to a scoped `SessionGrant`. The root local daemon -creates an `InvocationRequest`, performs policy preflight, emits hash-only +An invocation must bind to a scoped `SessionGrant`. The root local daemon can +accept a caller-supplied signed `InvocationRequest`; when supplied, the daemon +verifies its canonical proof and checks that it matches the session, input hash, +and dry-run mode before policy preflight. The daemon then emits hash-only evidence events, creates an `InvocationResult`, and signs that result with the target agent identity using the canonical object signing model. The signed -result is evidence that the target agent identity produced the invocation -outcome; it still does not bypass policy, revocation, or evidence verification. +request proves requester intent, and the signed result proves that the target +agent identity produced the invocation outcome; neither proof bypasses policy, +revocation, or evidence verification. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 46736e3..133378f 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -131,6 +131,7 @@ const session = await client.sessions.request({ const invocation = await client.invoke({ sessionId: session.session.session_id, input: { invoiceId: 'inv_123' }, + // signedRequest may be supplied when the requester signs an InvocationRequest. }) if (!invocation.signedResultVerified) { throw new Error('Invocation result signature did not verify') @@ -172,9 +173,11 @@ a registered local AgentCard. Failed SDK calls throw `FidesClientError`; when the daemon returns a protocol `ErrorEnvelope`, the typed envelope is available on `error.error` with stable `code`, `category`, `severity`, `retryable`, `message`, and `details` fields. -`client.invoke()` returns a typed invocation response including the -`InvocationResult`, the canonical `signedResult` proof when the local target -identity can sign it, and `signedResultVerified` from daemon-side verification. +`client.invoke()` accepts an optional signed `InvocationRequest`; if supplied, +the daemon verifies it before execution and returns `signedRequestVerified`. +The response includes the `InvocationResult`, the canonical `signedResult` +proof when the local target identity can sign it, and `signedResultVerified` +from daemon-side verification. Advanced authority flows can use `AgentdClient`. `AgentdClient.health()` reads `GET /health` and returns typed authority-store and local-state-store status, including the SQLite snapshot path when the daemon exposes it. diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index d296acc..2e87d24 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -4,6 +4,7 @@ import { type InvocationRequest, type InvocationResult, type SessionGrantV2, + type SignedInvocationRequest, type SignedInvocationResult, } from '@fides/core' @@ -84,12 +85,15 @@ export interface FidesInvocationRequest { session_id?: string input?: unknown dryRun?: boolean + signedRequest?: SignedInvocationRequest } export interface FidesInvocationResponse { authorityGranted: boolean session: SessionGrantV2 request: InvocationRequest + signedRequest?: SignedInvocationRequest + signedRequestVerified?: boolean preflight: Record result: InvocationResult signedResult?: SignedInvocationResult diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 1606b30..066569b 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -55,6 +55,7 @@ import { signAgentCard, signDHTPointerRecord, signRegistryIndexRecord, + verifySignedInvocationRequest, signInvocationResult, validateAgentCard, verifyDHTPointerRecord, @@ -83,6 +84,7 @@ import { type RevocationRecordV2, type RuntimeAttestation, type SessionGrantV2, + type SignedInvocationRequest, type SignedRegistryIndexRecord, type SignedAgentCard, type TrustResult, @@ -531,6 +533,12 @@ function policyErrorEnvelope(policy: FidesPolicyDecision) { }) } +function isSignedInvocationRequest(value: unknown): value is SignedInvocationRequest { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return Boolean(candidate.payload && typeof candidate.payload === 'object' && candidate.proof && typeof candidate.proof === 'object') +} + async function verifyLocalRuntimeAttestation(attestationId: string | undefined, agentId: string): Promise { if (!attestationId) return undefined const attestation = localRuntimeAttestations.get(attestationId) @@ -1553,7 +1561,7 @@ app.post('/invoke', async (c) => { }), sessionId, authorityGranted: false }, 404) } - const request = createInvocationRequest({ + let request = createInvocationRequest({ issuer: record.session.requester_agent_id, sessionGrant: record.session, input: body.input ?? {}, @@ -1561,6 +1569,43 @@ app.post('/invoke', async (c) => { inputSchema: found.capability.inputSchema, outputSchema: found.capability.outputSchema, }) + let signedRequest: SignedInvocationRequest | undefined + let signedRequestVerified = false + if (body.signedRequest !== undefined) { + if (!isSignedInvocationRequest(body.signedRequest)) { + return c.json({ error: createErrorEnvelope('IDENTITY_INVALID_SIGNATURE', { + message: 'signedRequest must be a canonical signed InvocationRequest', + }), authorityGranted: false }, 400) + } + + const candidateSignedRequest = body.signedRequest + signedRequest = candidateSignedRequest + signedRequestVerified = await verifySignedInvocationRequest(candidateSignedRequest) + const signedPayload = candidateSignedRequest.payload + const expectedInputHash = hashProtocolPayload(body.input ?? {}) + const expectedDryRun = typeof body.dryRun === 'boolean' ? body.dryRun : false + const payloadMatchesSession = signedPayload.session_id === record.session.session_id && + signedPayload.requester_agent_id === record.session.requester_agent_id && + signedPayload.target_agent_id === record.session.target_agent_id && + signedPayload.principal_id === record.session.principal_id && + signedPayload.capability === record.session.capability && + signedPayload.input_hash === expectedInputHash && + signedPayload.dry_run === expectedDryRun + + if (!signedRequestVerified || !payloadMatchesSession) { + return c.json({ error: createErrorEnvelope('IDENTITY_INVALID_SIGNATURE', { + message: 'Signed invocation request failed verification or does not match the session and input', + details: { + signedRequestVerified, + payloadMatchesSession, + sessionId, + request_id: signedPayload.id, + }, + }), authorityGranted: false }, 401) + } + + request = signedPayload + } const preflight = evaluateInvocationPreflight({ request, policyDecision: record.policy, @@ -1617,6 +1662,8 @@ app.post('/invoke', async (c) => { authorityGranted: preflight.can_execute, session: record.session, request, + signedRequest, + signedRequestVerified, preflight, result, signedResult, diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 88d3a85..ad1a4fd 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -45,10 +45,13 @@ vi.mock('node:dns/promises', () => ({ import { app } from '../src/index.js' import { createDelegationToken, + createIdentityKeyPair, createIncidentRecord, + createInvocationRequest, createRevocationRecord, signDelegationToken, signIncidentRecord, + signInvocationRequest, signRevocationRecord, } from '@fides/core' import * as ed from '@noble/ed25519' @@ -791,6 +794,88 @@ describe('Agentd Service Routes', () => { ])) }) + it('verifies caller-supplied signed invocation requests before execution', async () => { + const requester = await createIdentityKeyPair() + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Signed Invoice Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ + id: 'invoice.signed_reconcile', + riskLevel: 'medium', + requiredScopes: ['invoice:read'], + }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const session = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: requester.did, + agentId: identity.did, + capability: 'invoice.signed_reconcile', + requestedScopes: ['invoice:read'], + }), + }) + expect(session.status).toBe(201) + const sessionData = await session.json() + const input = { invoiceId: 'inv_signed' } + const request = createInvocationRequest({ + issuer: requester.did, + sessionGrant: sessionData.session, + input, + }) + const signedRequest = await signInvocationRequest(request, requester.privateKey, requester.did) + + const accepted = await app.request('/invoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionData.session.session_id, + input, + signedRequest, + }), + }) + expect(accepted.status).toBe(200) + const acceptedData = await accepted.json() + expect(acceptedData.signedRequestVerified).toBe(true) + expect(acceptedData.request).toEqual(request) + expect(acceptedData.signedResultVerified).toBe(true) + + const rejected = await app.request('/invoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionData.session.session_id, + input: { invoiceId: 'inv_tampered' }, + signedRequest, + }), + }) + expect(rejected.status).toBe(401) + await expect(rejected.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'IDENTITY_INVALID_SIGNATURE', + category: 'identity', + }, + }) + }) + it('returns typed error envelopes for root session and invocation failures', async () => { const missingCapability = await app.request('/sessions', { method: 'POST', From 5f65a1b9a8440d7fb5024ff2362d6f85de74f1d4 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 10:52:31 +0300 Subject: [PATCH 062/282] feat(invocation): enforce capability schemas --- docs/api-reference.md | 21 +++--- docs/protocol/capability-ontology.md | 5 ++ docs/protocol/delegation-and-sessions.md | 15 ++-- packages/core/src/invocation.ts | 82 +++++++++++++++++++++ packages/core/test/invocation.test.ts | 31 ++++++++ services/agentd/src/index.ts | 74 ++++++++++++++++++- services/agentd/test/routes.test.ts | 90 ++++++++++++++++++++++++ 7 files changed, 300 insertions(+), 18 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index e5308db..1d75649 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -188,16 +188,17 @@ policy-checked SessionGrant before invocation. `POST /sessions` issues a local `SessionGrant` only after policy allows or limits the action to dry-run. `POST /invoke` verifies the session, verifies an optional caller-supplied canonical `signedRequest`, runs the policy preflight path, validates the -capability context, and returns an `InvocationResult` plus a canonical -`signedResult` proof from the target agent identity when the target is locally -managed. A supplied signed request must verify and match the session, input -hash, and dry-run mode before execution. Invocation state and result evidence -are persisted in the local daemon snapshot when SQLite state is enabled; -normalized durable invocation tables remain follow-up hardening work. Session -issuance and invocation failures return a stable `ErrorEnvelope` on the -`error` field for policy denial, approval required, active revocation, active -kill switch, missing capability, missing session, expired session, invalid -session-scope cases, and invalid invocation request signatures. +capability context, validates input/output schemas for the advertised +capability, and returns an `InvocationResult` plus a canonical `signedResult` +proof from the target agent identity when the target is locally managed. A +supplied signed request must verify and match the session, input hash, and +dry-run mode before execution. Invocation state and result evidence are +persisted in the local daemon snapshot when SQLite state is enabled; normalized +durable invocation tables remain follow-up hardening work. Session issuance and +invocation failures return a stable `ErrorEnvelope` on the `error` field for +policy denial, approval required, active revocation, active kill switch, missing +capability, missing session, expired session, invalid session-scope cases, +invalid invocation request signatures, and capability schema violations. `POST /approvals` creates an approval request and records approval decisions through `/approvals/:id/approve` or `/approvals/:id/deny`. Approval records do diff --git a/docs/protocol/capability-ontology.md b/docs/protocol/capability-ontology.md index 1154abc..9005a90 100644 --- a/docs/protocol/capability-ontology.md +++ b/docs/protocol/capability-ontology.md @@ -21,6 +21,11 @@ Current implementation anchor: - human approval support - policy proof support +Invocation treats `inputSchema` and `outputSchema` as enforceable authority +checks, not just descriptive metadata. The local daemon rejects inputs that do +not satisfy the advertised `inputSchema`, and fails the invocation if generated +output does not satisfy `outputSchema`. + ## Seed Capabilities The seed ontology includes calendar, invoice, payments, code, file, and deploy capabilities. diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index b893b0d..ae885f6 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -33,9 +33,12 @@ Replay protection is required through nonce tracking. An invocation must bind to a scoped `SessionGrant`. The root local daemon can accept a caller-supplied signed `InvocationRequest`; when supplied, the daemon verifies its canonical proof and checks that it matches the session, input hash, -and dry-run mode before policy preflight. The daemon then emits hash-only -evidence events, creates an `InvocationResult`, and signs that result with the -target agent identity using the canonical object signing model. The signed -request proves requester intent, and the signed result proves that the target -agent identity produced the invocation outcome; neither proof bypasses policy, -revocation, or evidence verification. +and dry-run mode before policy preflight. The daemon validates the request body +against the capability input schema before execution and validates generated +outputs against the capability output schema before returning a successful +result. The daemon then emits hash-only evidence events, creates an +`InvocationResult`, and signs that result with the target agent identity using +the canonical object signing model. The signed request proves requester intent, +and the signed result proves that the target agent identity produced the +invocation outcome; neither proof bypasses policy, revocation, schema, or +evidence verification. diff --git a/packages/core/src/invocation.ts b/packages/core/src/invocation.ts index ecd6d84..eb18afc 100644 --- a/packages/core/src/invocation.ts +++ b/packages/core/src/invocation.ts @@ -1,5 +1,6 @@ import { signObject, verifyObject, type SignedObject } from './canonical-signer.js' import { hashProtocolPayload } from './protocol.js' +import type { JSONSchema } from './capability.js' import type { SessionGrantV2 } from './delegation.js' export type InvocationStatus = @@ -81,6 +82,11 @@ export interface InvocationPreflightResult { reason_codes: string[] } +export interface SchemaValidationResult { + valid: boolean + errors: string[] +} + export function createInvocationRequest(input: InvocationRequestInput): InvocationRequest { const payload = { schema_version: 'fides.invocation.request.v1' as const, @@ -157,6 +163,82 @@ export function evaluateInvocationPreflight(input: InvocationPreflightInput): In } } +export function validateJsonSchemaValue(schema: JSONSchema | undefined, value: unknown): SchemaValidationResult { + if (!schema) return { valid: true, errors: [] } + const errors: string[] = [] + validateAgainstSchema(schema, value, '$', errors) + return { valid: errors.length === 0, errors } +} + +function validateAgainstSchema(schema: JSONSchema, value: unknown, path: string, errors: string[]): void { + if (schema.const !== undefined && !Object.is(value, schema.const)) { + errors.push(`${path} must equal ${JSON.stringify(schema.const)}`) + } + + if (Array.isArray(schema.enum) && !schema.enum.some(item => Object.is(item, value))) { + errors.push(`${path} must be one of ${schema.enum.map(item => JSON.stringify(item)).join(', ')}`) + } + + if (schema.type && !matchesJsonSchemaType(value, schema.type)) { + errors.push(`${path} must be ${schema.type}`) + return + } + + if (schema.type === 'object') { + if (!value || typeof value !== 'object' || Array.isArray(value)) return + const objectValue = value as Record + for (const key of schema.required ?? []) { + if (!(key in objectValue)) { + errors.push(`${path}.${key} is required`) + } + } + + const properties = schema.properties ?? {} + for (const [key, propertySchema] of Object.entries(properties)) { + if (key in objectValue && isJsonSchema(propertySchema)) { + validateAgainstSchema(propertySchema, objectValue[key], `${path}.${key}`, errors) + } + } + + if (schema.additionalProperties === false) { + for (const key of Object.keys(objectValue)) { + if (!(key in properties)) { + errors.push(`${path}.${key} is not allowed`) + } + } + } + } + + if (schema.type === 'array' && Array.isArray(value) && isJsonSchema(schema.items)) { + value.forEach((item, index) => validateAgainstSchema(schema.items as JSONSchema, item, `${path}[${index}]`, errors)) + } +} + +function matchesJsonSchemaType(value: unknown, type: string): boolean { + switch (type) { + case 'object': + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) + case 'array': + return Array.isArray(value) + case 'string': + return typeof value === 'string' + case 'number': + return typeof value === 'number' && Number.isFinite(value) + case 'integer': + return typeof value === 'number' && Number.isInteger(value) + case 'boolean': + return typeof value === 'boolean' + case 'null': + return value === null + default: + return true + } +} + +function isJsonSchema(value: unknown): value is JSONSchema { + return Boolean(value && typeof value === 'object' && !Array.isArray(value) && typeof (value as { type?: unknown }).type === 'string') +} + export function signInvocationRequest( request: InvocationRequest, privateKey: Uint8Array, diff --git a/packages/core/test/invocation.test.ts b/packages/core/test/invocation.test.ts index 86721f5..a810d2b 100644 --- a/packages/core/test/invocation.test.ts +++ b/packages/core/test/invocation.test.ts @@ -7,6 +7,7 @@ import { evaluateInvocationPreflight, signInvocationRequest, signInvocationResult, + validateJsonSchemaValue, verifySignedInvocationRequest, verifySignedInvocationResult, } from '../src/invocation.js' @@ -81,4 +82,34 @@ describe('invocation protocol objects', () => { const signed = await signInvocationResult(result, target.privateKey, target.did) expect(await verifySignedInvocationResult(signed)).toBe(true) }) + + it('validates invocation inputs and outputs against a JSON Schema subset', () => { + const schema = { + type: 'object', + required: ['invoiceId', 'amount'], + additionalProperties: false, + properties: { + invoiceId: { type: 'string' }, + amount: { type: 'number' }, + dryRun: { type: 'boolean' }, + }, + } + + expect(validateJsonSchemaValue(schema, { + invoiceId: 'inv_123', + amount: 42, + dryRun: true, + })).toEqual({ valid: true, errors: [] }) + + const invalid = validateJsonSchemaValue(schema, { + invoiceId: 123, + unexpected: true, + }) + expect(invalid.valid).toBe(false) + expect(invalid.errors).toEqual(expect.arrayContaining([ + '$.amount is required', + '$.invoiceId must be string', + '$.unexpected is not allowed', + ])) + }) }) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 066569b..ee46e05 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -65,6 +65,7 @@ import { verifyDelegationTokenSignature, verifyDomainDid, evaluateInvocationPreflight, + validateJsonSchemaValue, verifyIncidentRecord, verifyRevocationRecord, type AgentIdentity, @@ -1561,6 +1562,18 @@ app.post('/invoke', async (c) => { }), sessionId, authorityGranted: false }, 404) } + const inputValidation = validateJsonSchemaValue(found.capability.inputSchema, body.input ?? {}) + if (!inputValidation.valid) { + return c.json({ error: createErrorEnvelope('CAPABILITY_SCHEMA_INVALID', { + message: 'Invocation input does not satisfy the capability input schema', + details: { + sessionId, + capability: record.session.capability, + errors: inputValidation.errors, + }, + }), authorityGranted: false }, 400) + } + let request = createInvocationRequest({ issuer: record.session.requester_agent_id, sessionGrant: record.session, @@ -1610,6 +1623,63 @@ app.post('/invoke', async (c) => { request, policyDecision: record.policy, }) + const output = preflight.can_execute ? { ok: true, capability: record.session.capability } : undefined + const outputValidation = output === undefined + ? { valid: true, errors: [] } + : validateJsonSchemaValue(found.capability.outputSchema, output) + if (!outputValidation.valid) { + const failedEvidence = appendRootEvidence({ + type: 'capability.failed', + actor: record.session.target_agent_id, + subject: record.session.requester_agent_id, + principal: record.session.principal_id, + capability: record.session.capability, + policy_hash: record.session.policy_hash, + decision: 'failed', + privacy_mode: 'hash_only', + metadata: { + session_id: record.session.session_id, + invocation_request_id: request.id, + schema_errors: outputValidation.errors, + }, + }) + const failedResult = createInvocationResult({ + issuer: record.session.target_agent_id, + invocationRequestId: request.id, + status: 'failed', + errorCode: 'CAPABILITY_SCHEMA_INVALID', + evidenceRefs: [failedEvidence.event_id], + }) + const targetIdentity = localIdentities.get(record.session.target_agent_id) + const signedFailedResult = targetIdentity + ? await signInvocationResult(failedResult, Buffer.from(targetIdentity.privateKeyHex, 'hex'), record.session.target_agent_id) + : undefined + + return c.json({ + authorityGranted: false, + session: record.session, + request, + signedRequest, + signedRequestVerified, + preflight: { + ...preflight, + status: 'failed', + can_execute: false, + reason_codes: [...preflight.reason_codes, 'CAPABILITY_SCHEMA_INVALID'], + }, + result: failedResult, + signedResult: signedFailedResult, + signedResultVerified: signedFailedResult ? await verifySignedInvocationResult(signedFailedResult) : false, + error: createErrorEnvelope('CAPABILITY_SCHEMA_INVALID', { + message: 'Invocation output does not satisfy the capability output schema', + details: { + sessionId, + capability: record.session.capability, + errors: outputValidation.errors, + }, + }), + }, 422) + } const invokedEvidence = appendRootEvidence({ type: 'capability.invoked', actor: record.session.requester_agent_id, @@ -1633,7 +1703,7 @@ app.post('/invoke', async (c) => { subject: record.session.requester_agent_id, principal: record.session.principal_id, capability: record.session.capability, - output: preflight.can_execute ? { ok: true, capability: record.session.capability } : undefined, + output, policy_hash: record.session.policy_hash, decision: status, privacy_mode: 'hash_only', @@ -1648,7 +1718,7 @@ app.post('/invoke', async (c) => { issuer: record.session.target_agent_id, invocationRequestId: request.id, status, - output: preflight.can_execute ? { ok: true, capability: record.session.capability } : undefined, + output, errorCode: preflight.can_execute ? undefined : preflight.reason_codes[0], evidenceRefs: [invokedEvidence.event_id, completedEvidence.event_id], }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index ad1a4fd..9a066af 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -876,6 +876,96 @@ describe('Agentd Service Routes', () => { }) }) + it('rejects invocation inputs and outputs that do not satisfy capability schemas', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Schema Invoice Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ + id: 'invoice.schema_reconcile', + riskLevel: 'medium', + requiredScopes: ['invoice:read'], + inputSchema: { + type: 'object', + required: ['invoiceId'], + properties: { invoiceId: { type: 'string' } }, + additionalProperties: false, + }, + outputSchema: { + type: 'object', + required: ['resultId'], + properties: { resultId: { type: 'string' } }, + additionalProperties: false, + }, + }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const session = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.did, + capability: 'invoice.schema_reconcile', + requestedScopes: ['invoice:read'], + }), + }) + expect(session.status).toBe(201) + const sessionData = await session.json() + + const invalidInput = await app.request('/invoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionData.session.session_id, + input: { invoiceId: 123, unexpected: true }, + }), + }) + expect(invalidInput.status).toBe(400) + await expect(invalidInput.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'CAPABILITY_SCHEMA_INVALID', + details: { + errors: expect.arrayContaining([ + '$.invoiceId must be string', + '$.unexpected is not allowed', + ]), + }, + }, + }) + + const invalidOutput = await app.request('/invoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionData.session.session_id, + input: { invoiceId: 'inv_123' }, + }), + }) + expect(invalidOutput.status).toBe(422) + const invalidOutputData = await invalidOutput.json() + expect(invalidOutputData.authorityGranted).toBe(false) + expect(invalidOutputData.error.code).toBe('CAPABILITY_SCHEMA_INVALID') + expect(invalidOutputData.result.status).toBe('failed') + expect(invalidOutputData.signedResultVerified).toBe(true) + }) + it('returns typed error envelopes for root session and invocation failures', async () => { const missingCapability = await app.request('/sessions', { method: 'POST', From 10eb7de08cf0537e6ea9eec35ea985e95fadd8c4 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:00:32 +0300 Subject: [PATCH 063/282] feat(sdk): add signed invocation helper --- docs/sdk-reference.md | 16 ++++++ packages/sdk/src/fides-client.ts | 44 +++++++++++++++ packages/sdk/test/fides-client.test.ts | 77 ++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 133378f..47c047c 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -175,6 +175,22 @@ on `error.error` with stable `code`, `category`, `severity`, `retryable`, `message`, and `details` fields. `client.invoke()` accepts an optional signed `InvocationRequest`; if supplied, the daemon verifies it before execution and returns `signedRequestVerified`. +`client.invokeSigned()` creates that canonical `InvocationRequest`, signs it +with the requester key, and submits it with the session id and input: + +```ts +const invocation = await client.invokeSigned({ + sessionGrant: session.session, + input: { invoiceId: 'inv_123' }, + privateKey: requesterPrivateKey, + inputSchema: { + type: 'object', + required: ['invoiceId'], + properties: { invoiceId: { type: 'string' } }, + }, +}) +``` + The response includes the `InvocationResult`, the canonical `signedResult` proof when the local target identity can sign it, and `signedResultVerified` from daemon-side verification. diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 2e87d24..009eef3 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -1,5 +1,7 @@ import { + createInvocationRequest, isErrorEnvelope, + signInvocationRequest, type ErrorEnvelope, type InvocationRequest, type InvocationResult, @@ -88,6 +90,17 @@ export interface FidesInvocationRequest { signedRequest?: SignedInvocationRequest } +export interface FidesSignedInvocationRequest { + sessionGrant: SessionGrantV2 + input?: unknown + dryRun?: boolean + privateKey: Uint8Array | string + verificationMethod?: string + inputSchema?: unknown + outputSchema?: unknown + issuedAt?: string +} + export interface FidesInvocationResponse { authorityGranted: boolean session: SessionGrantV2 @@ -247,6 +260,29 @@ export class FidesClient { return this.post('/invoke', body) as Promise } + async invokeSigned(body: FidesSignedInvocationRequest): Promise { + const request = createInvocationRequest({ + issuer: body.sessionGrant.requester_agent_id, + sessionGrant: body.sessionGrant, + input: body.input ?? {}, + dryRun: body.dryRun, + inputSchema: body.inputSchema, + outputSchema: body.outputSchema, + issuedAt: body.issuedAt, + }) + const signedRequest = await signInvocationRequest( + request, + privateKeyBytes(body.privateKey), + body.verificationMethod ?? body.sessionGrant.requester_agent_id + ) + return this.invoke({ + sessionId: body.sessionGrant.session_id, + input: body.input, + dryRun: body.dryRun, + signedRequest, + }) + } + private async get(path: string): Promise { return this.request(path, { method: 'GET' }) } @@ -290,3 +326,11 @@ function extractErrorEnvelope(payload: unknown): ErrorEnvelope | undefined { const error = (payload as { error?: unknown }).error return isErrorEnvelope(error) ? error : undefined } + +function privateKeyBytes(key: Uint8Array | string): Uint8Array { + const bytes = typeof key === 'string' ? Uint8Array.from(Buffer.from(key, 'hex')) : key + if (bytes.length !== 32) { + throw new FidesClientError('Ed25519 private key must be 32 bytes', 0, {}, undefined) + } + return bytes +} diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 5db9930..ba0cf7e 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { createAgentIdentity, verifySignedInvocationRequest, type SessionGrantV2 } from '@fides/core' import { FidesClient, FidesClientError } from '../src/fides-client.js' afterEach(() => { @@ -297,6 +298,82 @@ describe('FidesClient', () => { } }) + it('creates and submits signed invocation requests from a session grant', async () => { + const requester = await createAgentIdentity() + const sessionGrant: SessionGrantV2 = { + schema_version: 'fides.session_grant.v1', + session_id: 'sess_signed', + requester_agent_id: requester.identity.did, + target_agent_id: 'did:fides:target', + principal_id: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['read:invoices'], + constraints: { invoiceId: 'inv_123' }, + policy_hash: 'sha256:policy', + trust_result_hash: 'sha256:trust', + issued_at: '2026-05-30T00:00:00.000Z', + expires_at: '2026-05-30T01:00:00.000Z', + nonce: 'nonce_signed', + audience: ['did:fides:target'], + issuer: requester.identity.did, + payload_hash: 'sha256:session', + } + const calls: Array<{ url: string; init?: RequestInit }> = [] + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) + return new Response(JSON.stringify({ + authorityGranted: true, + session: sessionGrant, + request: { id: 'inv_req_1' }, + signedRequestVerified: true, + preflight: { status: 'allowed' }, + result: { status: 'completed' }, + signedResultVerified: true, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + await expect(client.invokeSigned({ + sessionGrant, + input: { invoiceId: 'inv_123' }, + privateKey: requester.privateKey, + inputSchema: { + type: 'object', + required: ['invoiceId'], + properties: { invoiceId: { type: 'string' } }, + }, + })).resolves.toMatchObject({ + authorityGranted: true, + signedRequestVerified: true, + }) + + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe('http://localhost:7345/invoke') + const body = JSON.parse(calls[0].init?.body as string) + expect(body).toMatchObject({ + sessionId: 'sess_signed', + input: { invoiceId: 'inv_123' }, + signedRequest: { + payload: { + schema_version: 'fides.invocation.request.v1', + issuer: requester.identity.did, + session_id: 'sess_signed', + requester_agent_id: requester.identity.did, + target_agent_id: 'did:fides:target', + principal_id: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['read:invoices'], + dry_run: false, + }, + proof: { + verificationMethod: requester.identity.did, + proofPurpose: 'capabilityInvocation', + }, + }, + }) + await expect(verifySignedInvocationRequest(body.signedRequest)).resolves.toBe(true) + }) + it('uses the root AgentCard API served by local agentd', async () => { const calls: Array<{ url: string; init?: RequestInit }> = [] vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { From 34e69e759e801595b55fccfb7fefdfc79b5181e0 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:02:59 +0300 Subject: [PATCH 064/282] feat(cli): add invoke command --- docs/cli-reference.md | 11 ++++ packages/cli/src/commands/invoke.ts | 78 +++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/test/commands.test.ts | 57 ++++++++++++++++++++- 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/invoke.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 39edc0a..e2f28ba 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -16,6 +16,7 @@ Current implementation anchors: - `trust` - `policy` - `session` +- `invoke` - `authorize` - `runtime` - `revoke` @@ -53,6 +54,9 @@ agentd relay register did:fides:... agentd relay discover --capability invoice.reconcile --supported-versions fides.v2.0 agentd dht publish --capability invoice.reconcile --agent-id did:fides:... agentd dht find --capability invoice.reconcile +agentd invoke did:fides:... --capability invoice.reconcile --input invoice.json --requested-scopes invoice:read +agentd invoke --session-id sess_... --input invoice.json +agentd invoke --dry-run did:fides:... --capability payments.prepare --input payment.json agentd evidence verify agentd daemon status ``` @@ -78,6 +82,13 @@ publish an external pointer from an AgentCard path/URL, or publish a signed local pointer without a URL by passing `--agent-id` or `--agent-card-id` with `--capability`. +`invoke` always goes through the authority path. With `--session-id`, it calls +`POST /invoke` directly. With ` --capability`, it first requests a +policy-checked `SessionGrant` from `POST /sessions`, then invokes that session. +Input defaults to `{}` and can be supplied with `--input` or `--input-json`. +Use `--dry-run` to request dry-run execution; discovery is never treated as +authority by this command. + `daemon status` calls `GET /health` and prints upstream checks, the authority store, and the root v2 local state store. When SQLite local state is enabled, the status output includes the SQLite path used for the daemon snapshot. diff --git a/packages/cli/src/commands/invoke.ts b/packages/cli/src/commands/invoke.ts new file mode 100644 index 0000000..d411be4 --- /dev/null +++ b/packages/cli/src/commands/invoke.ts @@ -0,0 +1,78 @@ +import { readFileSync } from 'node:fs' +import { Command } from 'commander' +import { parseList, parseJsonObject, postJson, printResult } from './authority-utils.js' + +export function createInvokeCommand(): Command { + return new Command('invoke') + .description('Invoke a capability through the local agentd authority path') + .argument('[agentId]', 'Target agent DID when creating a session first') + .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') + .option('--session-id ', 'Existing SessionGrant ID') + .option('--capability ', 'Capability ID when creating a session first') + .option('--input ', 'Invocation input JSON file') + .option('--input-json ', 'Invocation input JSON object') + .option('--dry-run', 'Request dry-run execution') + .option('--principal-id ', 'Principal DID for session creation') + .option('--requester-agent-id ', 'Requester agent DID for session creation') + .option('--requested-scopes ', 'Comma-separated requested scopes for session creation') + .option('--constraints-json ', 'Session constraint JSON object') + .option('--attestation-id ', 'Runtime attestation ID for session policy') + .option('--approval-granted', 'Mark approval as granted for session policy') + .option('--json', 'Print JSON only') + .action(async (agentId, options) => { + try { + const input = readInput(options) + const baseUrl = options.agentdUrl.replace(/\/$/, '') + const sessionId = options.sessionId ?? await createSession(baseUrl, agentId, options) + const result = await postJson(`${baseUrl}/invoke`, { + sessionId, + input, + ...(options.dryRun && { dryRun: true }), + }) + printResult('Invocation result:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exit(1) + } + }) +} + +function readInput(options: { input?: string; inputJson?: string }): unknown { + if (options.input) return JSON.parse(readFileSync(options.input, 'utf-8')) + if (options.inputJson) return JSON.parse(options.inputJson) + return {} +} + +async function createSession(baseUrl: string, agentId: string | undefined, options: { + capability?: string + principalId?: string + requesterAgentId?: string + requestedScopes?: string + constraintsJson?: string + attestationId?: string + approvalGranted?: boolean +}): Promise { + if (!agentId || !options.capability) { + throw new Error('Either --session-id or with --capability is required') + } + + const response = await postJson(`${baseUrl}/sessions`, { + agentId, + capability: options.capability, + requestedScopes: parseList(options.requestedScopes), + ...(options.principalId && { principalId: options.principalId }), + ...(options.requesterAgentId && { requesterAgentId: options.requesterAgentId }), + ...(options.constraintsJson && { constraints: parseJsonObject(options.constraintsJson) }), + ...(options.attestationId && { attestationId: options.attestationId }), + ...(options.approvalGranted && { approvalGranted: true }), + }) + + if (!response || typeof response !== 'object') { + throw new Error('Session response was not an object') + } + const session = (response as { session?: { session_id?: unknown } }).session + if (typeof session?.session_id !== 'string') { + throw new Error('Session response did not include session.session_id') + } + return session.session_id +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8d55fc4..a3247fd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -25,6 +25,7 @@ import { createDhtCommand } from './commands/dht.js'; import { createEvidenceCommand } from './commands/evidence.js'; import { createDemoCommand } from './commands/demo.js'; import { createSimulateCommand } from './commands/simulate.js'; +import { createInvokeCommand } from './commands/invoke.js'; import { inferCliName } from './cli-name.js'; import packageJson from '../package.json' with { type: 'json' }; @@ -60,5 +61,6 @@ program.addCommand(createDhtCommand()); program.addCommand(createEvidenceCommand()); program.addCommand(createDemoCommand()); program.addCommand(createSimulateCommand()); +program.addCommand(createInvokeCommand()); program.parse(); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 1c4f206..7fd7733 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -162,18 +162,73 @@ describe('CLI Commands', () => { }); describe('v2 command surface', () => { - it('exposes registry, dht, evidence, demo, and simulate commands', async () => { + it('exposes registry, dht, evidence, demo, simulate, and invoke commands', async () => { const { createRegistryCommand } = await import('../src/commands/registry.js'); const { createDhtCommand } = await import('../src/commands/dht.js'); const { createEvidenceCommand } = await import('../src/commands/evidence.js'); const { createDemoCommand } = await import('../src/commands/demo.js'); const { createSimulateCommand } = await import('../src/commands/simulate.js'); + const { createInvokeCommand } = await import('../src/commands/invoke.js'); expect(createRegistryCommand().name()).toBe('registry'); expect(createDhtCommand().name()).toBe('dht'); expect(createEvidenceCommand().name()).toBe('evidence'); expect(createDemoCommand().name()).toBe('demo'); expect(createSimulateCommand().name()).toBe('simulate'); + expect(createInvokeCommand().name()).toBe('invoke'); + }); + }); + + describe('invoke command', () => { + it('creates a session from agent and capability before invoking', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }); + if (String(url).endsWith('/sessions')) { + return new Response(JSON.stringify({ + authorityGranted: true, + session: { session_id: 'sess_cli' }, + }), { status: 201, headers: { 'Content-Type': 'application/json' } }); + } + return new Response(JSON.stringify({ + authorityGranted: true, + result: { status: 'completed' }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + })); + + const { createInvokeCommand } = await import('../src/commands/invoke.js'); + const cmd = createInvokeCommand(); + + await cmd.parseAsync([ + 'did:fides:agent', + '--capability', + 'invoice.reconcile', + '--input-json', + '{"invoiceId":"inv_123"}', + '--requested-scopes', + 'read:invoices,write:evidence', + '--principal-id', + 'did:fides:principal', + '--requester-agent-id', + 'did:fides:requester', + '--json', + ], { from: 'user' }); + + expect(calls.map(call => call.url)).toEqual([ + 'http://localhost:7345/sessions', + 'http://localhost:7345/invoke', + ]); + expect(JSON.parse(calls[0].init?.body as string)).toEqual({ + agentId: 'did:fides:agent', + capability: 'invoice.reconcile', + requestedScopes: ['read:invoices', 'write:evidence'], + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + }); + expect(JSON.parse(calls[1].init?.body as string)).toEqual({ + sessionId: 'sess_cli', + input: { invoiceId: 'inv_123' }, + }); }); }); From f79a06e3341fff8d53b63431df62e05c6317818d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:06:33 +0300 Subject: [PATCH 065/282] feat(discovery): add federation provider --- docs/protocol/registry-federation.md | 23 +++- packages/core/src/registry.ts | 8 ++ packages/core/test/registry.test.ts | 30 +++++ packages/discovery/src/federation-provider.ts | 108 ++++++++++++++++++ packages/discovery/src/index.ts | 1 + .../test/federation-provider.test.ts | 72 ++++++++++++ 6 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 packages/discovery/src/federation-provider.ts create mode 100644 packages/discovery/test/federation-provider.test.ts diff --git a/docs/protocol/registry-federation.md b/docs/protocol/registry-federation.md index 2c96c28..4b16b50 100644 --- a/docs/protocol/registry-federation.md +++ b/docs/protocol/registry-federation.md @@ -5,6 +5,7 @@ Registries publish and search signed AgentCard index records. Federation enables Current implementation anchors: - `packages/core/src/registry.ts` +- `packages/discovery/src/federation-provider.ts` - `services/registry/src/index.ts` - `services/agentd/src/index.ts` @@ -32,4 +33,24 @@ record includes: Search and discovery verify signed local registry index records before returning them. A valid registry index record still does not grant invocation authority. -Federation peering records are adapter-ready and should not imply trust. Peers provide discovery and propagation surfaces; FIDES still verifies identity, signatures, revocations, incidents, trust, and policy. +Federation peering records are adapter-ready and should not imply trust. Peers +provide discovery and propagation surfaces; FIDES still verifies identity, +signatures, revocations, incidents, trust, and policy. + +`LocalFederationDiscoveryProvider` is the local mock federation implementation. +It accepts signed `RegistryPeerRecord` values plus peer discovery providers, +verifies the peer record signature, ignores expired peers, and only queries +peers that advertise `registry_search`. Returned candidates are marked with +provider `federation`, `verified: false`, and an explanation that federation is +not authority. The provider does not publish or deregister AgentCards directly; +those operations belong to the source registry peer. + +Federated discovery flow: + +1. Load configured signed `RegistryPeerRecord` entries. +2. Reject expired or unsigned/tampered peer records. +3. Query peers that advertise `registry_search`. +4. Return candidate AgentCards with federation provenance. +5. Continue the normal FIDES pipeline: AgentCard verification, protocol version + negotiation, trust/reputation scoring, policy evaluation, scoped SessionGrant + issuance, and evidence recording. diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 340dd95..1f0562c 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -106,6 +106,14 @@ export function createRegistryPeerRecord(input: RegistryPeerRecordInput): Regist } } +export function isRegistryIndexRecordExpired(record: RegistryIndexRecord, now: Date = new Date()): boolean { + return record.expires_at ? new Date(record.expires_at) <= now : false +} + +export function isRegistryPeerRecordExpired(record: RegistryPeerRecord, now: Date = new Date()): boolean { + return record.expires_at ? new Date(record.expires_at) <= now : false +} + export function signRegistryIndexRecord( record: RegistryIndexRecord, privateKey: Uint8Array, diff --git a/packages/core/test/registry.test.ts b/packages/core/test/registry.test.ts index 87b4d69..8b9f239 100644 --- a/packages/core/test/registry.test.ts +++ b/packages/core/test/registry.test.ts @@ -3,6 +3,8 @@ import { createIdentityKeyPair } from '../src/identity.js' import { createRegistryIndexRecord, createRegistryPeerRecord, + isRegistryIndexRecordExpired, + isRegistryPeerRecordExpired, signRegistryIndexRecord, signRegistryPeerRecord, verifySignedRegistryIndexRecord, @@ -58,4 +60,32 @@ describe('registry and federation records', () => { const signed = await signRegistryPeerRecord(record, issuer.privateKey, issuer.did) expect(await verifySignedRegistryPeerRecord(signed)).toBe(true) }) + + it('detects expired registry and federation records', async () => { + const issuer = await createIdentityKeyPair() + const expiredAt = '2026-05-29T00:00:00.000Z' + const now = new Date('2026-05-30T00:00:00.000Z') + + expect(isRegistryIndexRecordExpired(createRegistryIndexRecord({ + issuer: issuer.did, + mode: 'hosted', + agentCardId: 'card_expired', + agentId: 'did:fides:agent', + capabilityIds: ['calendar.schedule'], + agentCardHash: 'sha256:card', + registryUrl: 'https://registry.example', + supportedVersions: ['fides.v2.0'], + expiresAt: expiredAt, + }), now)).toBe(true) + + expect(isRegistryPeerRecordExpired(createRegistryPeerRecord({ + issuer: issuer.did, + peerId: 'peer_expired', + registryUrl: 'https://peer.example', + peeringMode: 'federated', + supportedVersions: ['fides.v2.0'], + capabilities: ['registry_search'], + expiresAt: expiredAt, + }), now)).toBe(true) + }) }) diff --git a/packages/discovery/src/federation-provider.ts b/packages/discovery/src/federation-provider.ts new file mode 100644 index 0000000..0b49c0f --- /dev/null +++ b/packages/discovery/src/federation-provider.ts @@ -0,0 +1,108 @@ +import { + createDiscoveryCandidate, + isRegistryPeerRecordExpired, + verifySignedRegistryPeerRecord, + type AgentCard, + type DiscoveryCandidate, + type DiscoveryQuery, + type RegistryPeerRecord, + type SignedAgentCard, + type SignedRegistryPeerRecord, +} from '@fides/core' +import { DiscoveryProvider } from './provider.js' + +export interface FederationDiscoveryPeer { + readonly record: SignedRegistryPeerRecord + readonly provider: DiscoveryProvider +} + +export interface FederationDiscoveryProviderOptions { + peers?: FederationDiscoveryPeer[] +} + +/** + * Local mock federation provider. + * + * Federation only expands the discovery search space. Peer records and peer + * provider results are not authority; callers must still verify AgentCards, + * trust, policy, revocation, incident, and session grants before invocation. + */ +export class LocalFederationDiscoveryProvider implements DiscoveryProvider { + readonly name = 'federation' + private readonly peers = new Map() + + constructor(options: FederationDiscoveryProviderOptions = {}) { + for (const peer of options.peers ?? []) { + this.addPeer(peer) + } + } + + addPeer(peer: FederationDiscoveryPeer): void { + this.peers.set(peer.record.payload.peer_id, peer) + } + + listPeers(): RegistryPeerRecord[] { + return Array.from(this.peers.values()).map(peer => peer.record.payload) + } + + async resolve(did: string): Promise { + for (const peer of this.peers.values()) { + if (!await this.isUsablePeer(peer)) continue + const card = await peer.provider.resolve(did) + if (card) return card + } + return null + } + + async discover(query: DiscoveryQuery): Promise { + const candidates: DiscoveryCandidate[] = [] + for (const peer of this.peers.values()) { + if (!await this.isUsablePeer(peer)) continue + const peerCandidates = peer.provider.discover + ? await peer.provider.discover(query) + : await this.discoverThroughResolve(peer.provider, query) + for (const candidate of peerCandidates) { + candidates.push({ + ...candidate, + provider: this.name, + verified: false, + rank: candidate.rank - 1, + explanations: [ + `Federated peer ${peer.record.payload.peer_id} returned candidate via ${candidate.provider}; federation is not authority`, + ...candidate.explanations, + ], + }) + } + } + return candidates + .sort((a, b) => b.rank - a.rank || a.agentId.localeCompare(b.agentId)) + .slice(0, query.limit ?? candidates.length) + } + + async register(_card: SignedAgentCard): Promise { + throw new Error('Federation discovery does not publish AgentCards directly; publish to a registry peer instead') + } + + async deregister(_did: string): Promise { + throw new Error('Federation discovery does not deregister AgentCards directly; deregister from the source peer instead') + } + + private async discoverThroughResolve(provider: DiscoveryProvider, query: DiscoveryQuery): Promise { + if (!query.requester_agent_id) return [] + const card = await provider.resolve(query.requester_agent_id) + if (!card) return [] + return [createDiscoveryCandidate({ + provider: provider.name, + card, + capability: query.capability, + verified: false, + explanations: ['Resolved through federated legacy DID provider path'], + })] + } + + private async isUsablePeer(peer: FederationDiscoveryPeer): Promise { + if (isRegistryPeerRecordExpired(peer.record.payload)) return false + if (!peer.record.payload.capabilities.includes('registry_search')) return false + return verifySignedRegistryPeerRecord(peer.record) + } +} diff --git a/packages/discovery/src/index.ts b/packages/discovery/src/index.ts index 0782aa0..b0b03f2 100644 --- a/packages/discovery/src/index.ts +++ b/packages/discovery/src/index.ts @@ -5,3 +5,4 @@ export * from './local-provider.js' export * from './registry-provider.js' export * from './relay-provider.js' export * from './dht-provider.js' +export * from './federation-provider.js' diff --git a/packages/discovery/test/federation-provider.test.ts b/packages/discovery/test/federation-provider.test.ts new file mode 100644 index 0000000..0e0a3a1 --- /dev/null +++ b/packages/discovery/test/federation-provider.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + createAgentIdentity, + createCapabilityDescriptor, + createDiscoveryQuery, + createRegistryPeerRecord, + signRegistryPeerRecord, + type AgentCard, +} from '@fides/core' +import { LocalDiscoveryProvider } from '../src/local-provider.js' +import { LocalFederationDiscoveryProvider } from '../src/federation-provider.js' + +describe('LocalFederationDiscoveryProvider', () => { + async function fixture(options: { capabilities?: Array<'registry_search' | 'revocation_propagation'>; expiresAt?: string } = {}) { + const issuer = await createAgentIdentity() + const agent = await createAgentIdentity() + const card: AgentCard = { + id: agent.identity.did, + agent_id: agent.identity.did, + identity: agent.identity, + capabilities: [createCapabilityDescriptor({ id: 'invoice.reconcile' })], + endpoints: [], + policies: [{ requiresRuntimeAttestation: false, requiresApproval: false }], + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + } + const local = new LocalDiscoveryProvider({ storePath: `/tmp/fides-federation-${crypto.randomUUID()}.json` }) + local.registerCard(card) + const record = createRegistryPeerRecord({ + issuer: issuer.identity.did, + peerId: 'peer_registry', + registryUrl: 'https://peer.example', + peeringMode: 'federated', + supportedVersions: ['fides.v2.0'], + capabilities: options.capabilities ?? ['registry_search'], + expiresAt: options.expiresAt, + }) + const signed = await signRegistryPeerRecord(record, issuer.privateKey, issuer.identity.did) + return { card, local, signed } + } + + it('discovers candidates through signed federation peers without granting authority', async () => { + const { local, signed } = await fixture() + const federation = new LocalFederationDiscoveryProvider({ + peers: [{ record: signed, provider: local }], + }) + + const candidates = await federation.discover(createDiscoveryQuery({ capability: 'invoice.reconcile' })) + + expect(candidates).toHaveLength(1) + expect(candidates[0]).toMatchObject({ + provider: 'federation', + capability: 'invoice.reconcile', + verified: false, + }) + expect(candidates[0].explanations[0]).toContain('federation is not authority') + }) + + it('ignores expired peers and peers without registry search capability', async () => { + const expired = await fixture({ expiresAt: '2000-01-01T00:00:00.000Z' }) + const propagationOnly = await fixture({ capabilities: ['revocation_propagation'] }) + const federation = new LocalFederationDiscoveryProvider({ + peers: [ + { record: expired.signed, provider: expired.local }, + { record: propagationOnly.signed, provider: propagationOnly.local }, + ], + }) + + await expect(federation.discover(createDiscoveryQuery({ capability: 'invoice.reconcile' }))) + .resolves.toEqual([]) + }) +}) From 4db6268fd93f7073ac6c361bcb6cec2b8d9d7acc Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:11:20 +0300 Subject: [PATCH 066/282] feat(api): expose federation discovery --- docs/api-reference.md | 10 ++++- docs/cli-reference.md | 3 +- docs/sdk-reference.md | 16 ++++--- packages/cli/src/commands/discover.ts | 4 +- packages/sdk/src/fides-client.ts | 1 + packages/sdk/test/fides-client.test.ts | 7 ++- services/agentd/src/index.ts | 59 ++++++++++++++++++++++++++ services/agentd/test/routes.test.ts | 58 ++++++++++++++++++++++++- 8 files changed, 143 insertions(+), 15 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 1d75649..ee007a2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -26,6 +26,7 @@ Current implementation anchors: - `POST /discover/registry` - `POST /discover/relay` - `POST /discover/dht` +- `POST /discover/federation` - `POST /trust/evaluate` - `GET /trust/:agentId` - `POST /reputation/update` @@ -152,8 +153,9 @@ storage to normalized identity/card tables. candidate. `GET /agents` and `GET /agents/:id` expose local registration state and the associated AgentCard. `POST /discover` and `POST /discover/local` search registered local agents by capability. `POST /discover/well-known`, -`POST /discover/registry`, `POST /discover/relay`, and `POST /discover/dht` -expose provider-specific discovery aliases over the daemon's local state. +`POST /discover/registry`, `POST /discover/relay`, `POST /discover/dht`, and +`POST /discover/federation` expose provider-specific discovery aliases over +the daemon's local state. `POST /dht/publish` creates a signed DHT pointer when the referenced agent is registered locally; callers may omit `agentCardUrl`, in which case the daemon uses a `local://agent-cards/` pointer and signs it with the local @@ -172,6 +174,10 @@ discovery also negotiate protocol compatibility between query `protocolVersions`; incompatible candidates are omitted from provider results and reported under `rejectedCandidates`, `rejectedRecords`, or `rejectedPointers` with `VERSION_INCOMPATIBLE`. +Federation discovery wraps verified local registry records with a signed +`RegistryPeerRecord`, marks them as provider `federation`, and reports +incompatible records under `rejectedRecords`. Federation expands discovery +reach only; it is not a trust source and never grants authority. `POST /trust/evaluate` computes a local capability-scoped trust result for a registered candidate. `POST /reputation/update` stores capability-specific diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e2f28ba..37270db 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -43,6 +43,7 @@ agentd discover "reconcile invoices" --capability invoice.reconcile --provider l agentd discover --capability invoice.reconcile --provider registry --supported-versions fides.v2.0 --required-versions fides.v2.0 agentd discover --capability invoice.reconcile --provider relay --supported-versions fides.v2.0 agentd discover --capability invoice.reconcile --provider dht +agentd discover --capability invoice.reconcile --provider federation agentd discover --capability invoice.reconcile --all-providers agentd demo run agentd simulate adversarial @@ -67,7 +68,7 @@ Local identity files are stored under `~/.fides/identities` by default. Set inside the local identity file. `discover --capability` targets local `agentd` capability discovery. Use -`--provider local`, `well-known`, `registry`, `relay`, `dht`, or +`--provider local`, `well-known`, `registry`, `relay`, `dht`, `federation`, or `--all-providers` to choose the provider surface. These commands return candidates, registry records, relay presence records, or DHT pointers only; they do not grant invocation authority. Use `--supported-versions` and diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 47c047c..88514e8 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -40,6 +40,7 @@ await client.discovery.relay({ supported_versions: ['fides.v2.0'], }) await client.discovery.dht({ capability: 'invoice.reconcile' }) +await client.discovery.federation({ capability: 'invoice.reconcile' }) const trust = await client.trust.evaluate({ agentId: identity.identity.did, capability: 'invoice.reconcile', @@ -163,13 +164,14 @@ controls, with kill switch rules overriding normal policy while active. Revocation and incident helpers expose local governance records that feed root session policy decisions. Runtime attestation helpers issue and verify local MockTEE attestations that can satisfy high-risk session policy when passed as -an `attestationId`. Registry, relay, DHT, and well-known helpers expose the -local mock discovery surfaces. They return candidate records or pointers only; -they do not convert discovery into authority. Discovery, registry, and relay -helpers accept `supported_versions` and `required_versions` so callers can -request protocol compatibility filtering. `dht.publish` can publish a signed -local pointer without an AgentCard URL when `agentId` or `agentCardId` refers to -a registered local AgentCard. Failed SDK calls throw `FidesClientError`; when +an `attestationId`. Registry, relay, DHT, federation, and well-known helpers +expose the local mock discovery surfaces. They return candidate records or +pointers only; they do not convert discovery into authority. Discovery, +registry, relay, and federation helpers accept `supported_versions` and +`required_versions` so callers can request protocol compatibility filtering. +`dht.publish` can publish a signed local pointer without an AgentCard URL when +`agentId` or `agentCardId` refers to a registered local AgentCard. Failed SDK +calls throw `FidesClientError`; when the daemon returns a protocol `ErrorEnvelope`, the typed envelope is available on `error.error` with stable `code`, `category`, `severity`, `retryable`, `message`, and `details` fields. diff --git a/packages/cli/src/commands/discover.ts b/packages/cli/src/commands/discover.ts index 099acce..ac52ca1 100644 --- a/packages/cli/src/commands/discover.ts +++ b/packages/cli/src/commands/discover.ts @@ -4,7 +4,7 @@ import { loadConfig } from '../utils/config.js'; import { error, info, formatScore } from '../utils/output.js'; import { parseList, postJson, printResult } from './authority-utils.js'; -const DISCOVERY_PROVIDERS = ['local', 'well-known', 'registry', 'relay', 'dht'] as const +const DISCOVERY_PROVIDERS = ['local', 'well-known', 'registry', 'relay', 'dht', 'federation'] as const type DiscoveryProviderName = typeof DISCOVERY_PROVIDERS[number] export function createDiscoverCommand(): Command { @@ -14,7 +14,7 @@ export function createDiscoverCommand(): Command { .description('Discover agent identities or capability candidates') .argument('[agent-did-or-domain-or-intent]', 'DID/domain to resolve, or an intent when --capability is provided') .option('--capability ', 'Capability to discover, e.g. invoice.reconcile') - .option('--provider ', 'Discovery provider: local, well-known, registry, relay, dht, all', 'local') + .option('--provider ', 'Discovery provider: local, well-known, registry, relay, dht, federation, all', 'local') .option('--all-providers', 'Query all local agentd discovery providers') .option('--constraints ', 'Discovery constraints as a JSON object') .option('--supported-versions ', 'Comma-separated FIDES protocol versions supported by the requester') diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 009eef3..06542a3 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -155,6 +155,7 @@ export class FidesClient { registry: (query: FidesDiscoveryQuery): Promise => this.post('/discover/registry', query) as Promise, relay: (query: FidesDiscoveryQuery): Promise => this.post('/discover/relay', query) as Promise, dht: (query: FidesDiscoveryQuery): Promise => this.post('/discover/dht', query) as Promise, + federation: (query: FidesDiscoveryQuery): Promise => this.post('/discover/federation', query) as Promise, } readonly trust = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index ba0cf7e..d49f72d 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -36,6 +36,7 @@ describe('FidesClient', () => { supported_versions: ['fides.v2.0'], }) await client.discovery.dht({ capability: 'invoice.reconcile' }) + await client.discovery.federation({ capability: 'invoice.reconcile' }) await client.trust.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.reputation.update({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.policy.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) @@ -104,6 +105,7 @@ describe('FidesClient', () => { 'http://localhost:4817/discover/registry', 'http://localhost:4817/discover/relay', 'http://localhost:4817/discover/dht', + 'http://localhost:4817/discover/federation', 'http://localhost:4817/trust/evaluate', 'http://localhost:4817/reputation/update', 'http://localhost:4817/policy/evaluate', @@ -166,6 +168,7 @@ describe('FidesClient', () => { 'POST', 'POST', 'POST', + 'POST', 'GET', 'POST', 'POST', @@ -212,12 +215,12 @@ describe('FidesClient', () => { supported_versions: ['fides.v2.0'], required_versions: ['fides.v2.0'], }) - expect(JSON.parse(calls[36].init?.body as string)).toEqual({ + expect(JSON.parse(calls[37].init?.body as string)).toEqual({ capability: 'invoice.reconcile', supported_versions: ['fides.v2.0'], required_versions: ['fides.v2.0'], }) - expect(JSON.parse(calls[42].init?.body as string)).toEqual({ + expect(JSON.parse(calls[43].init?.body as string)).toEqual({ capability: 'invoice.reconcile', agentId: 'did:fides:agent', }) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index ee46e05..f10dcfc 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -42,6 +42,7 @@ import { createDHTPointerRecord, createErrorEnvelope, createRegistryIndexRecord, + createRegistryPeerRecord, createPrincipalIdentity, createPublisherIdentity, createRevocationRecordV2, @@ -55,12 +56,14 @@ import { signAgentCard, signDHTPointerRecord, signRegistryIndexRecord, + signRegistryPeerRecord, verifySignedInvocationRequest, signInvocationResult, validateAgentCard, verifyDHTPointerRecord, verifySignedInvocationResult, verifySignedRegistryIndexRecord, + verifySignedRegistryPeerRecord, verifySignedAgentCard, verifyDelegationTokenSignature, verifyDomainDid, @@ -87,6 +90,7 @@ import { type SessionGrantV2, type SignedInvocationRequest, type SignedRegistryIndexRecord, + type SignedRegistryPeerRecord, type SignedAgentCard, type TrustResult, type VersionNegotiationRecord, @@ -2349,6 +2353,21 @@ async function filterVerifiedLocalRegistryRecords(records: Array { + const identity = Array.from(localIdentities.values())[0] + const record = createRegistryPeerRecord({ + issuer: identity?.identity.did ?? 'did:fides:agentd:local-registry', + peerId: 'local_registry_peer', + registryUrl: 'local://registry', + peeringMode: 'federated', + supportedVersions: ['fides.v2.0'], + capabilities: ['registry_search', 'revocation_propagation', 'incident_propagation'], + }) + if (!identity) return { signed: null, verified: false } + const signed = await signRegistryPeerRecord(record, Buffer.from(identity.privateKeyHex, 'hex'), identity.identity.did) + return { signed, verified: await verifySignedRegistryPeerRecord(signed) } +} + function localRelayRecordFor(agentId: string, endpointHints: unknown[] = []) { const registered = localAgents.get(agentId) const card = registered ? localAgentCards.get(registered.cardId) : undefined @@ -2431,6 +2450,46 @@ app.post('/discover/registry', async (c) => { }) }) +app.post('/discover/federation', async (c) => { + const body = await c.req.json().catch(() => ({})) + const capability = typeof body.capability === 'string' ? body.capability : undefined + const matched = Array.from(localRegistryRecords.values()).filter((record) => ( + !capability || (record.capabilities as string[] | undefined)?.includes(capability) + )) + const verified = await filterVerifiedLocalRegistryRecords(matched) + const filtered = filterVersionCompatibleProviderRecords(body, verified.records) + const peer = await localFederationPeerRecord() + const federatedRecords = filtered.records.map((record) => ({ + ...record, + provider: 'federation', + federationPeerId: peer.signed?.payload.peer_id ?? 'local_registry_peer', + federationPeerRecord: peer.signed?.payload ?? null, + federationPeerProof: peer.signed?.proof ?? null, + federationPeerVerified: peer.verified, + authorityGranted: false, + reasons: [ + ...(Array.isArray(record.reasons) ? record.reasons.map(String) : []), + 'federation_peer_matched_capability', + 'federation_does_not_grant_authority', + ], + })) + return c.json({ + provider: 'federation', + mode: 'local_mock_federation', + capability: capability ?? null, + records: federatedRecords, + rejectedRecords: [ + ...verified.rejectedRecords, + ...filtered.rejected, + ], + federationPeerRecord: peer.signed?.payload ?? null, + federationPeerProof: peer.signed?.proof ?? null, + federationPeerVerified: peer.verified, + authorityGranted: false, + explanation: 'Federation expands discovery to registry peers only; federated results are candidates and never invocation authority.', + }) +}) + app.get('/registry/index', (c) => { return c.json({ mode: 'local_mock_registry', diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 9a066af..735a319 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -552,7 +552,7 @@ describe('Agentd Service Routes', () => { }), }) - for (const path of ['/registry/search', '/discover/registry', '/relay/discover', '/discover/relay']) { + for (const path of ['/registry/search', '/discover/registry', '/discover/federation', '/relay/discover', '/discover/relay']) { const response = await app.request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -605,6 +605,62 @@ describe('Agentd Service Routes', () => { expect(dhtData.authorityGranted).toBe(false) }) + it('returns federated registry candidates without granting authority', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Federated Invoice Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: 'invoice.federated_reconcile', requiredScopes: ['invoice:read'] }], + endpoints: [], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + await app.request('/registry/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const response = await app.request('/discover/federation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'invoice.federated_reconcile' }), + }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data).toMatchObject({ + provider: 'federation', + mode: 'local_mock_federation', + authorityGranted: false, + federationPeerVerified: true, + }) + expect(data.records).toEqual(expect.arrayContaining([ + expect.objectContaining({ + provider: 'federation', + agentId: identity.did, + federationPeerVerified: true, + authorityGranted: false, + reasons: expect.arrayContaining([ + 'federation_peer_matched_capability', + 'federation_does_not_grant_authority', + ]), + }), + ])) + }) + it('evaluates root trust, reputation, and policy for a registered local candidate', async () => { const identityResponse = await app.request('/identities', { method: 'POST', From a9f3a5b09d11a0203fc4855b2fe74693f59f95ff Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:12:15 +0300 Subject: [PATCH 067/282] docs: document federation discovery semantics --- docs/protocol/discovery.md | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/protocol/discovery.md b/docs/protocol/discovery.md index a587d24..cb29dcf 100644 --- a/docs/protocol/discovery.md +++ b/docs/protocol/discovery.md @@ -10,6 +10,10 @@ Current implementation anchors: - `packages/discovery/src/local-provider.ts` - `packages/discovery/src/well-known-provider.ts` - `packages/discovery/src/registry-provider.ts` +- `packages/discovery/src/relay-provider.ts` +- `packages/discovery/src/dht-provider.ts` +- `packages/discovery/src/federation-provider.ts` +- `services/agentd/src/index.ts` ## Flow @@ -26,12 +30,19 @@ Current implementation anchors: The current implementation supports capability-query providers and candidate explanations. Trust/policy/evidence integration remains an incremental hardening area. -Root `agentd` local, well-known, registry, relay, and locally resolvable DHT -discovery now apply protocol version negotiation before returning provider -results. A query can send `supported_versions` and `required_versions`; each -matching local AgentCard contributes its `protocolVersions`. Compatible -candidates, records, and pointers include a `versionNegotiation` record. -Incompatible matches are filtered out of the active result set and returned in -`rejectedCandidates`, `rejectedRecords`, or `rejectedPointers` with a -`VERSION_INCOMPATIBLE` error envelope. This keeps discovery useful for -explainability without treating an incompatible candidate as invokable. +Root `agentd` local, well-known, registry, relay, locally resolvable DHT, and +local mock federation discovery now apply protocol version negotiation before +returning provider results. A query can send `supported_versions` and +`required_versions`; each matching local AgentCard contributes its +`protocolVersions`. Compatible candidates, records, and pointers include a +`versionNegotiation` record. Incompatible matches are filtered out of the +active result set and returned in `rejectedCandidates`, `rejectedRecords`, or +`rejectedPointers` with a `VERSION_INCOMPATIBLE` error envelope. This keeps +discovery useful for explainability without treating an incompatible candidate +as invokable. + +Federation discovery is deliberately candidate-only. The local mock federation +provider verifies signed `RegistryPeerRecord` metadata and ignores expired or +non-search peers, but a federated candidate remains untrusted until the normal +AgentCard, trust, reputation, revocation, incident, policy, session, and +evidence pipeline completes. From 6b3b85f8b6a380383fa84793ea7d493dc7fc22b9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:15:52 +0300 Subject: [PATCH 068/282] feat(attestations): record lifecycle evidence --- docs/api-reference.md | 4 ++- docs/protocol/runtime-attestation.md | 12 +++++++ services/agentd/src/index.ts | 41 ++++++++++++++++++++++-- services/agentd/test/routes.test.ts | 47 +++++++++++++++++++++++++++- 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index ee007a2..166e5c1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -224,4 +224,6 @@ resolved with `POST /incidents/:id/resolve`. `POST /attestations` issues a local FIDES v2 runtime attestation through the MockTEE provider. `POST /attestations/:id/verify` verifies provider, expiry, and hash shape. Root `POST /sessions` can consume a valid `attestationId` as -runtime attestation evidence for high-risk capability policy. +runtime attestation evidence for high-risk capability policy. Attestation +issuance and verification append `attestation.issued`, `attestation.verified`, +or `attestation.failed` evidence events and return `evidenceRefs`. diff --git a/docs/protocol/runtime-attestation.md b/docs/protocol/runtime-attestation.md index 2699343..92b382a 100644 --- a/docs/protocol/runtime-attestation.md +++ b/docs/protocol/runtime-attestation.md @@ -20,3 +20,15 @@ Current implementation anchors: ## Policy Rule High-risk capabilities require valid runtime attestation or explicit approval. Missing attestation should not deny low-risk actions by default. + +## Evidence + +Runtime attestation lifecycle actions are evidence-producing. Local `agentd` +appends hash-only events for: + +- `attestation.issued` when `POST /attestations` creates a MockTEE attestation. +- `attestation.verified` when `POST /attestations/:id/verify` succeeds. +- `attestation.failed` when verification fails or the attestation is missing. + +These evidence events do not grant authority. They provide audit references +that policy and trust decisions can cite later. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index f10dcfc..5bb29f2 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -2102,8 +2102,23 @@ app.post('/attestations', async (c) => { : undefined, }) localRuntimeAttestations.set(attestation.attestation_id, attestation) + const event = appendRootEvidence({ + type: 'attestation.issued', + actor: agentId, + subject: agentId, + output: attestation, + decision: 'issued', + privacy_mode: 'hash_only', + metadata: { + attestation_id: attestation.attestation_id, + provider: attestation.provider, + code_hash: attestation.code_hash, + runtime_hash: attestation.runtime_hash, + policy_hash: attestation.policy_hash, + }, + }) - return c.json({ attestation }, 201) + return c.json({ attestation, evidenceRefs: [event.event_id], authorityGranted: false }, 201) }) app.get('/attestations/:id', (c) => { @@ -2119,10 +2134,30 @@ app.post('/attestations/:id/verify', async (c) => { const id = c.req.param('id') const attestation = localRuntimeAttestations.get(id) if (!attestation) { - return c.json({ id, valid: false, error: 'attestation not found' }, 404) + const failed = appendRootEvidence({ + type: 'attestation.failed', + actor: 'did:fides:agentd:local', + subject: id, + decision: 'not_found', + privacy_mode: 'hash_only', + metadata: { attestation_id: id }, + }) + return c.json({ id, valid: false, error: 'attestation not found', evidenceRefs: [failed.event_id], authorityGranted: false }, 404) } const valid = await runtimeAttestationProvider.verify(attestation) - return c.json({ id, valid, attestation }) + const event = appendRootEvidence({ + type: valid ? 'attestation.verified' : 'attestation.failed', + actor: attestation.agent_id, + subject: attestation.agent_id, + output: { valid, attestation_id: id }, + decision: valid ? 'verified' : 'failed', + privacy_mode: 'hash_only', + metadata: { + attestation_id: id, + provider: attestation.provider, + }, + }) + return c.json({ id, valid, attestation, evidenceRefs: [event.event_id], authorityGranted: false }) }) // ─── FIDES v2 Local API Aliases ─────────────────────────────────── diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 735a319..ca29f4c 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1337,6 +1337,8 @@ describe('Agentd Service Routes', () => { expect(attestation.status).toBe(201) const attestationData = await attestation.json() expect(attestationData.attestation.agent_id).toBe(identity.did) + expect(attestationData.authorityGranted).toBe(false) + expect(attestationData.evidenceRefs).toHaveLength(1) const shown = await app.request(`/attestations/${attestationData.attestation.attestation_id}`) expect(shown.status).toBe(200) @@ -1344,7 +1346,10 @@ describe('Agentd Service Routes', () => { const verified = await app.request(`/attestations/${attestationData.attestation.attestation_id}/verify`, { method: 'POST' }) expect(verified.status).toBe(200) - expect((await verified.json()).valid).toBe(true) + const verifiedData = await verified.json() + expect(verifiedData.valid).toBe(true) + expect(verifiedData.authorityGranted).toBe(false) + expect(verifiedData.evidenceRefs).toHaveLength(1) const session = await app.request('/sessions', { method: 'POST', @@ -1360,6 +1365,46 @@ describe('Agentd Service Routes', () => { }) expect(session.status).toBe(201) expect((await session.json()).policy.reason_codes).toContain('POLICY_ALLOWED') + + const evidence = await app.request('/evidence') + const evidenceData = await evidence.json() + expect(evidenceData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event_id: attestationData.evidenceRefs[0], + type: 'attestation.issued', + subject: identity.did, + privacy_mode: 'hash_only', + }), + expect.objectContaining({ + event_id: verifiedData.evidenceRefs[0], + type: 'attestation.verified', + subject: identity.did, + privacy_mode: 'hash_only', + }), + ])) + }) + + it('records failed attestation verification evidence for missing attestations', async () => { + const verified = await app.request('/attestations/att_missing/verify', { method: 'POST' }) + expect(verified.status).toBe(404) + const verifiedData = await verified.json() + expect(verifiedData).toMatchObject({ + id: 'att_missing', + valid: false, + authorityGranted: false, + }) + expect(verifiedData.evidenceRefs).toHaveLength(1) + + const evidence = await app.request('/evidence') + const evidenceData = await evidence.json() + expect(evidenceData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event_id: verifiedData.evidenceRefs[0], + type: 'attestation.failed', + subject: 'att_missing', + privacy_mode: 'hash_only', + }), + ])) }) it('serves local DHT publish and find endpoints', async () => { From 23590ccf19067e12a0939f3e22086b8427a92029 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:20:08 +0300 Subject: [PATCH 069/282] feat(evidence): audit governance lifecycle events --- docs/api-reference.md | 10 ++- docs/protocol/approvals.md | 13 ++++ docs/protocol/evidence-ledger.md | 10 +++ docs/protocol/incidents.md | 6 ++ docs/protocol/kill-switch.md | 7 +++ docs/protocol/revocation.md | 6 ++ services/agentd/src/index.ts | 94 +++++++++++++++++++++++++++++ services/agentd/test/routes.test.ts | 91 ++++++++++++++++++++++++++++ 8 files changed, 235 insertions(+), 2 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 166e5c1..70d5dde 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -209,17 +209,23 @@ invalid invocation request signatures, and capability schema violations. `POST /approvals` creates an approval request and records approval decisions through `/approvals/:id/approve` or `/approvals/:id/deny`. Approval records do not grant authority by themselves; they are inputs to policy/session issuance. +Approval request, grant, and deny mutations append `approval.requested`, +`approval.granted`, or `approval.denied` evidence events and return +`evidenceRefs`. `POST /killswitch` creates an active kill switch rule for an agent, publisher, capability, session, principal, or risk class. Active kill switch rules override normal trust and policy evaluation and block root session issuance until -disabled with `DELETE /killswitch/:id`. +disabled with `DELETE /killswitch/:id`. Kill switch creation appends a +`kill_switch.triggered` evidence event and returns `evidenceRefs`. `POST /revocations` creates a local FIDES v2 revocation record for keys, identities, agents, AgentCards, capabilities, sessions, attestations, or publishers. Active matching revocations override normal policy and block root session issuance. `POST /incidents` records an open incident against a target agent; open incidents require policy review for matching session requests until -resolved with `POST /incidents/:id/resolve`. +resolved with `POST /incidents/:id/resolve`. Revocation and incident creation +append `revocation.recorded` and `incident.reported` evidence events and return +`evidenceRefs`. `POST /attestations` issues a local FIDES v2 runtime attestation through the MockTEE provider. `POST /attestations/:id/verify` verifies provider, expiry, diff --git a/docs/protocol/approvals.md b/docs/protocol/approvals.md index 9a9dcc1..491e76d 100644 --- a/docs/protocol/approvals.md +++ b/docs/protocol/approvals.md @@ -14,3 +14,16 @@ Current implementation anchor: Approval requests include requester, target, principal, capability, scopes, risk level, policy decision hash, and evidence refs. Approval decisions are signed by the approver and may include constraints. + +## Evidence + +The local root daemon appends hash-only evidence for approval lifecycle +mutations: + +- `approval.requested` +- `approval.granted` +- `approval.denied` + +These events record authorization intent and decision provenance. They do not +grant authority without a subsequent policy evaluation and scoped +`SessionGrant`. diff --git a/docs/protocol/evidence-ledger.md b/docs/protocol/evidence-ledger.md index 512923b..a1e2f1a 100644 --- a/docs/protocol/evidence-ledger.md +++ b/docs/protocol/evidence-ledger.md @@ -11,6 +11,16 @@ Current implementation anchors: The event taxonomy includes agent registration, discovery, trust computation, policy evaluation, approval, session, invocation, attestation, revocation, incident, and kill switch events. +Current root `agentd` mutations append hash-only lifecycle evidence for: + +- approval requests, grants, and denials +- kill switch activation +- revocation records +- incident reports +- session grants and denials +- invocation attempts and results +- runtime attestation issuance and verification + ## Integrity Each event links to the previous event hash. Verification detects broken chains. Export should preserve enough metadata to audit without leaking sensitive inputs or outputs. diff --git a/docs/protocol/incidents.md b/docs/protocol/incidents.md index 0bf53b7..6efc369 100644 --- a/docs/protocol/incidents.md +++ b/docs/protocol/incidents.md @@ -18,3 +18,9 @@ Current implementation anchor: - `suspicious_behavior` Incidents carry severity, evidence refs, resolution status, trust penalty, and reputation penalty. + +## Evidence + +The local root daemon appends a hash-only `incident.reported` event when an +incident is reported. The event records reporter, target agent, severity, +category, and penalty metadata while preserving the evidence privacy default. diff --git a/docs/protocol/kill-switch.md b/docs/protocol/kill-switch.md index 40e8ada..07c033f 100644 --- a/docs/protocol/kill-switch.md +++ b/docs/protocol/kill-switch.md @@ -19,3 +19,10 @@ Current implementation anchors: - risk class Kill switch checks should run before policy grants or invocation execution. + +## Evidence + +The local root daemon appends a hash-only `kill_switch.triggered` event when a +kill switch rule is created. The event records issuer, target type, target, +enabled state, and reason metadata so policy denials caused by kill switches +can be audited later. diff --git a/docs/protocol/revocation.md b/docs/protocol/revocation.md index 8a49e16..a68b0e0 100644 --- a/docs/protocol/revocation.md +++ b/docs/protocol/revocation.md @@ -18,3 +18,9 @@ Current implementation anchor: - publisher Revocation must be checked before trust, policy, session, and invocation flows complete. + +## Evidence + +The local root daemon appends a hash-only `revocation.recorded` event when a +revocation record is created. The event links the issuer, target, target type, +and upstream evidence refs without storing sensitive payloads by default. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 5bb29f2..2084f58 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -1777,9 +1777,25 @@ app.post('/approvals', async (c) => { expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : undefined, }) localApprovals.set(approval.id, approval) + const event = appendRootEvidence({ + type: 'approval.requested', + actor: requesterAgentId, + subject: targetAgentId, + principal: principalId, + capability, + decision: 'requested', + risk_level: approval.risk_level, + privacy_mode: 'hash_only', + metadata: { + approval_id: approval.id, + requested_scopes: approval.requested_scopes, + upstream_evidence_refs: approval.evidence_refs, + }, + }) return c.json({ approval, + evidenceRefs: [event.event_id], authorityGranted: false, explanation: 'Approval records human authorization intent; it does not grant invocation authority without policy and a scoped SessionGrant.', }, 201) @@ -1812,10 +1828,27 @@ app.post('/approvals/:id/approve', async (c) => { const updated: ApprovalRequest = { ...approval, status: 'approved' } localApprovals.set(id, updated) localApprovalDecisions.set(decision.id, decision) + const event = appendRootEvidence({ + type: 'approval.granted', + actor: decision.approver_id, + subject: approval.target_agent_id, + principal: approval.principal_id, + capability: approval.capability, + output: decision, + decision: decision.decision, + risk_level: approval.risk_level, + privacy_mode: 'hash_only', + metadata: { + approval_id: approval.id, + approval_decision_id: decision.id, + upstream_evidence_refs: decision.evidence_refs, + }, + }) return c.json({ approval: updated, decision, + evidenceRefs: [event.event_id], authorityGranted: false, explanation: 'Approval has been recorded. A policy evaluation and scoped SessionGrant are still required before invocation.', }) @@ -1840,10 +1873,27 @@ app.post('/approvals/:id/deny', async (c) => { const updated: ApprovalRequest = { ...approval, status: 'denied' } localApprovals.set(id, updated) localApprovalDecisions.set(decision.id, decision) + const event = appendRootEvidence({ + type: 'approval.denied', + actor: decision.approver_id, + subject: approval.target_agent_id, + principal: approval.principal_id, + capability: approval.capability, + output: decision, + decision: decision.decision, + risk_level: approval.risk_level, + privacy_mode: 'hash_only', + metadata: { + approval_id: approval.id, + approval_decision_id: decision.id, + upstream_evidence_refs: decision.evidence_refs, + }, + }) return c.json({ approval: updated, decision, + evidenceRefs: [event.event_id], authorityGranted: false, }) }) @@ -1880,9 +1930,24 @@ app.post('/killswitch', async (c) => { expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : undefined, }) localKillSwitchRules.set(rule.id, rule) + const event = appendRootEvidence({ + type: 'kill_switch.triggered', + actor: rule.issuer, + subject: rule.target, + decision: rule.enabled ? 'enabled' : 'created_disabled', + privacy_mode: 'hash_only', + metadata: { + rule_id: rule.id, + target_type: rule.target_type, + target: rule.target, + enabled: rule.enabled, + reason: rule.reason, + }, + }) return c.json({ rule, + evidenceRefs: [event.event_id], authorityOverride: true, explanation: 'Kill switch rules override normal trust and policy evaluation while active.', }, 201) @@ -1947,9 +2012,22 @@ app.post('/revocations', async (c) => { expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : undefined, }) localRevocationRecords.set(record.id, record) + const event = appendRootEvidence({ + type: 'revocation.recorded', + actor: record.issuer, + subject: record.target_id, + decision: record.status, + privacy_mode: 'hash_only', + metadata: { + revocation_id: record.id, + target_type: record.target_type, + upstream_evidence_refs: record.evidence_refs, + }, + }) return c.json({ record, + evidenceRefs: [event.event_id], authorityOverride: true, explanation: 'Active revocation records override normal trust and policy evaluation for matching requests.', }, 201) @@ -2018,9 +2096,25 @@ app.post('/incidents', async (c) => { reputationPenalty: typeof body.reputationPenalty === 'number' ? body.reputationPenalty : undefined, }) localIncidentRecords.set(record.id, record) + const event = appendRootEvidence({ + type: 'incident.reported', + actor: record.reporter, + subject: record.target_agent_id, + decision: record.resolution_status, + risk_level: record.severity, + privacy_mode: 'hash_only', + metadata: { + incident_id: record.id, + category: record.category, + trust_penalty: record.trust_penalty, + reputation_penalty: record.reputation_penalty, + upstream_evidence_refs: record.evidence_refs, + }, + }) return c.json({ record, + evidenceRefs: [event.event_id], explanation: 'Open incident records require policy review for matching target agents until resolved.', }, 201) }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index ca29f4c..8c0f38f 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1073,6 +1073,7 @@ describe('Agentd Service Routes', () => { const requestData = await request.json() expect(requestData.approval.status).toBe('pending') expect(requestData.authorityGranted).toBe(false) + expect(requestData.evidenceRefs).toHaveLength(1) const listed = await app.request('/approvals') expect(listed.status).toBe(200) @@ -1090,6 +1091,56 @@ describe('Agentd Service Routes', () => { expect(approvedData.approval.status).toBe('approved') expect(approvedData.decision.decision).toBe('approved') expect(approvedData.authorityGranted).toBe(false) + expect(approvedData.evidenceRefs).toHaveLength(1) + + const denyRequest = await app.request('/approvals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: 'did:fides:agent', + capability: 'deploy.production', + riskLevel: 'critical', + }), + }) + const denyRequestData = await denyRequest.json() + const denied = await app.request(`/approvals/${denyRequestData.approval.id}/deny`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approverId: 'did:fides:approver', reason: 'No production deploy window.' }), + }) + expect(denied.status).toBe(200) + const deniedData = await denied.json() + expect(deniedData.approval.status).toBe('denied') + expect(deniedData.decision.decision).toBe('denied') + expect(deniedData.evidenceRefs).toHaveLength(1) + + const evidence = await app.request('/evidence') + const evidenceData = await evidence.json() + expect(evidenceData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event_id: requestData.evidenceRefs[0], + type: 'approval.requested', + subject: 'did:fides:agent', + capability: 'payments.prepare', + privacy_mode: 'hash_only', + }), + expect.objectContaining({ + event_id: approvedData.evidenceRefs[0], + type: 'approval.granted', + actor: 'did:fides:approver', + decision: 'approved', + privacy_mode: 'hash_only', + }), + expect.objectContaining({ + event_id: deniedData.evidenceRefs[0], + type: 'approval.denied', + actor: 'did:fides:approver', + decision: 'denied', + privacy_mode: 'hash_only', + }), + ])) }) it('serves root kill switch rules and blocks scoped session issuance', async () => { @@ -1127,6 +1178,7 @@ describe('Agentd Service Routes', () => { expect(enabled.status).toBe(201) const enabledData = await enabled.json() expect(enabledData.rule.enabled).toBe(true) + expect(enabledData.evidenceRefs).toHaveLength(1) const blocked = await app.request('/sessions', { method: 'POST', @@ -1148,6 +1200,18 @@ describe('Agentd Service Routes', () => { const disabled = await app.request(`/killswitch/${enabledData.rule.id}`, { method: 'DELETE' }) expect(disabled.status).toBe(200) expect((await disabled.json()).rule.enabled).toBe(false) + + const evidence = await app.request('/evidence') + const evidenceData = await evidence.json() + expect(evidenceData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event_id: enabledData.evidenceRefs[0], + type: 'kill_switch.triggered', + actor: 'did:fides:operator', + subject: 'deploy.preview', + privacy_mode: 'hash_only', + }), + ])) }) it('serves root revocation records and blocks scoped session issuance', async () => { @@ -1185,6 +1249,7 @@ describe('Agentd Service Routes', () => { expect(revocation.status).toBe(201) const revocationData = await revocation.json() expect(revocationData.record.status).toBe('active') + expect(revocationData.evidenceRefs).toHaveLength(1) const listed = await app.request('/revocations') expect(listed.status).toBe(200) @@ -1213,6 +1278,18 @@ describe('Agentd Service Routes', () => { const blockedData = await blocked.json() expect(blockedData.policy.reason_codes).toContain('REVOCATION_ACTIVE') expect(blockedData.error.code).toBe('REVOCATION_ACTIVE') + + const evidence = await app.request('/evidence') + const evidenceData = await evidence.json() + expect(evidenceData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event_id: revocationData.evidenceRefs[0], + type: 'revocation.recorded', + actor: 'did:fides:operator', + subject: identity.did, + privacy_mode: 'hash_only', + }), + ])) }) it('serves root incident records and blocks scoped session issuance until resolved', async () => { @@ -1252,6 +1329,7 @@ describe('Agentd Service Routes', () => { expect(incident.status).toBe(201) const incidentData = await incident.json() expect(incidentData.record.resolution_status).toBe('open') + expect(incidentData.evidenceRefs).toHaveLength(1) const listed = await app.request('/incidents') expect(listed.status).toBe(200) @@ -1284,6 +1362,19 @@ describe('Agentd Service Routes', () => { }) expect(resolved.status).toBe(200) expect((await resolved.json()).record.resolution_status).toBe('resolved') + + const evidence = await app.request('/evidence') + const evidenceData = await evidence.json() + expect(evidenceData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event_id: incidentData.evidenceRefs[0], + type: 'incident.reported', + actor: 'did:fides:principal', + subject: identity.did, + risk_level: 'critical', + privacy_mode: 'hash_only', + }), + ])) }) it('serves root runtime attestations and uses valid attestations for high-risk session issuance', async () => { From c8300762b9e1233d4741f5ee7d0d42a77de7875b Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:23:50 +0300 Subject: [PATCH 070/282] feat(adapters): add interop mapping contract --- docs/inspection/cross-repo-primitive-map.md | 14 +- docs/protocol/interop-adapters.md | 29 +++ packages/adapters/README.md | 15 ++ packages/adapters/src/index.ts | 223 ++++++++++++++++++++ packages/adapters/test/adapters.test.ts | 123 +++++++++++ 5 files changed, 397 insertions(+), 7 deletions(-) diff --git a/docs/inspection/cross-repo-primitive-map.md b/docs/inspection/cross-repo-primitive-map.md index a8ea2df..fc6bcdc 100644 --- a/docs/inspection/cross-repo-primitive-map.md +++ b/docs/inspection/cross-repo-primitive-map.md @@ -67,13 +67,13 @@ This map reflects the current local inspection of: | Examples | `examples/` | demos | examples | many fixtures/examples | many demos | FIDES | extend existing | | Tests | package/service/e2e/adversarial | Rust/Python/TS tests | conformance | reference tests | large suite | FIDES | extend existing | | Docs | docs present but stale | docs | spec/docs | spec/schemas | docs/site | FIDES | extend existing | -| MCP adapter | not first-class FIDES adapter | exists | MCP server | MCP adapter | MCP server | OAPS + Sardis/OSP | adapter-ready | -| A2A adapter | shared/SDK legacy shapes | exists | A2A adjacent | A2A adapter | A2A resources/routes | FIDES + OAPS/Sardis | adapter-ready | -| OAPS adapter | not found | not found | not found | source spec | not found | FIDES | create new | -| OSP adapter | not found | not found | source spec | not found | not found | FIDES + OSP | adapter-ready | -| AP2 adapter | not found | not found | not core | payment profile | AP2 verifier/mandates | Sardis | adapter-ready | -| x402 adapter | not found | not found | not core | x402 adapter | x402 facilitator | Sardis/OAPS | adapter-ready | -| Sardis adapter | not found | FIDES adapter to AGIT | Sardis integration | profile relation | source consumer | FIDES + Sardis | create new | +| MCP adapter | adapter contract + manifest | exists | MCP server | MCP adapter | MCP server | OAPS + Sardis/OSP | adapter-ready | +| A2A adapter | adapter contract + manifest | exists | A2A adjacent | A2A adapter | A2A resources/routes | FIDES + OAPS/Sardis | adapter-ready | +| OAPS adapter | adapter contract + mapping set | not found | not found | source spec | not found | FIDES | extend existing | +| OSP adapter | adapter contract + mapping set | not found | source spec | not found | not found | FIDES + OSP | adapter-ready | +| AP2 adapter | payment action-flow adapter contract | not found | not core | payment profile | AP2 verifier/mandates | Sardis | adapter-ready | +| x402 adapter | payment action-flow adapter contract | not found | not core | x402 adapter | x402 facilitator | Sardis/OAPS | adapter-ready | +| Sardis adapter | payment action-flow adapter contract | FIDES adapter to AGIT | Sardis integration | profile relation | source consumer | FIDES + Sardis | extend existing | ## Key Findings diff --git a/docs/protocol/interop-adapters.md b/docs/protocol/interop-adapters.md index 476fcfc..8603ef8 100644 --- a/docs/protocol/interop-adapters.md +++ b/docs/protocol/interop-adapters.md @@ -5,6 +5,7 @@ FIDES exposes adapter interfaces for external protocols without runtime dependen Current implementation anchor: - `packages/adapters/src/index.ts` +- `packages/adapters/README.md` ## Adapter Kinds @@ -19,3 +20,31 @@ Current implementation anchor: Adapters map identity, AgentCards, capabilities, delegation, policy, evidence, invocation, and payment/action flows where relevant. Payment-specific execution remains outside generic FIDES. + +## Adapter Contract + +Adapters declare an `AdapterManifest` with supported protocol surfaces: + +- identity +- AgentCard +- capability +- discovery +- trust +- policy +- delegation +- session +- approval +- invocation +- evidence +- attestation +- revocation +- incident +- payment action flow + +`AdapterMapping` is the lightweight cross-reference record. `InteropMappingSet` +is the richer adapter-ready shape for moving external protocol data into +FIDES-owned runtime objects without making FIDES depend on external SDKs. + +AP2, x402, and Sardis are marked payment/action-flow adapters. They can map +payment-specific flows into FIDES policy, authority, and evidence references, +but generic FIDES does not execute payments. diff --git a/packages/adapters/README.md b/packages/adapters/README.md index a657702..68d7e84 100644 --- a/packages/adapters/README.md +++ b/packages/adapters/README.md @@ -4,6 +4,21 @@ Interface-only interop adapters for FIDES v2. This package describes how external protocols map into FIDES identity, AgentCards, capabilities, delegation, policy, evidence, and invocation records. It intentionally avoids runtime dependencies on MCP, A2A, OAPS, OSP, AP2, x402, Sardis, or payment SDKs. +## Contract + +- `AdapterManifest` declares the adapter kind, version, supported protocol + surfaces, and whether the adapter is payment-specific. +- `AdapterMapping` maps an external protocol identifier to FIDES identities and + capability IDs. +- `InteropMappingSet` maps richer protocol surfaces into FIDES-native shapes: + identity, AgentCards, capabilities, discovery candidates, trust, policy, + delegation, sessions, approvals, invocation, evidence, attestations, + revocations, incidents, and payment action-flow references. + +Payment execution remains outside generic FIDES. AP2, x402, and Sardis adapters +can expose payment action-flow mappings, but FIDES treats those as interop +references for policy, authority, and evidence. + ## License MIT diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index e4e5942..001bed6 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -1,3 +1,40 @@ +import type { + AgentCard, + ApprovalDecision, + ApprovalRequest, + CapabilityDescriptor, + DelegationToken, + DiscoveryCandidate, + IncidentRecordV2, + InvocationRequest, + InvocationResult, + PrincipalIdentity, + PublisherIdentity, + RevocationRecordV2, + RuntimeAttestation, + SessionGrantV2, + TrustResult, + AgentIdentity, +} from '@fides/core' + +export interface AdapterPolicyDecision { + schema_version?: string + decision: string + reason_codes?: string[] + evidence_refs?: string[] + [key: string]: unknown +} + +export interface AdapterEvidenceEvent { + schema_version?: string + event_id?: string + type: string + actor: string + subject?: string + evidence_refs?: string[] + [key: string]: unknown +} + export const ADAPTER_KINDS = [ 'mcp', 'a2a', @@ -10,6 +47,26 @@ export const ADAPTER_KINDS = [ export type AdapterKind = typeof ADAPTER_KINDS[number] +export const ADAPTER_PROTOCOL_SURFACES = [ + 'identity', + 'agent_card', + 'capability', + 'discovery', + 'trust', + 'policy', + 'delegation', + 'session', + 'approval', + 'invocation', + 'evidence', + 'attestation', + 'revocation', + 'incident', + 'payment_action_flow', +] as const + +export type AdapterProtocolSurface = typeof ADAPTER_PROTOCOL_SURFACES[number] + export interface AdapterMapping { schema_version: 'fides.adapter.mapping.v1' id: string @@ -26,6 +83,51 @@ export interface AdapterMapping { created_at: string } +export interface AdapterManifest { + schema_version: 'fides.adapter.manifest.v1' + id: string + kind: AdapterKind + name: string + version: string + surfaces: AdapterProtocolSurface[] + payment_specific: boolean + runtime_dependency_required: boolean + created_at: string +} + +export interface InteropMappingSet { + schema_version: 'fides.adapter.mapping_set.v1' + id: string + kind: AdapterKind + external_id: string + identities: { + agent?: AgentIdentity + publisher?: PublisherIdentity + principal?: PrincipalIdentity + } + agent_card?: AgentCard + capabilities: CapabilityDescriptor[] + discovery_candidates: DiscoveryCandidate[] + trust_results: TrustResult[] + policy_decisions: AdapterPolicyDecision[] + delegation_tokens: DelegationToken[] + session_grants: SessionGrantV2[] + approvals: { + requests: ApprovalRequest[] + decisions: ApprovalDecision[] + } + invocations: { + requests: InvocationRequest[] + results: InvocationResult[] + } + evidence_events: AdapterEvidenceEvent[] + runtime_attestations: RuntimeAttestation[] + revocations: RevocationRecordV2[] + incidents: IncidentRecordV2[] + payment_action_flows: unknown[] + created_at: string +} + export interface AdapterMappingInput { kind: AdapterKind externalId: string @@ -40,9 +142,47 @@ export interface AdapterMappingInput { createdAt?: string } +export interface AdapterManifestInput { + kind: AdapterKind + name: string + version?: string + surfaces?: AdapterProtocolSurface[] + runtimeDependencyRequired?: boolean + createdAt?: string +} + +export interface InteropMappingSetInput { + kind: AdapterKind + externalId: string + identities?: InteropMappingSet['identities'] + agentCard?: AgentCard + capabilities?: CapabilityDescriptor[] + discoveryCandidates?: DiscoveryCandidate[] + trustResults?: TrustResult[] + policyDecisions?: AdapterPolicyDecision[] + delegationTokens?: DelegationToken[] + sessionGrants?: SessionGrantV2[] + approvals?: Partial + invocations?: Partial + evidenceEvents?: AdapterEvidenceEvent[] + runtimeAttestations?: RuntimeAttestation[] + revocations?: RevocationRecordV2[] + incidents?: IncidentRecordV2[] + paymentActionFlows?: unknown[] + createdAt?: string +} + +export interface AdapterCoverageReport { + valid: boolean + missing: AdapterProtocolSurface[] +} + export interface FidesInteropAdapter { readonly kind: AdapterKind + readonly manifest: AdapterManifest toFidesMapping(external: TExternal): Promise | AdapterMapping + toFidesMappingSet?(external: TExternal): Promise | InteropMappingSet + fromFidesMappingSet?(mapping: InteropMappingSet): Promise | TExternal } export function createAdapterMapping(input: AdapterMappingInput): AdapterMapping { @@ -63,10 +203,93 @@ export function createAdapterMapping(input: AdapterMappingInput): AdapterMapping } } +export function createAdapterManifest(input: AdapterManifestInput): AdapterManifest { + return { + schema_version: 'fides.adapter.manifest.v1', + id: crypto.randomUUID(), + kind: input.kind, + name: input.name, + version: input.version ?? '0.1.0', + surfaces: input.surfaces ?? defaultSurfacesForAdapter(input.kind), + payment_specific: isPaymentAdapterKind(input.kind), + runtime_dependency_required: input.runtimeDependencyRequired ?? false, + created_at: input.createdAt ?? new Date().toISOString(), + } +} + +export function createInteropMappingSet(input: InteropMappingSetInput): InteropMappingSet { + return { + schema_version: 'fides.adapter.mapping_set.v1', + id: crypto.randomUUID(), + kind: input.kind, + external_id: input.externalId, + identities: input.identities ?? {}, + agent_card: input.agentCard, + capabilities: input.capabilities ?? [], + discovery_candidates: input.discoveryCandidates ?? [], + trust_results: input.trustResults ?? [], + policy_decisions: input.policyDecisions ?? [], + delegation_tokens: input.delegationTokens ?? [], + session_grants: input.sessionGrants ?? [], + approvals: { + requests: input.approvals?.requests ?? [], + decisions: input.approvals?.decisions ?? [], + }, + invocations: { + requests: input.invocations?.requests ?? [], + results: input.invocations?.results ?? [], + }, + evidence_events: input.evidenceEvents ?? [], + runtime_attestations: input.runtimeAttestations ?? [], + revocations: input.revocations ?? [], + incidents: input.incidents ?? [], + payment_action_flows: input.paymentActionFlows ?? [], + created_at: input.createdAt ?? new Date().toISOString(), + } +} + export function isAdapterKind(value: string): value is AdapterKind { return (ADAPTER_KINDS as readonly string[]).includes(value) } +export function isAdapterProtocolSurface(value: string): value is AdapterProtocolSurface { + return (ADAPTER_PROTOCOL_SURFACES as readonly string[]).includes(value) +} + export function isPaymentAdapterKind(kind: AdapterKind): boolean { return kind === 'ap2' || kind === 'x402' || kind === 'sardis' } + +export function defaultSurfacesForAdapter(kind: AdapterKind): AdapterProtocolSurface[] { + const generic: AdapterProtocolSurface[] = [ + 'identity', + 'agent_card', + 'capability', + 'delegation', + 'policy', + 'evidence', + 'invocation', + ] + + if (kind === 'osp') { + return ['identity', 'agent_card', 'capability', 'discovery', 'revocation'] + } + + if (isPaymentAdapterKind(kind)) { + return [...generic, 'session', 'approval', 'attestation', 'revocation', 'incident', 'payment_action_flow'] + } + + return generic +} + +export function validateAdapterCoverage( + manifest: AdapterManifest, + requiredSurfaces: AdapterProtocolSurface[] +): AdapterCoverageReport { + const provided = new Set(manifest.surfaces) + const missing = requiredSurfaces.filter(surface => !provided.has(surface)) + return { + valid: missing.length === 0, + missing, + } +} diff --git a/packages/adapters/test/adapters.test.ts b/packages/adapters/test/adapters.test.ts index 6508f0a..e9fbda1 100644 --- a/packages/adapters/test/adapters.test.ts +++ b/packages/adapters/test/adapters.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from 'vitest' import { ADAPTER_KINDS, + ADAPTER_PROTOCOL_SURFACES, + createAdapterManifest, createAdapterMapping, + createInteropMappingSet, + defaultSurfacesForAdapter, + isAdapterProtocolSurface, isPaymentAdapterKind, + validateAdapterCoverage, } from '../src/index.js' describe('FIDES interop adapter interfaces', () => { @@ -18,6 +24,28 @@ describe('FIDES interop adapter interfaces', () => { ]) }) + it('declares protocol surfaces that adapters can map without protocol SDK dependencies', () => { + expect(ADAPTER_PROTOCOL_SURFACES).toEqual([ + 'identity', + 'agent_card', + 'capability', + 'discovery', + 'trust', + 'policy', + 'delegation', + 'session', + 'approval', + 'invocation', + 'evidence', + 'attestation', + 'revocation', + 'incident', + 'payment_action_flow', + ]) + expect(isAdapterProtocolSurface('evidence')).toBe(true) + expect(isAdapterProtocolSurface('unknown')).toBe(false) + }) + it('creates mapping records for identity, capabilities, policy, evidence, and invocation', () => { const mapping = createAdapterMapping({ kind: 'oaps', @@ -49,4 +77,99 @@ describe('FIDES interop adapter interfaces', () => { expect(isPaymentAdapterKind('sardis')).toBe(true) expect(isPaymentAdapterKind('mcp')).toBe(false) }) + + it('creates manifests with default generic surfaces', () => { + const manifest = createAdapterManifest({ + kind: 'mcp', + name: 'MCP adapter', + version: '0.1.0', + }) + + expect(manifest).toMatchObject({ + schema_version: 'fides.adapter.manifest.v1', + kind: 'mcp', + name: 'MCP adapter', + version: '0.1.0', + payment_specific: false, + runtime_dependency_required: false, + }) + expect(manifest.surfaces).toEqual(expect.arrayContaining([ + 'identity', + 'agent_card', + 'capability', + 'delegation', + 'policy', + 'evidence', + 'invocation', + ])) + }) + + it('keeps payment action-flow surfaces scoped to payment adapters', () => { + expect(defaultSurfacesForAdapter('sardis')).toContain('payment_action_flow') + expect(defaultSurfacesForAdapter('x402')).toContain('payment_action_flow') + expect(defaultSurfacesForAdapter('ap2')).toContain('payment_action_flow') + expect(defaultSurfacesForAdapter('oaps')).not.toContain('payment_action_flow') + + const manifest = createAdapterManifest({ kind: 'sardis', name: 'Sardis adapter' }) + expect(manifest.payment_specific).toBe(true) + expect(manifest.surfaces).toContain('approval') + expect(manifest.surfaces).toContain('attestation') + expect(manifest.surfaces).toContain('revocation') + }) + + it('builds mapping sets across FIDES protocol surfaces', () => { + const mapping = createInteropMappingSet({ + kind: 'oaps', + externalId: 'oaps:flow:invoice-reconcile', + identities: { + agent: { + did: 'did:fides:agent', + publicKey: new Uint8Array(32), + keyType: 'Ed25519', + createdAt: '2026-01-01T00:00:00.000Z', + }, + }, + capabilities: [{ + id: 'invoice.reconcile', + namespace: 'invoice', + action: 'reconcile', + resource: 'invoice', + name: 'invoice.reconcile', + description: 'Reconcile invoices', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object' }, + riskLevel: 'medium', + requiresApproval: false, + requiresRuntimeAttestation: false, + }], + policyDecisions: [{ decision: 'allow', reason_codes: ['POLICY_ALLOWED'] }], + evidenceEvents: [{ type: 'policy.evaluated', actor: 'did:fides:agent' }], + }) + + expect(mapping).toMatchObject({ + schema_version: 'fides.adapter.mapping_set.v1', + kind: 'oaps', + external_id: 'oaps:flow:invoice-reconcile', + }) + expect(mapping.identities.agent?.did).toBe('did:fides:agent') + expect(mapping.capabilities[0]?.id).toBe('invoice.reconcile') + expect(mapping.policy_decisions[0]?.decision).toBe('allow') + expect(mapping.evidence_events[0]?.type).toBe('policy.evaluated') + expect(mapping.approvals).toEqual({ requests: [], decisions: [] }) + expect(mapping.invocations).toEqual({ requests: [], results: [] }) + }) + + it('reports missing required surfaces for adapter readiness checks', () => { + const manifest = createAdapterManifest({ + kind: 'osp', + name: 'OSP adapter', + surfaces: ['identity', 'discovery'], + }) + + const report = validateAdapterCoverage(manifest, ['identity', 'discovery', 'revocation']) + expect(report).toEqual({ + valid: false, + missing: ['revocation'], + }) + }) }) From 6205014d507c69bc3cb4f02548814001d7967b64 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:26:42 +0300 Subject: [PATCH 071/282] feat(dht): reject invalid pointer candidates --- docs/inspection/cross-repo-primitive-map.md | 4 +- docs/inspection/fides-report.md | 4 +- docs/protocol/dht-discovery.md | 6 ++ packages/discovery/src/dht-provider.ts | 23 +++++-- packages/discovery/test/dht-provider.test.ts | 67 ++++++++++++++++++++ 5 files changed, 94 insertions(+), 10 deletions(-) diff --git a/docs/inspection/cross-repo-primitive-map.md b/docs/inspection/cross-repo-primitive-map.md index fc6bcdc..c86c215 100644 --- a/docs/inspection/cross-repo-primitive-map.md +++ b/docs/inspection/cross-repo-primitive-map.md @@ -37,8 +37,8 @@ This map reflects the current local inspection of: | Well-known discovery | discovery service/provider | not found | manifest fetch | `.well-known/oaps.json` | agent auth/A2A well-known | FIDES | extend existing | | Registry | `services/registry` | not found | `osp-registry` | not broad runtime | not generic | FIDES + OSP | extend existing | | Relay | `services/relay`, relay provider | not found | not found | not found | not found | FIDES | extend existing | -| DHT | in-memory direct-card provider | not found | not found | not found | not found | FIDES | extend existing | -| Federation | partial/spec only | not found | registry concepts | profile notes only | not found | FIDES + OSP | create new | +| DHT | signed pointer record + in-memory simulator | not found | not found | not found | not found | FIDES | extend existing | +| Federation | signed peer record + local mock provider | not found | registry concepts | profile notes only | not found | FIDES + OSP | extend existing | | DelegationToken | `packages/core/src/delegation.ts` | not found | delegation chain structs | `DelegationToken` | mandates | FIDES + OAPS | extend existing | | SessionGrant | `packages/core/src/delegation.ts`, session store | not found | not found | auth-web session adjacent | grant/session-like agent auth | FIDES | extend existing | | PolicyBundle | `packages/policy` | guard chain | not core | policy package | policy DSL/pipeline | FIDES + OAPS | extend existing | diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index f255c97..1a7d485 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -55,8 +55,8 @@ Local evidence: | Well-known discovery | Present | `packages/discovery/src/well-known-provider.ts`, `services/discovery/src/routes/well-known.ts`. | | Registry discovery | Present | `packages/discovery/src/registry-provider.ts`, `services/registry/src/`. | | Relay discovery | Present, prototype | `packages/discovery/src/relay-provider.ts`, `services/relay/src/`. | -| DHT discovery | Present, too weak | `packages/discovery/src/dht-provider.ts` stores AgentCards by DID in an in-memory peer graph; it does not yet implement signed DHT pointer records. | -| Federation | Partial/spec only | Registry service exists, but I could not find federation peering records/runtime. | +| DHT discovery | Present, local simulator | `packages/core/src/dht.ts` defines signed `DHTPointerRecord`; `packages/discovery/src/dht-provider.ts` rejects tampered, expired, hash-mismatched, or revoked pointer candidates. | +| Federation | Present, local mock | `packages/core/src/registry.ts` defines `RegistryPeerRecord`; `packages/discovery/src/federation-provider.ts` verifies signed peer records for candidate-only federation discovery. | | Trust graph | Present | `services/trust-graph/src/services/graph.ts`, `services/trust-graph/src/services/trust-service.ts`. | | Capability-specific reputation | Partial | `services/trust-graph/src/db/migrations/003_capability_scoring.sql`, `services/trust-graph/src/services/capability-scoring.ts`. | | Context-specific trust scoring | Partial | Trust edges include optional capability/context, but no full v2 scoring component model. | diff --git a/docs/protocol/dht-discovery.md b/docs/protocol/dht-discovery.md index eff9325..7f2ea64 100644 --- a/docs/protocol/dht-discovery.md +++ b/docs/protocol/dht-discovery.md @@ -31,4 +31,10 @@ publishes are accepted only as local mock records and are marked unverified. returning them. Expired, tampered, or AgentCard-hash-mismatched pointers are reported as rejected pointers and do not become authority. +The package-level `DHTDiscoveryProvider` also rejects invalid pointers before +returning candidates. Tampered pointer hashes, expired pointers, AgentCard hash +mismatches, and locally revoked agent IDs are filtered out. A returned DHT +candidate therefore only means "this signed pointer resolved to this AgentCard"; +it still does not grant trust or authority. + The in-memory DHT simulator is local mock infrastructure. A libp2p/Kademlia adapter should implement the same provider contract later. diff --git a/packages/discovery/src/dht-provider.ts b/packages/discovery/src/dht-provider.ts index 3a990b0..59f623d 100644 --- a/packages/discovery/src/dht-provider.ts +++ b/packages/discovery/src/dht-provider.ts @@ -31,9 +31,16 @@ export class DHTDiscoveryProvider implements DiscoveryProvider { private pointerStore = new Map() // Replication factor private replicationFactor: number + private isRevoked: (agentId: string) => boolean | Promise - constructor(options?: { replicationFactor?: number }) { + constructor(options?: { + replicationFactor?: number + revokedAgentIds?: Iterable + isRevoked?: (agentId: string) => boolean | Promise + }) { this.replicationFactor = options?.replicationFactor ?? 3 + const revokedAgentIds = new Set(options?.revokedAgentIds ?? []) + this.isRevoked = options?.isRevoked ?? ((agentId: string) => revokedAgentIds.has(agentId)) } async resolve(did: string): Promise { @@ -61,18 +68,22 @@ export class DHTDiscoveryProvider implements DiscoveryProvider { const pointers = await this.findPointers(query.capability) const candidates: DiscoveryCandidate[] = [] for (const pointer of pointers) { + const pointerVerification = await verifyDHTPointerRecord(pointer) + if (!pointerVerification.valid) continue + if (await this.isRevoked(pointer.agent_id)) continue + const card = await this.resolve(pointer.agent_id) if (!card || !cardSupportsCapability(card, query.capability)) continue const verification = await verifyDHTPointerRecord(pointer, { card }) + if (!verification.valid) continue + candidates.push(createDiscoveryCandidate({ provider: this.name, card, capability: query.capability, - verified: verification.valid, - rank: verification.valid ? 50 : 0, - explanations: verification.valid - ? ['DHT returned a signed capability pointer; DHT is not an authority source'] - : ['DHT pointer failed verification'], + verified: true, + rank: 50, + explanations: ['DHT returned a signed capability pointer; DHT is not an authority source'], errors: [], })) } diff --git a/packages/discovery/test/dht-provider.test.ts b/packages/discovery/test/dht-provider.test.ts index 124613f..472ef66 100644 --- a/packages/discovery/test/dht-provider.test.ts +++ b/packages/discovery/test/dht-provider.test.ts @@ -70,4 +70,71 @@ describe('DHTDiscoveryProvider', () => { expect(candidates).toEqual([]) }) + + it('rejects tampered DHT pointers', async () => { + const { signedCard, pointer } = await fixture() + const provider = new DHTDiscoveryProvider() + + await provider.register(signedCard) + await provider.publishPointer({ + ...pointer, + capability_hash: 'sha256:tampered', + }) + + const candidates = await provider.discover(createDiscoveryQuery({ + capability: 'invoice.reconcile', + })) + + expect(candidates).toEqual([]) + }) + + it('rejects expired DHT pointers', async () => { + const { signedCard, pointer } = await fixture() + const provider = new DHTDiscoveryProvider() + + await provider.register(signedCard) + await provider.publishPointer({ + ...pointer, + expires_at: '2000-01-01T00:00:00.000Z', + }) + + const candidates = await provider.discover(createDiscoveryQuery({ + capability: 'invoice.reconcile', + })) + + expect(candidates).toEqual([]) + }) + + it('rejects DHT pointers whose AgentCard hash does not match', async () => { + const { signedCard, pointer } = await fixture() + const provider = new DHTDiscoveryProvider() + + await provider.register(signedCard) + await provider.publishPointer({ + ...pointer, + agent_card_hash: 'sha256:mismatch', + }) + + const candidates = await provider.discover(createDiscoveryQuery({ + capability: 'invoice.reconcile', + })) + + expect(candidates).toEqual([]) + }) + + it('rejects revoked agents before returning DHT candidates', async () => { + const { signedCard, pointer } = await fixture() + const provider = new DHTDiscoveryProvider({ + revokedAgentIds: [signedCard.payload.id], + }) + + await provider.register(signedCard) + await provider.publishPointer(pointer) + + const candidates = await provider.discover(createDiscoveryQuery({ + capability: 'invoice.reconcile', + })) + + expect(candidates).toEqual([]) + }) }) From f546fa67ba97d0ebdfe5dc3c6bd2379afe42d4e8 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:30:26 +0300 Subject: [PATCH 072/282] feat(discovery): filter candidates by protocol version --- docs/inspection/cross-repo-primitive-map.md | 2 +- docs/inspection/fides-report.md | 2 +- docs/protocol/discovery.md | 7 ++- docs/protocol/version-negotiation.md | 6 ++ packages/core/src/discovery.ts | 22 +++++++ packages/core/test/versioning.test.ts | 28 +++++++++ packages/discovery/src/orchestrator.ts | 25 +++++++- packages/discovery/test/orchestrator.test.ts | 65 +++++++++++++++++++- 8 files changed, 151 insertions(+), 6 deletions(-) diff --git a/docs/inspection/cross-repo-primitive-map.md b/docs/inspection/cross-repo-primitive-map.md index c86c215..3043aeb 100644 --- a/docs/inspection/cross-repo-primitive-map.md +++ b/docs/inspection/cross-repo-primitive-map.md @@ -55,7 +55,7 @@ This map reflects the current local inspection of: | TEE | Mock/HTTP adapter boundary | not found | not found | not found | not found | FIDES | adapter-ready | | Privacy/redaction | evidence privacy modes | not found | credential encryption | profile/spec only | payment privacy primitives | FIDES + Sardis prior art | extend existing | | Error vocabulary | broad error classes | `AgitError` | error responses | error taxonomy | exception/reason codes | FIDES + OAPS | extend existing | -| Version negotiation | missing/partial | not found | version fields | negotiateVersion | version fields | OAPS | create new | +| Version negotiation | core record + discovery filters | not found | version fields | negotiateVersion | version fields | FIDES + OAPS | extend existing | | Kill switch | `packages/runtime`, CLI/agentd | not found | not found | revoke/fail-closed only | payment kill switch | FIDES + Sardis | extend existing | | Guardrails | guard/policy | guard chain/blast radius | not core | policy/approval | pre-execution pipeline | FIDES + Sardis + AGIT | extend existing | | Service lifecycle | agentd/services | not found | discover/provision/rotate/deprovision | not core | project provisioning payment-adjacent | OSP | adapter-ready | diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index 1a7d485..72a09b5 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -73,7 +73,7 @@ Local evidence: | Approval primitives | Partial | Guard and policy can require approval; I could not find first-class ApprovalRequest/ApprovalDecision protocol objects in core. | | Kill switch | Present | `packages/runtime/src/index.ts`, `packages/cli/src/commands/killswitch.ts`, `services/agentd/src/index.ts`. | | Evidence privacy | Present, basic | `packages/evidence/src/index.ts` supports public/private/redacted/hash-only export modes. | -| Version negotiation | Missing/partial | Shared errors include `VersioningError`, but no full VersionNegotiationRecord or discovery downgrade flow was found. | +| Version negotiation | Present | `packages/core/src/versioning.ts`, `packages/core/src/discovery.ts`, and `packages/discovery/src/orchestrator.ts` negotiate and filter discovery candidates by protocol compatibility. | | Typed errors | Partial | `packages/shared/src/errors.ts` has broad classes, but not stable code/category/severity/retryable envelopes. | | Explainability | Partial | Guard and policy return factors/explanations in `packages/guard/src/index.ts` and `packages/policy/src/index.ts`. | | Adversarial simulation | Present as test, incomplete harness | `tests/adversarial/adversarial.test.ts`; no `agentd simulate adversarial` command found. | diff --git a/docs/protocol/discovery.md b/docs/protocol/discovery.md index cb29dcf..a9071e1 100644 --- a/docs/protocol/discovery.md +++ b/docs/protocol/discovery.md @@ -28,7 +28,12 @@ Current implementation anchors: 9. Return ranked candidates with explanations. 10. Emit evidence. -The current implementation supports capability-query providers and candidate explanations. Trust/policy/evidence integration remains an incremental hardening area. +The package-level `DiscoveryOrchestrator` supports capability-query providers, +candidate explanations, provider scoping, ranking, and protocol version +negotiation. Incompatible provider or legacy DID-resolution candidates are +filtered before ranking and compatible candidates carry a +`versionNegotiation` record. Trust/policy/evidence integration remains an +incremental hardening area. Root `agentd` local, well-known, registry, relay, locally resolvable DHT, and local mock federation discovery now apply protocol version negotiation before diff --git a/docs/protocol/version-negotiation.md b/docs/protocol/version-negotiation.md index ad0cde7..0fac75c 100644 --- a/docs/protocol/version-negotiation.md +++ b/docs/protocol/version-negotiation.md @@ -6,6 +6,8 @@ Current implementation anchors: - `packages/core/src/versioning.ts` - `packages/core/src/protocol.ts` +- `packages/core/src/discovery.ts` +- `packages/discovery/src/orchestrator.ts` ## Required Fields @@ -23,6 +25,10 @@ Current implementation anchors: 4. Reject with `VERSION_INCOMPATIBLE` when no overlap exists. Version compatibility is a discovery filter. It does not grant authority. +The package-level `DiscoveryOrchestrator` attaches a +`VersionNegotiationRecord` to compatible candidates and filters incompatible +provider or legacy DID-resolution candidates before ranking them. + In root `agentd` discovery, incompatible local AgentCards are excluded from active local, well-known, registry, relay, and locally resolvable DHT results. They are surfaced as `rejectedCandidates`, `rejectedRecords`, or diff --git a/packages/core/src/discovery.ts b/packages/core/src/discovery.ts index 3ac05f1..c3a454a 100644 --- a/packages/core/src/discovery.ts +++ b/packages/core/src/discovery.ts @@ -1,5 +1,7 @@ import type { AgentCard } from './agent-card.js' import type { ErrorEnvelope } from './errors.js' +import { FIDES_PROTOCOL_VERSION } from './protocol.js' +import { negotiateProtocolVersion, type VersionNegotiationRecord } from './versioning.js' export interface DiscoveryQuery { schema_version: 'fides.discovery_query.v1' @@ -24,6 +26,7 @@ export interface DiscoveryCandidate { rank: number explanations: string[] errors: ErrorEnvelope[] + versionNegotiation?: VersionNegotiationRecord } export function createDiscoveryQuery(input: Omit & { id?: string }): DiscoveryQuery { @@ -54,6 +57,7 @@ export function createDiscoveryCandidate(input: { rank?: number explanations?: string[] errors?: ErrorEnvelope[] + versionNegotiation?: VersionNegotiationRecord }): DiscoveryCandidate { return { schema_version: 'fides.discovery_candidate.v1', @@ -65,5 +69,23 @@ export function createDiscoveryCandidate(input: { rank: input.rank ?? 0, explanations: input.explanations ?? [], errors: input.errors ?? [], + ...(input.versionNegotiation !== undefined && { versionNegotiation: input.versionNegotiation }), } } + +export function negotiateDiscoveryCandidateVersion( + query: Pick, + card: AgentCard +): VersionNegotiationRecord { + return negotiateProtocolVersion({ + localSupported: query.supported_versions, + localRequired: query.required_versions, + peerSupported: card.protocolVersions?.length ? card.protocolVersions : [FIDES_PROTOCOL_VERSION], + peerRequired: getCardRequiredVersions(card), + }) +} + +function getCardRequiredVersions(card: AgentCard): string[] | undefined { + const value = (card as unknown as { required_versions?: unknown }).required_versions + return Array.isArray(value) ? value.map(String) : undefined +} diff --git a/packages/core/test/versioning.test.ts b/packages/core/test/versioning.test.ts index 8b33ebf..a5ed3d8 100644 --- a/packages/core/test/versioning.test.ts +++ b/packages/core/test/versioning.test.ts @@ -5,6 +5,8 @@ import { negotiateProtocolVersion, } from '../src/versioning.js' import { FIDES_PROTOCOL_VERSION } from '../src/protocol.js' +import { createDiscoveryQuery, negotiateDiscoveryCandidateVersion } from '../src/discovery.js' +import type { AgentCard } from '../src/agent-card.js' describe('version negotiation', () => { it('negotiates the first common supported version', () => { @@ -50,4 +52,30 @@ describe('version negotiation', () => { expect(record.required_versions).toEqual([FIDES_PROTOCOL_VERSION]) expect(isSupportedProtocolVersion(record.negotiated_version!)).toBe(true) }) + + it('negotiates discovery query versions against AgentCard protocol versions', () => { + const card = { + id: 'did:fides:agent', + identity: { + did: 'did:fides:agent', + publicKey: new Uint8Array(32), + keyType: 'Ed25519', + createdAt: '2026-01-01T00:00:00.000Z', + }, + capabilities: [], + endpoints: [], + policies: [], + protocolVersions: ['fides.v2.0'], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + } satisfies AgentCard + + const record = negotiateDiscoveryCandidateVersion(createDiscoveryQuery({ + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }), card) + + expect(record.compatible).toBe(true) + expect(record.negotiated_version).toBe('fides.v2.0') + }) }) diff --git a/packages/discovery/src/orchestrator.ts b/packages/discovery/src/orchestrator.ts index cc57f15..8ab4eee 100644 --- a/packages/discovery/src/orchestrator.ts +++ b/packages/discovery/src/orchestrator.ts @@ -1,6 +1,7 @@ import { cardSupportsCapability, createDiscoveryCandidate, + negotiateDiscoveryCandidateVersion, type AgentCard, type DiscoveryCandidate, type DiscoveryQuery, @@ -22,19 +23,22 @@ export class DiscoveryOrchestrator { try { if (provider.discover) { - candidates.push(...await provider.discover(query)) + candidates.push(...filterVersionCompatibleCandidates(query, await provider.discover(query))) continue } if (query.requester_agent_id) { const card = await provider.resolve(query.requester_agent_id) if (card && cardSupportsCapability(card, query.capability)) { + const versionNegotiation = negotiateDiscoveryCandidateVersion(query, card) + if (!versionNegotiation.compatible) continue candidates.push(createDiscoveryCandidate({ provider: provider.name, card, capability: query.capability, verified: false, explanations: ['Resolved through legacy DID provider path'], + versionNegotiation, })) } } @@ -85,3 +89,22 @@ export class DiscoveryOrchestrator { } } } + +function filterVersionCompatibleCandidates( + query: DiscoveryQuery, + candidates: DiscoveryCandidate[] +): DiscoveryCandidate[] { + return candidates.flatMap((candidate) => { + const versionNegotiation = candidate.versionNegotiation ?? negotiateDiscoveryCandidateVersion(query, candidate.card) + if (!versionNegotiation.compatible) return [] + return [{ + ...candidate, + versionNegotiation, + errors: candidate.errors.filter(error => error.code !== 'VERSION_INCOMPATIBLE'), + explanations: [ + ...candidate.explanations, + `Protocol version ${versionNegotiation.negotiated_version} is compatible`, + ], + }] + }) +} diff --git a/packages/discovery/test/orchestrator.test.ts b/packages/discovery/test/orchestrator.test.ts index e26c1f6..4044723 100644 --- a/packages/discovery/test/orchestrator.test.ts +++ b/packages/discovery/test/orchestrator.test.ts @@ -75,14 +75,18 @@ describe('DiscoveryOrchestrator', () => { }) it('discovers capability candidates through provider discover implementations', async () => { + const card = { + ...mockCard, + protocolVersions: ['fides.v2.0'], + } const provider: DiscoveryProvider = { name: 'query-provider', resolve: vi.fn(), discover: vi.fn().mockResolvedValue([{ schema_version: 'fides.discovery_candidate.v1', provider: 'query-provider', - agentId: mockCard.id, - card: mockCard, + agentId: card.id, + card, capability: 'calendar.schedule', verified: true, rank: 10, @@ -96,12 +100,47 @@ describe('DiscoveryOrchestrator', () => { expect(candidates).toHaveLength(1) expect(provider.discover).toHaveBeenCalledWith(query) + expect(candidates[0].versionNegotiation).toMatchObject({ + compatible: true, + negotiated_version: 'fides.v2.0', + }) + expect(candidates[0].explanations).toContain('Protocol version fides.v2.0 is compatible') + }) + + it('filters provider candidates with incompatible protocol versions', async () => { + const provider: DiscoveryProvider = { + name: 'query-provider', + resolve: vi.fn(), + discover: vi.fn().mockResolvedValue([{ + schema_version: 'fides.discovery_candidate.v1', + provider: 'query-provider', + agentId: mockCard.id, + card: { + ...mockCard, + protocolVersions: ['fides.v1'], + }, + capability: 'calendar.schedule', + verified: true, + rank: 10, + explanations: ['matched'], + errors: [], + }]), + } + + const candidates = await new DiscoveryOrchestrator([provider]).discover(createDiscoveryQuery({ + capability: 'calendar.schedule', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + })) + + expect(candidates).toEqual([]) }) it('falls back to legacy DID resolution when requester_agent_id is present', async () => { const card: AgentCard = { ...mockCard, capabilities: [createCapabilityDescriptor({ id: 'calendar.schedule' })], + protocolVersions: ['fides.v2.0'], } const provider: DiscoveryProvider = { name: 'legacy-provider', @@ -120,6 +159,28 @@ describe('DiscoveryOrchestrator', () => { capability: 'calendar.schedule', verified: false, }) + expect(candidates[0].versionNegotiation?.compatible).toBe(true) + }) + + it('filters legacy DID resolution candidates with incompatible protocol versions', async () => { + const card: AgentCard = { + ...mockCard, + capabilities: [createCapabilityDescriptor({ id: 'calendar.schedule' })], + protocolVersions: ['fides.v1'], + } + const provider: DiscoveryProvider = { + name: 'legacy-provider', + resolve: vi.fn().mockResolvedValue(card), + } + + const candidates = await new DiscoveryOrchestrator([provider]).discover(createDiscoveryQuery({ + capability: 'calendar.schedule', + requester_agent_id: card.id, + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + })) + + expect(candidates).toEqual([]) }) it('local provider discovers registered cards by capability', async () => { From a4a4081752afd547b3c471450638fdedece8cde9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:33:59 +0300 Subject: [PATCH 073/282] feat(evidence): add merkle inclusion proofs --- docs/inspection/cross-repo-primitive-map.md | 2 +- docs/protocol/evidence-ledger.md | 8 +- packages/evidence/README.md | 14 +++- packages/evidence/src/index.ts | 81 ++++++++++++++++++++- packages/evidence/test/evidence.test.ts | 51 +++++++++++++ 5 files changed, 150 insertions(+), 6 deletions(-) diff --git a/docs/inspection/cross-repo-primitive-map.md b/docs/inspection/cross-repo-primitive-map.md index 3043aeb..485e682 100644 --- a/docs/inspection/cross-repo-primitive-map.md +++ b/docs/inspection/cross-repo-primitive-map.md @@ -48,7 +48,7 @@ This map reflects the current local inspection of: | ApprovalDecision | missing first-class core | `approval.rs` | HITL spec | core approvals | approval flow | OAPS + Sardis | create new | | EvidenceEvent | `packages/evidence` | commits/events/audit | webhook events | hash-linked evidence | evidence export/hash-chain | FIDES + OAPS + AGIT | extend existing | | Hash chain | `packages/evidence` | strong lineage/hash prior art | not generic | evidence package | policy hash-chain | FIDES + AGIT | extend existing | -| Merkle proof | Merkle root only | Merkle/state diff concepts | not found | not found | ledger anchor | AGIT + Sardis | create new | +| Merkle proof | Merkle root + inclusion proof helpers | Merkle/state diff concepts | not found | not found | ledger anchor | FIDES + AGIT | extend existing | | Revocation | `packages/core/src/revocation.ts`, services | not core | docs/spec | revoke flow | identity/payment revocation | FIDES | extend existing | | Incident | `packages/core/src/revocation.ts`, services | not core | not found | not found | payment/trust context | FIDES | extend existing | | Runtime attestation | `packages/runtime` | not found | not found | not found | not generic | FIDES | extend existing | diff --git a/docs/protocol/evidence-ledger.md b/docs/protocol/evidence-ledger.md index a1e2f1a..c43fb08 100644 --- a/docs/protocol/evidence-ledger.md +++ b/docs/protocol/evidence-ledger.md @@ -23,4 +23,10 @@ Current root `agentd` mutations append hash-only lifecycle evidence for: ## Integrity -Each event links to the previous event hash. Verification detects broken chains. Export should preserve enough metadata to audit without leaking sensitive inputs or outputs. +Each event links to the previous event hash. Verification detects broken +chains. The evidence package can also compute Merkle roots and generate +Merkle inclusion proofs for individual events, so an exported event can be +verified against an anchored root without disclosing the entire log. + +Export should preserve enough metadata to audit without leaking sensitive +inputs or outputs. diff --git a/packages/evidence/README.md b/packages/evidence/README.md index a360ee8..09dbecb 100644 --- a/packages/evidence/README.md +++ b/packages/evidence/README.md @@ -2,7 +2,9 @@ Tamper-evident evidence chains for FIDES. -This package provides hash-chained evidence events, Merkle root computation, privacy levels, and verification helpers for audit trails produced by autonomous agents and trust services. +This package provides hash-chained evidence events, Merkle root computation, +Merkle inclusion proofs, privacy levels, and verification helpers for audit +trails produced by autonomous agents and trust services. ## Installation @@ -13,7 +15,13 @@ npm install @fides/evidence ## Usage ```typescript -import { appendEvidenceEvent, createEvidenceChain, verifyEvidenceChain } from '@fides/evidence' +import { + appendEvidenceEvent, + buildEvidenceMerkleProof, + createEvidenceChain, + verifyEvidenceChain, + verifyMerkleProof, +} from '@fides/evidence' let chain = createEvidenceChain() @@ -28,6 +36,8 @@ chain = appendEvidenceEvent(chain, { }, 'signature-hex') const valid = verifyEvidenceChain(chain) +const proof = buildEvidenceMerkleProof(chain, 'evt_1') +const included = verifyMerkleProof(proof) ``` ## License diff --git a/packages/evidence/src/index.ts b/packages/evidence/src/index.ts index 4e79dde..40dbbb8 100644 --- a/packages/evidence/src/index.ts +++ b/packages/evidence/src/index.ts @@ -32,6 +32,18 @@ export interface EvidenceChain { merkleRoot?: string } +export interface MerkleProofStep { + position: 'left' | 'right' + hash: string +} + +export interface MerkleProof { + leafHash: string + leafIndex: number + root: string + steps: MerkleProofStep[] +} + export type EvidenceEventType = | 'agent.registered' | 'agent.updated' @@ -213,8 +225,7 @@ export function buildMerkleRoot(eventHashes: string[]): string { const nextLevel: string[] = [] for (let i = 0; i < level.length; i += 2) { if (i + 1 < level.length) { - const combined = level[i] + level[i + 1] - nextLevel.push(bytesToHex(sha256(new TextEncoder().encode(combined)))) + nextLevel.push(hashMerklePair(level[i], level[i + 1])) } else { nextLevel.push(level[i]) } @@ -224,6 +235,72 @@ export function buildMerkleRoot(eventHashes: string[]): string { return level[0] } +export function buildMerkleProof(eventHashes: string[], leafIndex: number): MerkleProof { + if (eventHashes.length === 0) { + throw new Error('Cannot build a Merkle proof for an empty tree') + } + if (!Number.isInteger(leafIndex) || leafIndex < 0 || leafIndex >= eventHashes.length) { + throw new Error('Merkle proof leafIndex is out of range') + } + + let index = leafIndex + let level = [...eventHashes] + const steps: MerkleProofStep[] = [] + + while (level.length > 1) { + const siblingIndex = index % 2 === 0 ? index + 1 : index - 1 + if (siblingIndex < level.length) { + steps.push({ + position: siblingIndex < index ? 'left' : 'right', + hash: level[siblingIndex], + }) + } + + const nextLevel: string[] = [] + for (let i = 0; i < level.length; i += 2) { + if (i + 1 < level.length) { + nextLevel.push(hashMerklePair(level[i], level[i + 1])) + } else { + nextLevel.push(level[i]) + } + } + index = Math.floor(index / 2) + level = nextLevel + } + + return { + leafHash: eventHashes[leafIndex], + leafIndex, + root: level[0], + steps, + } +} + +export function buildEvidenceMerkleProof(chain: EvidenceChain, eventId: string): MerkleProof { + const leafIndex = chain.events.findIndex(event => event.id === eventId) + if (leafIndex === -1) { + throw new Error(`Evidence event not found in chain: ${eventId}`) + } + return buildMerkleProof(chain.events.map(event => event.hash), leafIndex) +} + +export function verifyMerkleProof(proof: MerkleProof): boolean { + if (!proof.leafHash || !proof.root || proof.leafIndex < 0 || !Number.isInteger(proof.leafIndex)) { + return false + } + let computed = proof.leafHash + for (const step of proof.steps) { + computed = step.position === 'left' + ? hashMerklePair(step.hash, computed) + : hashMerklePair(computed, step.hash) + } + return computed === proof.root +} + +function hashMerklePair(left: string, right: string): string { + return bytesToHex(sha256(new TextEncoder().encode(left + right))) +} + /** * Compute the Merkle root of an evidence chain. */ diff --git a/packages/evidence/test/evidence.test.ts b/packages/evidence/test/evidence.test.ts index 2765624..34f0c84 100644 --- a/packages/evidence/test/evidence.test.ts +++ b/packages/evidence/test/evidence.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest' import { appendEvidenceEvent, appendEvidenceEventV2, + buildEvidenceMerkleProof, + buildMerkleProof, createEvidenceChain, createEvidenceEventV2, hashEvidenceValue, @@ -10,6 +12,7 @@ import { verifyEvidenceChain, verifyEvidenceEventV2, verifyEvidenceEventsV2, + verifyMerkleProof, } from '../src/index.js' import type { EvidenceEvent } from '../src/index.js' import { createAgentIdentity } from '@fides/core' @@ -48,6 +51,54 @@ describe('Evidence Ledger', () => { expect(verifyEvidenceChain(chain)).toBe(true) }) + it('builds and verifies Merkle inclusion proofs for evidence chains', () => { + let chain = createEvidenceChain() + chain = appendEvidenceEvent(chain, { + id: 'evt_1', + type: 'invocation', + timestamp: '2026-05-29T00:00:00.000Z', + actor: 'did:fides:alice', + action: 'read', + payload: { file: 'doc1' }, + privacy: { level: 'hash-only' }, + }, 'sig1') + chain = appendEvidenceEvent(chain, { + id: 'evt_2', + type: 'policy', + timestamp: '2026-05-29T00:00:01.000Z', + actor: 'did:fides:policy', + action: 'evaluate', + payload: { decision: 'allow' }, + privacy: { level: 'hash-only' }, + }, 'sig2') + chain = appendEvidenceEvent(chain, { + id: 'evt_3', + type: 'invocation', + timestamp: '2026-05-29T00:00:02.000Z', + actor: 'did:fides:bob', + action: 'write', + payload: { file: 'doc2' }, + privacy: { level: 'hash-only' }, + }, 'sig3') + + const proof = buildEvidenceMerkleProof(chain, 'evt_2') + + expect(proof.leafHash).toBe(chain.events[1].hash) + expect(proof.root).toBe(chain.merkleRoot) + expect(verifyMerkleProof(proof)).toBe(true) + expect(verifyMerkleProof({ ...proof, leafHash: 'tampered' })).toBe(false) + }) + + it('builds Merkle proofs from raw event hashes', () => { + const hashes = ['h1', 'h2', 'h3', 'h4'] + const proof = buildMerkleProof(hashes, 3) + + expect(proof.leafHash).toBe('h4') + expect(proof.leafIndex).toBe(3) + expect(verifyMerkleProof(proof)).toBe(true) + expect(verifyMerkleProof({ ...proof, steps: proof.steps.slice(1) })).toBe(false) + }) + it('should detect tampered chain', () => { let chain = createEvidenceChain() chain = appendEvidenceEvent(chain, { From faa3953c6dc506e41bd7cd4fc9e181e642c305bc Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:36:24 +0300 Subject: [PATCH 074/282] feat(policy): hash policy decision objects --- docs/protocol/policy-engine.md | 23 +++++++++++ packages/policy/README.md | 55 +++++++++++++++++++------- packages/policy/src/index.ts | 23 +++++++++-- packages/policy/test/policy-v2.test.ts | 31 +++++++++++++++ 4 files changed, 114 insertions(+), 18 deletions(-) diff --git a/docs/protocol/policy-engine.md b/docs/protocol/policy-engine.md index d0021e1..09ddc3c 100644 --- a/docs/protocol/policy-engine.md +++ b/docs/protocol/policy-engine.md @@ -21,3 +21,26 @@ Current implementation anchors: Policy evaluates principal, requester agent, target agent, capability, trust result, reputation result, runtime attestation, revocation status, incidents, kill switch rules, scopes, and constraints. Every decision returns machine-readable reasons, human-readable reasons, required controls, and evidence refs. No decision should be only a boolean. + +## PolicyDecision object + +`packages/policy/src/index.ts` emits `fides.policy.decision.v1` records as canonical-hashable protocol objects: + +- `id` +- `issuer` +- `subject` +- `principal_id` +- `requester_agent_id` +- `target_agent_id` +- `capability` +- `decision` +- `reason_codes` +- `machine_reasons` +- `human_reasons` +- `required_controls` +- `evidence_refs` +- `issued_at` +- `evaluated_at` +- `payload_hash` + +The `payload_hash` is computed with the shared FIDES canonical JSON digest. A policy engine, daemon, or registry can wrap the decision with the canonical object signing model; downstream session grants and evidence events can then reference the exact policy decision hash. diff --git a/packages/policy/README.md b/packages/policy/README.md index 316519a..7c0ec72 100644 --- a/packages/policy/README.md +++ b/packages/policy/README.md @@ -2,7 +2,9 @@ Deterministic policy evaluation for FIDES. -This package evaluates policy bundles against request and trust context before an agent action executes. Decisions are explicit: `allow`, `deny`, `approve-required`, or `dry-run`. +This package evaluates policy bundles against request and trust context before an agent action executes. Decisions are explicit: `allow`, `deny`, `require_approval`, `dry_run_only`, `scope_limit`, or `risk_limit`. + +FIDES v2 policy decisions are protocol objects. The v2 evaluator returns `id`, `issuer`, `subject`, `issued_at`, machine-readable reasons, human-readable reasons, required controls, evidence refs, and a canonical `payload_hash` so the decision can be signed by the shared FIDES canonical object signing model. ## Installation @@ -13,20 +15,43 @@ npm install @fides/policy ## Usage ```typescript -import { evaluatePolicy } from '@fides/policy' - -const result = evaluatePolicy({ - id: 'default', - version: '1.0.0', - defaultAction: 'deny', - rules: [ - { - id: 'trusted-agent', - condition: { field: 'reputationScore', operator: 'gte', value: 0.8 }, - action: 'allow', - }, - ], -}, { reputationScore: 0.91 }) +import { evaluateFidesPolicy } from '@fides/policy' + +const decision = evaluateFidesPolicy({ + issuerId: 'did:fides:policy-engine', + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:invoice-agent', + capability: { + id: 'invoice.reconcile', + namespace: 'invoice', + action: 'reconcile', + name: 'Invoice reconciliation', + description: 'Match invoice data against records', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object' }, + riskLevel: 'medium', + requiresApproval: false, + requiresRuntimeAttestation: false, + requiredScopes: ['invoice:read'], + supportedControls: ['dry_run', 'human_approval', 'scope_limit'], + }, + trustResult: { + schema_version: 'fides.trust.result.v1', + agent_id: 'did:fides:invoice-agent', + capability: 'invoice.reconcile', + score: 0.82, + band: 'high', + reasons: [], + risk_flags: [], + evidence_refs: ['evt_1'], + required_controls: [], + computed_at: new Date().toISOString(), + }, + requestedScopes: ['invoice:read'], +}) + +console.log(decision.decision, decision.payload_hash) ``` ## License diff --git a/packages/policy/src/index.ts b/packages/policy/src/index.ts index 1cf846c..75319f8 100644 --- a/packages/policy/src/index.ts +++ b/packages/policy/src/index.ts @@ -1,4 +1,4 @@ -import type { CapabilityControl, CapabilityDescriptor, TrustResult } from '@fides/core' +import { hashProtocolPayload, type CapabilityControl, type CapabilityDescriptor, type HashValue, type TrustResult } from '@fides/core' /** * FIDES v2 Policy Engine @@ -58,6 +58,9 @@ export interface PolicyReason { export interface FidesPolicyDecision { schema_version: 'fides.policy.decision.v1' + id: string + issuer: string + subject: string decision: FidesPolicyDecisionAction principal_id: string requester_agent_id: string @@ -68,10 +71,14 @@ export interface FidesPolicyDecision { human_reasons: string[] required_controls: CapabilityControl[] evidence_refs: string[] + issued_at: string evaluated_at: string + payload_hash: HashValue } export interface FidesPolicyEvaluationInput { + issuerId?: string + decisionId?: string principalId: string requesterAgentId: string targetAgentId: string @@ -153,14 +160,18 @@ function createDecision( reasons: PolicyReason[], requiredControls: CapabilityControl[] = [] ): FidesPolicyDecision { + const evaluatedAt = input.evaluatedAt ?? new Date().toISOString() const evidenceRefs = Array.from(new Set([ ...(input.evidenceRefs ?? []), ...input.trustResult.evidence_refs, ...reasons.flatMap(reason => reason.evidence_refs), ])) - return { + const payload = { schema_version: 'fides.policy.decision.v1', + id: input.decisionId ?? crypto.randomUUID(), + issuer: input.issuerId ?? input.requesterAgentId, + subject: input.targetAgentId, decision, principal_id: input.principalId, requester_agent_id: input.requesterAgentId, @@ -171,7 +182,13 @@ function createDecision( human_reasons: reasons.map(reason => reason.message), required_controls: Array.from(new Set(requiredControls)), evidence_refs: evidenceRefs, - evaluated_at: input.evaluatedAt ?? new Date().toISOString(), + issued_at: evaluatedAt, + evaluated_at: evaluatedAt, + } satisfies Omit + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), } } diff --git a/packages/policy/test/policy-v2.test.ts b/packages/policy/test/policy-v2.test.ts index 63a9855..2158b8a 100644 --- a/packages/policy/test/policy-v2.test.ts +++ b/packages/policy/test/policy-v2.test.ts @@ -33,6 +33,8 @@ const trust = (band: TrustResult['band'], score: number): TrustResult => ({ describe('FIDES policy v2', () => { it('denies revoked agents before trust or capability scoring', () => { const decision = evaluateFidesPolicy({ + issuerId: 'did:fides:policy-engine', + decisionId: 'pol_decision_1', principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', targetAgentId: 'did:fides:agent', @@ -40,8 +42,14 @@ describe('FIDES policy v2', () => { trustResult: trust('verified', 0.95), requestedScopes: ['invoice:read'], revocationActive: true, + evaluatedAt: '2026-05-29T00:00:00.000Z', }) + expect(decision.id).toBe('pol_decision_1') + expect(decision.issuer).toBe('did:fides:policy-engine') + expect(decision.subject).toBe('did:fides:agent') + expect(decision.issued_at).toBe('2026-05-29T00:00:00.000Z') + expect(decision.payload_hash).toMatch(/^sha256:/) expect(decision.decision).toBe('deny') expect(decision.reason_codes).toContain('REVOCATION_ACTIVE') expect(decision.human_reasons[0]).toContain('revocation') @@ -96,4 +104,27 @@ describe('FIDES policy v2', () => { expect(decision.reason_codes).toContain('POLICY_ALLOWED') expect(decision.machine_reasons.length).toBeGreaterThan(0) }) + + it('changes payload_hash when machine-readable policy reasons change', () => { + const base = { + issuerId: 'did:fides:policy-engine', + decisionId: 'pol_decision_stable', + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:agent', + capability: capability('medium'), + trustResult: trust('high' as const, 0.72), + requestedScopes: ['invoice:read'], + evaluatedAt: '2026-05-29T00:00:00.000Z', + } + + const allowed = evaluateFidesPolicy(base) + const scopeLimited = evaluateFidesPolicy({ + ...base, + requestedScopes: [], + }) + + expect(allowed.payload_hash).not.toBe(scopeLimited.payload_hash) + expect(scopeLimited.reason_codes).toContain('SESSION_SCOPE_INVALID') + }) }) From 17861e36fcccc18969e305870848a71077ace490 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:38:06 +0300 Subject: [PATCH 075/282] feat(core): add approval object issuers --- docs/inspection/cross-repo-primitive-map.md | 4 ++-- docs/protocol/approvals.md | 10 ++++++++-- packages/core/src/approval.ts | 8 ++++++++ packages/core/test/approval.test.ts | 6 ++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/inspection/cross-repo-primitive-map.md b/docs/inspection/cross-repo-primitive-map.md index 485e682..94959f8 100644 --- a/docs/inspection/cross-repo-primitive-map.md +++ b/docs/inspection/cross-repo-primitive-map.md @@ -44,8 +44,8 @@ This map reflects the current local inspection of: | PolicyBundle | `packages/policy` | guard chain | not core | policy package | policy DSL/pipeline | FIDES + OAPS | extend existing | | Policy engine | `packages/policy`, `services/policy-engine`, guard | guards/blast radius | not core | fail-closed evaluator | pre-execution pipeline | FIDES + Sardis | extend existing | | Intent | not first-class | not found | not found | foundation intent | AP2/payment intents | OAPS | create new | -| ApprovalRequest | missing first-class core | `approval.rs` | HITL spec | core approvals | approval flow | OAPS + Sardis | create new | -| ApprovalDecision | missing first-class core | `approval.rs` | HITL spec | core approvals | approval flow | OAPS + Sardis | create new | +| ApprovalRequest | `packages/core/src/approval.ts` | `approval.rs` | HITL spec | core approvals | approval flow | FIDES + OAPS + Sardis | extend existing | +| ApprovalDecision | `packages/core/src/approval.ts` | `approval.rs` | HITL spec | core approvals | approval flow | FIDES + OAPS + Sardis | extend existing | | EvidenceEvent | `packages/evidence` | commits/events/audit | webhook events | hash-linked evidence | evidence export/hash-chain | FIDES + OAPS + AGIT | extend existing | | Hash chain | `packages/evidence` | strong lineage/hash prior art | not generic | evidence package | policy hash-chain | FIDES + AGIT | extend existing | | Merkle proof | Merkle root + inclusion proof helpers | Merkle/state diff concepts | not found | not found | ledger anchor | FIDES + AGIT | extend existing | diff --git a/docs/protocol/approvals.md b/docs/protocol/approvals.md index 491e76d..eb468ff 100644 --- a/docs/protocol/approvals.md +++ b/docs/protocol/approvals.md @@ -11,8 +11,14 @@ Current implementation anchor: - `ApprovalRequest` - `ApprovalDecision` -Approval requests include requester, target, principal, capability, scopes, risk level, policy decision hash, and evidence refs. - +Approval requests include `id`, `issuer`, `subject`, requester, target, +principal, capability, scopes, risk level, policy decision hash, evidence refs, +timestamps, and canonical `payload_hash`. The request issuer is the requester +agent and the subject is the target agent. + +Approval decisions include `id`, `issuer`, `subject`, approver, decision, +constraints, evidence refs, timestamps, and canonical `payload_hash`. The +decision issuer is the approver and the subject is the approval request id. Approval decisions are signed by the approver and may include constraints. ## Evidence diff --git a/packages/core/src/approval.ts b/packages/core/src/approval.ts index 3d5bd52..314c2c1 100644 --- a/packages/core/src/approval.ts +++ b/packages/core/src/approval.ts @@ -7,6 +7,8 @@ export type KillSwitchTargetType = 'agent' | 'publisher' | 'capability' | 'sessi export interface ApprovalRequest { schema_version: 'fides.approval.request.v1' id: string + issuer: string + subject: string requester_agent_id: string target_agent_id: string principal_id: string @@ -24,6 +26,8 @@ export interface ApprovalRequest { export interface ApprovalDecision { schema_version: 'fides.approval.decision.v1' id: string + issuer: string + subject: string approval_request_id: string approver_id: string decision: ApprovalDecisionValue @@ -95,6 +99,8 @@ export function createApprovalRequest(input: CreateApprovalRequestInput): Approv return withPayloadHash({ schema_version: 'fides.approval.request.v1' as const, id: crypto.randomUUID(), + issuer: input.requesterAgentId, + subject: input.targetAgentId, requester_agent_id: input.requesterAgentId, target_agent_id: input.targetAgentId, principal_id: input.principalId, @@ -113,6 +119,8 @@ export function createApprovalDecision(input: CreateApprovalDecisionInput): Appr return withPayloadHash({ schema_version: 'fides.approval.decision.v1' as const, id: crypto.randomUUID(), + issuer: input.approverId, + subject: input.approvalRequestId, approval_request_id: input.approvalRequestId, approver_id: input.approverId, decision: input.decision, diff --git a/packages/core/test/approval.test.ts b/packages/core/test/approval.test.ts index 0632439..f2f962f 100644 --- a/packages/core/test/approval.test.ts +++ b/packages/core/test/approval.test.ts @@ -28,6 +28,9 @@ describe('approval and kill switch primitives', () => { }) const signedRequest = await signApprovalRequest(request, approver.privateKey, approver.did) + expect(request.issuer).toBe('did:fides:requester') + expect(request.subject).toBe('did:fides:target') + expect(request.payload_hash).toMatch(/^sha256:/) expect(await verifySignedApprovalRequest(signedRequest)).toBe(true) const decision = createApprovalDecision({ @@ -39,6 +42,9 @@ describe('approval and kill switch primitives', () => { }) const signedDecision = await signApprovalDecision(decision, approver.privateKey, approver.did) + expect(decision.issuer).toBe(approver.did) + expect(decision.subject).toBe(request.id) + expect(decision.payload_hash).toMatch(/^sha256:/) expect(await verifySignedApprovalDecision(signedDecision)).toBe(true) }) From 979b18a24676ccfb8b936d38f33ad721bed08a59 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:39:35 +0300 Subject: [PATCH 076/282] feat(core): add invocation object subjects --- docs/protocol/delegation-and-sessions.md | 25 +++++++++++++++--------- packages/core/src/invocation.ts | 4 ++++ packages/core/test/invocation.test.ts | 6 ++++++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index ae885f6..a69d979 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -33,12 +33,19 @@ Replay protection is required through nonce tracking. An invocation must bind to a scoped `SessionGrant`. The root local daemon can accept a caller-supplied signed `InvocationRequest`; when supplied, the daemon verifies its canonical proof and checks that it matches the session, input hash, -and dry-run mode before policy preflight. The daemon validates the request body -against the capability input schema before execution and validates generated -outputs against the capability output schema before returning a successful -result. The daemon then emits hash-only evidence events, creates an -`InvocationResult`, and signs that result with the target agent identity using -the canonical object signing model. The signed request proves requester intent, -and the signed result proves that the target agent identity produced the -invocation outcome; neither proof bypasses policy, revocation, schema, or -evidence verification. +and dry-run mode before policy preflight. `InvocationRequest` includes `id`, +`issuer`, `subject`, session binding fields, capability, scopes, input hash, +optional schema hashes, `issued_at`, and `payload_hash`; the subject is the +target agent id. + +The daemon validates the request body against the capability input schema before +execution and validates generated outputs against the capability output schema +before returning a successful result. The daemon then emits hash-only evidence +events, creates an `InvocationResult`, and signs that result with the target +agent identity using the canonical object signing model. `InvocationResult` +includes `id`, `issuer`, `subject`, request id, status, output hash or error +code, evidence refs, `created_at`, and `payload_hash`; the subject is the +invocation request id. The signed request proves requester intent, and the +signed result proves that the target agent identity produced the invocation +outcome; neither proof bypasses policy, revocation, schema, or evidence +verification. diff --git a/packages/core/src/invocation.ts b/packages/core/src/invocation.ts index eb18afc..3275be5 100644 --- a/packages/core/src/invocation.ts +++ b/packages/core/src/invocation.ts @@ -15,6 +15,7 @@ export interface InvocationRequest { schema_version: 'fides.invocation.request.v1' id: string issuer: string + subject: string session_id: string requester_agent_id: string target_agent_id: string @@ -33,6 +34,7 @@ export interface InvocationResult { schema_version: 'fides.invocation.result.v1' id: string issuer: string + subject: string invocation_request_id: string status: InvocationStatus output_hash?: string @@ -92,6 +94,7 @@ export function createInvocationRequest(input: InvocationRequestInput): Invocati schema_version: 'fides.invocation.request.v1' as const, id: crypto.randomUUID(), issuer: input.issuer, + subject: input.sessionGrant.target_agent_id, session_id: input.sessionGrant.session_id, requester_agent_id: input.sessionGrant.requester_agent_id, target_agent_id: input.sessionGrant.target_agent_id, @@ -116,6 +119,7 @@ export function createInvocationResult(input: InvocationResultInput): Invocation schema_version: 'fides.invocation.result.v1' as const, id: crypto.randomUUID(), issuer: input.issuer, + subject: input.invocationRequestId, invocation_request_id: input.invocationRequestId, status: input.status, output_hash: input.output === undefined ? undefined : hashProtocolPayload(input.output), diff --git a/packages/core/test/invocation.test.ts b/packages/core/test/invocation.test.ts index a810d2b..13481c0 100644 --- a/packages/core/test/invocation.test.ts +++ b/packages/core/test/invocation.test.ts @@ -42,6 +42,9 @@ describe('invocation protocol objects', () => { expect(request.input_hash).toMatch(/^sha256:/) expect(request.output_schema_hash).toBeUndefined() + expect(request.issuer).toBe(requester.did) + expect(request.subject).toBe('did:fides:target') + expect(request.payload_hash).toMatch(/^sha256:/) const signed = await signInvocationRequest(request, requester.privateKey, requester.did) expect(await verifySignedInvocationRequest(signed)).toBe(true) @@ -79,6 +82,9 @@ describe('invocation protocol objects', () => { }) expect(result.output_hash).toMatch(/^sha256:/) + expect(result.issuer).toBe(target.did) + expect(result.subject).toBe('inv_req_1') + expect(result.payload_hash).toMatch(/^sha256:/) const signed = await signInvocationResult(result, target.privateKey, target.did) expect(await verifySignedInvocationResult(signed)).toBe(true) }) From e7c40aad25cddbff30642c3df800cd9a16bff179 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:42:14 +0300 Subject: [PATCH 077/282] feat(core): apply capability ontology defaults --- docs/inspection/cross-repo-primitive-map.md | 2 +- docs/inspection/fides-report.md | 4 +-- docs/protocol/capability-ontology.md | 6 ++++ packages/core/src/capability.ts | 32 ++++++++++++------ packages/core/test/capability.test.ts | 36 +++++++++++++++++++-- 5 files changed, 65 insertions(+), 15 deletions(-) diff --git a/docs/inspection/cross-repo-primitive-map.md b/docs/inspection/cross-repo-primitive-map.md index 94959f8..53c224d 100644 --- a/docs/inspection/cross-repo-primitive-map.md +++ b/docs/inspection/cross-repo-primitive-map.md @@ -31,7 +31,7 @@ This map reflects the current local inspection of: | Trust graph | `services/trust-graph` | causal graph only | not found | not found | FIDES adapter/trust infra | FIDES | extend existing | | Reputation | capability scoring partial | not found | registry reputation metadata | not found | KYA/payment reputation | FIDES | extend existing | | Capability descriptor | `packages/core/src/capability.ts` | not found | service/capability manifest | CapabilityCard | agent auth/A2A/payment capabilities | OAPS + FIDES | extend existing | -| Capability ontology | heuristic only | blast radius/risk | service taxonomy | capability schemas/constants | risk/action patterns | OAPS | create new | +| Capability ontology | seed taxonomy + lookup helpers | blast radius/risk | service taxonomy | capability schemas/constants | risk/action patterns | FIDES + OAPS | extend existing | | AgentCard / ActorCard | `packages/core/src/agent-card.ts`, shared AgentCard | not found | service manifest | ActorCard | A2A AgentCard | FIDES + OAPS | extend existing | | Discovery | provider package/services | adapter only | service discovery | actor discovery | A2A/agent auth | FIDES | extend existing | | Well-known discovery | discovery service/provider | not found | manifest fetch | `.well-known/oaps.json` | agent auth/A2A well-known | FIDES | extend existing | diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index 72a09b5..c3f9976 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -49,8 +49,8 @@ Local evidence: | Canonical object signing | Present | `packages/core/src/canonical-signer.ts`. | | HTTP message signatures | Present in SDK | `packages/sdk/src/signing/`. | | Signed AgentCards | Partial | `packages/core/src/agent-card.ts` defines `SignedAgentCard`, discovery providers accept signed cards, but AgentCard lacks all requested v2 fields. | -| Capability descriptors | Partial | `packages/core/src/capability.ts` has id/name/schema/risk/approval/attestation; missing namespace/action/resource/control metadata. | -| Capability ontology | Missing | I could not find ontology entries beyond heuristic risk classification in `packages/core/src/capability.ts`. | +| Capability descriptors | Present, evolving | `packages/core/src/capability.ts` has id, namespace, action, resource, schemas, risk, scopes, supported controls, dry-run, approval, runtime attestation, and policy-proof metadata. | +| Capability ontology | Present, seed taxonomy | `packages/core/src/capability.ts` defines `DEFAULT_CAPABILITY_ONTOLOGY`, lookup helpers, and ontology-backed descriptor defaults before heuristic risk classification. | | Local discovery | Present | `packages/discovery/src/local-provider.ts`. | | Well-known discovery | Present | `packages/discovery/src/well-known-provider.ts`, `services/discovery/src/routes/well-known.ts`. | | Registry discovery | Present | `packages/discovery/src/registry-provider.ts`, `services/registry/src/`. | diff --git a/docs/protocol/capability-ontology.md b/docs/protocol/capability-ontology.md index 9005a90..0aa9816 100644 --- a/docs/protocol/capability-ontology.md +++ b/docs/protocol/capability-ontology.md @@ -26,6 +26,12 @@ checks, not just descriptive metadata. The local daemon rejects inputs that do not satisfy the advertised `inputSchema`, and fails the invocation if generated output does not satisfy `outputSchema`. +`createCapabilityDescriptor()` applies seed ontology defaults before falling +back to heuristic risk classification. This matters for capabilities like +`payments.prepare`: the generic FIDES ontology classifies preparation as +`high`, while `payments.execute` remains `critical` and Sardis-specific for real +payment execution authority. + ## Seed Capabilities The seed ontology includes calendar, invoice, payments, code, file, and deploy capabilities. diff --git a/packages/core/src/capability.ts b/packages/core/src/capability.ts index 5b70367..8cc3317 100644 --- a/packages/core/src/capability.ts +++ b/packages/core/src/capability.ts @@ -94,8 +94,15 @@ export function parseCapabilityId(id: string): Pick entry.id === id) +} + export function createCapabilityDescriptor(input: { id: string + namespace?: string + action?: string + resource?: string name?: string description?: string inputSchema?: JSONSchema @@ -107,28 +114,33 @@ export function createCapabilityDescriptor(input: { supportsHumanApproval?: boolean supportsPolicyProof?: boolean }): CapabilityDescriptor { + const ontologyEntry = findCapabilityOntologyEntry(input.id) const parsed = parseCapabilityId(input.id) const supportedControls = input.supportedControls ?? [ ...(input.supportsDryRun ? ['dry_run' as const] : []), ...(input.supportsHumanApproval ? ['human_approval' as const] : []), ...(input.supportsPolicyProof ? ['policy_proof' as const] : []), + ...(ontologyEntry?.supportedControls ?? []), ] + const uniqueControls = Array.from(new Set(supportedControls)) return { id: input.id, - ...parsed, + namespace: input.namespace ?? parsed.namespace ?? ontologyEntry?.namespace, + action: input.action ?? parsed.action ?? ontologyEntry?.action, + resource: input.resource ?? parsed.resource ?? ontologyEntry?.resource, name: input.name ?? input.id, - description: input.description ?? input.id, + description: input.description ?? ontologyEntry?.description ?? input.id, inputSchema: input.inputSchema ?? { type: 'object' }, outputSchema: input.outputSchema ?? { type: 'object' }, - riskLevel: input.riskLevel ?? classifyCapabilityRisk(input.id), - requiresApproval: input.supportsHumanApproval ?? supportedControls.includes('human_approval'), - requiresRuntimeAttestation: supportedControls.includes('runtime_attestation'), - requiredScopes: input.requiredScopes ?? [], - supportedControls, - supportsDryRun: input.supportsDryRun ?? supportedControls.includes('dry_run'), - supportsHumanApproval: input.supportsHumanApproval ?? supportedControls.includes('human_approval'), - supportsPolicyProof: input.supportsPolicyProof ?? supportedControls.includes('policy_proof'), + riskLevel: input.riskLevel ?? ontologyEntry?.riskClass ?? classifyCapabilityRisk(input.id), + requiresApproval: input.supportsHumanApproval ?? uniqueControls.includes('human_approval'), + requiresRuntimeAttestation: uniqueControls.includes('runtime_attestation'), + requiredScopes: input.requiredScopes ?? ontologyEntry?.defaultRequiredScopes ?? [], + supportedControls: uniqueControls, + supportsDryRun: input.supportsDryRun ?? uniqueControls.includes('dry_run'), + supportsHumanApproval: input.supportsHumanApproval ?? uniqueControls.includes('human_approval'), + supportsPolicyProof: input.supportsPolicyProof ?? uniqueControls.includes('policy_proof'), } } diff --git a/packages/core/test/capability.test.ts b/packages/core/test/capability.test.ts index f220a8d..1e6ea53 100644 --- a/packages/core/test/capability.test.ts +++ b/packages/core/test/capability.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_CAPABILITY_ONTOLOGY, classifyCapabilityRisk, createCapabilityDescriptor, + findCapabilityOntologyEntry, parseCapabilityId, } from '../src/capability.js' @@ -42,7 +43,7 @@ describe('CapabilityDescriptor', () => { }) }) - it('creates v2 capability descriptors with controls and scopes', () => { + it('creates v2 capability descriptors with explicit controls and scopes', () => { const capability = createCapabilityDescriptor({ id: 'payments.prepare', requiredScopes: ['payments:prepare'], @@ -53,7 +54,7 @@ describe('CapabilityDescriptor', () => { id: 'payments.prepare', namespace: 'payments', action: 'prepare', - riskLevel: 'critical', + riskLevel: 'high', requiredScopes: ['payments:prepare'], requiresApproval: true, requiresRuntimeAttestation: true, @@ -63,6 +64,37 @@ describe('CapabilityDescriptor', () => { }) }) + it('applies seed ontology defaults before heuristic risk classification', () => { + const capability = createCapabilityDescriptor({ id: 'payments.prepare' }) + + expect(capability).toMatchObject({ + id: 'payments.prepare', + namespace: 'payments', + action: 'prepare', + resource: 'payment', + riskLevel: 'high', + requiredScopes: ['payments:prepare'], + supportedControls: expect.arrayContaining([ + 'dry_run', + 'human_approval', + 'policy_proof', + 'runtime_attestation', + ]), + requiresApproval: true, + requiresRuntimeAttestation: true, + }) + expect(classifyCapabilityRisk('payments.prepare')).toBe('critical') + }) + + it('looks up ontology entries by capability id', () => { + expect(findCapabilityOntologyEntry('deploy.production')).toMatchObject({ + id: 'deploy.production', + riskClass: 'critical', + resource: 'deployment', + }) + expect(findCapabilityOntologyEntry('unknown.capability')).toBeUndefined() + }) + it('ships the requested seed ontology entries', () => { const ids = DEFAULT_CAPABILITY_ONTOLOGY.map(entry => entry.id) From df5b5b21eb975586dfef084a059ba15e23720b16 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:44:07 +0300 Subject: [PATCH 078/282] feat(discovery): mark candidates as non-authoritative --- docs/protocol/discovery.md | 5 +++-- packages/core/src/discovery.ts | 5 +++++ packages/discovery/src/federation-provider.ts | 4 +++- packages/discovery/src/orchestrator.ts | 6 ++++-- packages/discovery/test/orchestrator.test.ts | 6 ++++++ 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/protocol/discovery.md b/docs/protocol/discovery.md index a9071e1..3e2de35 100644 --- a/docs/protocol/discovery.md +++ b/docs/protocol/discovery.md @@ -32,8 +32,9 @@ The package-level `DiscoveryOrchestrator` supports capability-query providers, candidate explanations, provider scoping, ranking, and protocol version negotiation. Incompatible provider or legacy DID-resolution candidates are filtered before ranking and compatible candidates carry a -`versionNegotiation` record. Trust/policy/evidence integration remains an -incremental hardening area. +`versionNegotiation` record. Every returned `DiscoveryCandidate` is explicitly +marked `authority: candidate_only` and carries `evidence_refs` for audit links. +Trust/policy/evidence integration remains an incremental hardening area. Root `agentd` local, well-known, registry, relay, locally resolvable DHT, and local mock federation discovery now apply protocol version negotiation before diff --git a/packages/core/src/discovery.ts b/packages/core/src/discovery.ts index c3a454a..ad23676 100644 --- a/packages/core/src/discovery.ts +++ b/packages/core/src/discovery.ts @@ -22,9 +22,11 @@ export interface DiscoveryCandidate { agentId: string card: AgentCard capability?: string + authority: 'candidate_only' verified: boolean rank: number explanations: string[] + evidence_refs: string[] errors: ErrorEnvelope[] versionNegotiation?: VersionNegotiationRecord } @@ -56,6 +58,7 @@ export function createDiscoveryCandidate(input: { verified?: boolean rank?: number explanations?: string[] + evidenceRefs?: string[] errors?: ErrorEnvelope[] versionNegotiation?: VersionNegotiationRecord }): DiscoveryCandidate { @@ -65,9 +68,11 @@ export function createDiscoveryCandidate(input: { agentId: input.card.agent_id ?? input.card.identity.did, card: input.card, ...(input.capability !== undefined && { capability: input.capability }), + authority: 'candidate_only', verified: input.verified ?? false, rank: input.rank ?? 0, explanations: input.explanations ?? [], + evidence_refs: input.evidenceRefs ?? [], errors: input.errors ?? [], ...(input.versionNegotiation !== undefined && { versionNegotiation: input.versionNegotiation }), } diff --git a/packages/discovery/src/federation-provider.ts b/packages/discovery/src/federation-provider.ts index 0b49c0f..44b72c8 100644 --- a/packages/discovery/src/federation-provider.ts +++ b/packages/discovery/src/federation-provider.ts @@ -65,11 +65,13 @@ export class LocalFederationDiscoveryProvider implements DiscoveryProvider { candidates.push({ ...candidate, provider: this.name, + authority: 'candidate_only', verified: false, rank: candidate.rank - 1, + evidence_refs: candidate.evidence_refs ?? [], explanations: [ `Federated peer ${peer.record.payload.peer_id} returned candidate via ${candidate.provider}; federation is not authority`, - ...candidate.explanations, + ...(candidate.explanations ?? []), ], }) } diff --git a/packages/discovery/src/orchestrator.ts b/packages/discovery/src/orchestrator.ts index 8ab4eee..05635d1 100644 --- a/packages/discovery/src/orchestrator.ts +++ b/packages/discovery/src/orchestrator.ts @@ -99,10 +99,12 @@ function filterVersionCompatibleCandidates( if (!versionNegotiation.compatible) return [] return [{ ...candidate, + authority: 'candidate_only' as const, versionNegotiation, - errors: candidate.errors.filter(error => error.code !== 'VERSION_INCOMPATIBLE'), + evidence_refs: candidate.evidence_refs ?? [], + errors: (candidate.errors ?? []).filter(error => error.code !== 'VERSION_INCOMPATIBLE'), explanations: [ - ...candidate.explanations, + ...(candidate.explanations ?? []), `Protocol version ${versionNegotiation.negotiated_version} is compatible`, ], }] diff --git a/packages/discovery/test/orchestrator.test.ts b/packages/discovery/test/orchestrator.test.ts index 4044723..e1e80c9 100644 --- a/packages/discovery/test/orchestrator.test.ts +++ b/packages/discovery/test/orchestrator.test.ts @@ -104,6 +104,8 @@ describe('DiscoveryOrchestrator', () => { compatible: true, negotiated_version: 'fides.v2.0', }) + expect(candidates[0].authority).toBe('candidate_only') + expect(candidates[0].evidence_refs).toEqual([]) expect(candidates[0].explanations).toContain('Protocol version fides.v2.0 is compatible') }) @@ -157,7 +159,9 @@ describe('DiscoveryOrchestrator', () => { provider: 'legacy-provider', agentId: card.id, capability: 'calendar.schedule', + authority: 'candidate_only', verified: false, + evidence_refs: [], }) expect(candidates[0].versionNegotiation?.compatible).toBe(true) }) @@ -198,6 +202,8 @@ describe('DiscoveryOrchestrator', () => { expect(candidates).toHaveLength(1) expect(candidates[0].provider).toBe('local') + expect(candidates[0].authority).toBe('candidate_only') + expect(candidates[0].evidence_refs).toEqual([]) expect(candidates[0].explanations[0]).toContain('invoice.reconcile') }) }) From 9f8d941e710220f572c9612d5df7b4820bb43f5b Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:45:41 +0300 Subject: [PATCH 079/282] fix(identity): fail closed on unbound dids --- docs/inspection/fides-report.md | 2 +- packages/core/src/identity.ts | 21 +++++++++++++-------- packages/core/test/identity.test.ts | 6 ++++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index c3f9976..3fc7b4d 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -42,7 +42,7 @@ Local evidence: | Agent identity | Present, shallow v2 shape | `packages/core/src/identity.ts`, `packages/shared/src/types.ts`. | | Publisher identity | Present, limited verification methods | `packages/core/src/identity.ts`, `packages/core/src/domain-verifier.ts`. | | Principal identity | Present, limited | `packages/core/src/identity.ts`. | -| Domainless identity | Partial | `packages/core/src/identity.ts` supports DIDs without domain, but identity creation currently uses random bytes as public key rather than keypair issuance. | +| Domainless identity | Present | `packages/core/src/identity.ts` issues domainless `did:fides` identities from Ed25519 keypairs and deprecated DID-based construction now fails closed when the DID cannot decode to a bound public key. | | Platform-hosted identity | Partial | Shared and docs mention platform identity, but no first-class hosted identity lifecycle was found. | | Domain/org verified identity | Present for DNS TXT verification | `packages/core/src/domain-verifier.ts`, `services/discovery/src/db/migrations/003_identity_domain_verification.sql`, `services/discovery/src/db/migrations/004_organization_domain_verification.sql`. | | Trust anchors | Present | `packages/core/src/trust-anchor.ts`. | diff --git a/packages/core/src/identity.ts b/packages/core/src/identity.ts index b258c42..739b2e9 100644 --- a/packages/core/src/identity.ts +++ b/packages/core/src/identity.ts @@ -165,7 +165,12 @@ export function publicKeyFromDid(did: string): Uint8Array { throw new Error('Invalid FIDES DID') } const encoded = did.slice('did:fides:'.length) - const publicKey = bs58.decode(encoded) + let publicKey: Uint8Array + try { + publicKey = bs58.decode(encoded) + } catch { + throw new Error('Invalid FIDES DID public key encoding') + } if (publicKey.length !== 32) { throw new Error('FIDES DID public key must decode to 32 bytes') } @@ -240,15 +245,15 @@ export function validateIdentityKeyBinding(identity: Pick = {}): AgentIdentity & { metadata: Record } { - let publicKey: Uint8Array - try { - publicKey = publicKeyFromDid(did) - } catch { - publicKey = crypto.getRandomValues(new Uint8Array(32)) - } + const publicKey = publicKeyFromDid(did) return { did, diff --git a/packages/core/test/identity.test.ts b/packages/core/test/identity.test.ts index 991b861..785fe9d 100644 --- a/packages/core/test/identity.test.ts +++ b/packages/core/test/identity.test.ts @@ -81,6 +81,12 @@ describe('Identity v2', () => { expect(identity.publicKey).toEqual(issued.identity.publicKey) expect(validateIdentityKeyBinding(identity)).toBe(true) }) + + it('rejects deprecated createIdentity calls when the DID cannot bind to a public key', () => { + expect(() => createIdentity('did:fides:not-a-valid-key', 'agent')).toThrow( + 'Invalid FIDES DID public key encoding' + ) + }) }) describe('identityDisplayName', () => { From 96dad1d8d51ba4ba406f877b88f57c9d15fb475d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:48:24 +0300 Subject: [PATCH 080/282] feat(core): normalize agent card metadata --- docs/inspection/fides-report.md | 2 +- docs/protocol/agent-card.md | 10 ++++++ packages/core/src/agent-card.ts | 44 +++++++++++++++++++++++++++ packages/core/test/agent-card.test.ts | 31 ++++++++++++++++++- 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index 3fc7b4d..37d63a8 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -48,7 +48,7 @@ Local evidence: | Trust anchors | Present | `packages/core/src/trust-anchor.ts`. | | Canonical object signing | Present | `packages/core/src/canonical-signer.ts`. | | HTTP message signatures | Present in SDK | `packages/sdk/src/signing/`. | -| Signed AgentCards | Partial | `packages/core/src/agent-card.ts` defines `SignedAgentCard`, discovery providers accept signed cards, but AgentCard lacks all requested v2 fields. | +| Signed AgentCards | Present, evolving | `packages/core/src/agent-card.ts` defines `SignedAgentCard`, canonical signing, schema/agent id normalization, public key defaults, endpoint-derived transports, protocol versions, trust anchors, runtime attestations, revocation references, and validation. | | Capability descriptors | Present, evolving | `packages/core/src/capability.ts` has id, namespace, action, resource, schemas, risk, scopes, supported controls, dry-run, approval, runtime attestation, and policy-proof metadata. | | Capability ontology | Present, seed taxonomy | `packages/core/src/capability.ts` defines `DEFAULT_CAPABILITY_ONTOLOGY`, lookup helpers, and ontology-backed descriptor defaults before heuristic risk classification. | | Local discovery | Present | `packages/discovery/src/local-provider.ts`. | diff --git a/docs/protocol/agent-card.md b/docs/protocol/agent-card.md index d82d45e..f0bede7 100644 --- a/docs/protocol/agent-card.md +++ b/docs/protocol/agent-card.md @@ -23,6 +23,16 @@ Current implementation anchors: - revocation URL or revocation record reference - canonical signature +`normalizeAgentCard()` makes signed cards self-describing before canonical +signing: + +- `schema_version` defaults to `fides.agent_card.v1` +- `agent_id` defaults to `identity.did` +- `publicKeys` defaults to the Ed25519 public key encoded in the `did:fides` + identifier +- `transports` defaults from endpoint transport metadata +- `protocolVersions` defaults to the current FIDES protocol version + ## Rule Discovery may return AgentCards, but invocation requires trust evaluation, policy evaluation, and a scoped session grant. diff --git a/packages/core/src/agent-card.ts b/packages/core/src/agent-card.ts index 6ed112e..17a4c6a 100644 --- a/packages/core/src/agent-card.ts +++ b/packages/core/src/agent-card.ts @@ -11,6 +11,8 @@ import { signObject, verifyObject, type SignedObject } from './canonical-signer. import type { CapabilityDescriptor } from './capability.js' import type { RuntimeAttestation } from './runtime-attestation.js' import type { IdentityTrustAnchor } from './identity.js' +import { FIDES_PROTOCOL_VERSION } from './protocol.js' +import bs58 from 'bs58' export interface EndpointDescriptor { /** Endpoint URL */ @@ -23,6 +25,19 @@ export interface EndpointDescriptor { auth?: 'none' | 'signature' | 'bearer' | 'delegation' } +export interface TransportDescriptor { + /** Transport protocol advertised by the card. */ + protocol: EndpointDescriptor['protocol'] | 'stdio' | 'mcp' | 'a2a' + /** Human-readable transport label. */ + name?: string + /** Endpoint URL when the transport is network-addressable. */ + url?: string + /** Capability IDs supported over this transport. */ + capabilities?: string[] + /** Authentication method required for this transport. */ + auth?: EndpointDescriptor['auth'] +} + export interface PolicyRequirement { /** Policy bundle ID that must be satisfied */ policyBundleId?: string @@ -49,6 +64,8 @@ export interface AgentCard { capabilities: CapabilityDescriptor[] /** Service endpoints */ endpoints: EndpointDescriptor[] + /** Transport metadata, derived from endpoints when omitted. */ + transports?: TransportDescriptor[] /** Policy requirements for invokers */ policies: PolicyRequirement[] /** Public keys advertised for verification and invocation. */ @@ -104,6 +121,9 @@ export function validateAgentCard(card: AgentCard): { valid: boolean; errors: st } if (!Array.isArray(card.capabilities)) errors.push('AgentCard.capabilities must be an array') if (!Array.isArray(card.endpoints)) errors.push('AgentCard.endpoints must be an array') + if (card.transports !== undefined && !Array.isArray(card.transports)) { + errors.push('AgentCard.transports must be an array') + } if (!Array.isArray(card.policies)) errors.push('AgentCard.policies must be an array') if (!card.createdAt) errors.push('AgentCard.createdAt is required') if (!card.updatedAt) errors.push('AgentCard.updatedAt is required') @@ -119,14 +139,38 @@ export function validateAgentCard(card: AgentCard): { valid: boolean; errors: st errors.push(`CapabilityDescriptor ${capability.id} namespace does not match id`) } } + for (const key of card.publicKeys ?? []) { + if (key.type !== 'Ed25519') errors.push(`AgentCard.publicKeys ${key.id} must use Ed25519`) + if (!key.publicKey) errors.push(`AgentCard.publicKeys ${key.id} publicKey is required`) + } return { valid: errors.length === 0, errors } } export function normalizeAgentCard(card: AgentCard): AgentCard { + const publicKeys = card.publicKeys?.length + ? card.publicKeys + : [{ + id: `${card.identity.did}#ed25519`, + type: 'Ed25519' as const, + publicKey: bs58.encode(card.identity.publicKey), + }] + const transports = card.transports?.length + ? card.transports + : card.endpoints.map(endpoint => ({ + protocol: endpoint.protocol, + name: endpoint.protocol, + url: endpoint.url, + capabilities: endpoint.capabilities, + auth: endpoint.auth, + })) + return { ...card, schema_version: card.schema_version ?? 'fides.agent_card.v1', agent_id: card.agent_id ?? card.identity.did, + publicKeys, + transports, + protocolVersions: card.protocolVersions?.length ? card.protocolVersions : [FIDES_PROTOCOL_VERSION], } } diff --git a/packages/core/test/agent-card.test.ts b/packages/core/test/agent-card.test.ts index 565ca93..b3740e5 100644 --- a/packages/core/test/agent-card.test.ts +++ b/packages/core/test/agent-card.test.ts @@ -54,6 +54,13 @@ describe('AgentCard', () => { expect(result.errors).toContain('AgentCard.capabilities must be an array') }) + it('should reject invalid transports metadata', () => { + const card = { ...validCard, transports: 'stdio' as any } + const result = validateAgentCard(card) + expect(result.valid).toBe(false) + expect(result.errors).toContain('AgentCard.transports must be an array') + }) + it('should reject mismatched agent_id', () => { const result = validateAgentCard({ ...validCard, @@ -65,10 +72,18 @@ describe('AgentCard', () => { }) it('should normalize v2 schema and agent id fields', () => { - expect(normalizeAgentCard(validCard)).toMatchObject({ + const normalized = normalizeAgentCard(validCard) + + expect(normalized).toMatchObject({ schema_version: 'fides.agent_card.v1', agent_id: validCard.identity.did, + protocolVersions: ['fides.v2.0'], }) + expect(normalized.publicKeys?.[0]).toMatchObject({ + id: `${validCard.identity.did}#ed25519`, + type: 'Ed25519', + }) + expect(normalized.transports).toEqual([]) }) it('should sign and verify AgentCards with the canonical signing model', async () => { @@ -95,6 +110,14 @@ describe('AgentCard', () => { supportsDryRun: true, }, ], + endpoints: [ + { + url: 'https://calendar.example.test/invoke', + protocol: 'https', + capabilities: ['calendar.schedule'], + auth: 'delegation', + }, + ], protocolVersions: ['fides.v2.0'], expiresAt: '2999-01-01T00:00:00.000Z', } @@ -103,6 +126,12 @@ describe('AgentCard', () => { expect(signed.payload.schema_version).toBe('fides.agent_card.v1') expect(signed.payload.agent_id).toBe(issued.identity.did) + expect(signed.payload.publicKeys?.[0].publicKey).toBeTruthy() + expect(signed.payload.transports?.[0]).toMatchObject({ + protocol: 'https', + url: 'https://calendar.example.test/invoke', + auth: 'delegation', + }) expect(await verifySignedAgentCard(signed)).toBe(true) signed.payload.capabilities[0].name = 'Tampered' From 37478ada81776dd6c6575439f5cd80b84ddaa9bc Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:49:56 +0300 Subject: [PATCH 081/282] feat(core): hash runtime attestation objects --- docs/inspection/cross-repo-primitive-map.md | 2 +- docs/inspection/fides-report.md | 2 +- docs/protocol/runtime-attestation.md | 25 +++++++++++++++++++ packages/core/src/runtime-attestation.ts | 18 +++++++++++-- .../core/test/runtime-attestation.test.ts | 8 ++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/inspection/cross-repo-primitive-map.md b/docs/inspection/cross-repo-primitive-map.md index 53c224d..cb5c7ef 100644 --- a/docs/inspection/cross-repo-primitive-map.md +++ b/docs/inspection/cross-repo-primitive-map.md @@ -51,7 +51,7 @@ This map reflects the current local inspection of: | Merkle proof | Merkle root + inclusion proof helpers | Merkle/state diff concepts | not found | not found | ledger anchor | FIDES + AGIT | extend existing | | Revocation | `packages/core/src/revocation.ts`, services | not core | docs/spec | revoke flow | identity/payment revocation | FIDES | extend existing | | Incident | `packages/core/src/revocation.ts`, services | not core | not found | not found | payment/trust context | FIDES | extend existing | -| Runtime attestation | `packages/runtime` | not found | not found | not found | not generic | FIDES | extend existing | +| Runtime attestation | `packages/core/src/runtime-attestation.ts`, `packages/runtime` | not found | not found | not found | not generic | FIDES | extend existing | | TEE | Mock/HTTP adapter boundary | not found | not found | not found | not found | FIDES | adapter-ready | | Privacy/redaction | evidence privacy modes | not found | credential encryption | profile/spec only | payment privacy primitives | FIDES + Sardis prior art | extend existing | | Error vocabulary | broad error classes | `AgitError` | error responses | error taxonomy | exception/reason codes | FIDES + OAPS | extend existing | diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index 37d63a8..ad6e7e7 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -64,7 +64,7 @@ Local evidence: | Delegation tokens | Present | `packages/core/src/delegation.ts`. | | Session grants | Present, not v2-complete | `packages/core/src/delegation.ts`, `packages/core/src/session-store.ts`, `services/agentd/src/index.ts`. | | Capability invocation | Partial | Guard/agentd authorization exists; no generic signed InvocationRequest/InvocationResult protocol object found. | -| Runtime attestation | Present | `packages/runtime/src/index.ts`. | +| Runtime attestation | Present | `packages/core/src/runtime-attestation.ts` defines canonical-hashable v2 `RuntimeAttestation` objects; `packages/runtime/src/index.ts` provides MockTEE, HTTP TEE, build, container, package, and GitHub adapter-ready providers. | | TEE-ready attestation | Present as adapter boundary | `packages/runtime/src/index.ts`. | | MockTEE | Present | `packages/runtime/src/index.ts`. | | Evidence ledger | Present, package-level | `packages/evidence/src/index.ts`; persisted locally by agentd authority store. | diff --git a/docs/protocol/runtime-attestation.md b/docs/protocol/runtime-attestation.md index 92b382a..ea1ad18 100644 --- a/docs/protocol/runtime-attestation.md +++ b/docs/protocol/runtime-attestation.md @@ -17,6 +17,31 @@ Current implementation anchors: - container image attestation adapter-ready - reproducible build attestation adapter-ready +## RuntimeAttestation Object + +`packages/core/src/runtime-attestation.ts` emits +`fides.runtime_attestation.v1` records with shared signed-object fields: + +- `id` +- `issuer` +- `subject` +- `attestation_id` +- `agent_id` +- `provider` +- `code_hash` +- `runtime_hash` +- `policy_hash` +- `enclave_measurement` +- `issued_at` +- `expires_at` +- `payload_hash` +- `signature` + +`id` and `attestation_id` are the same identifier for compatibility with older +call sites. `subject` is the attested agent id. `payload_hash` is computed with +the shared canonical JSON digest before the provider-specific signature is +attached. + ## Policy Rule High-risk capabilities require valid runtime attestation or explicit approval. Missing attestation should not deny low-risk actions by default. diff --git a/packages/core/src/runtime-attestation.ts b/packages/core/src/runtime-attestation.ts index a6e0932..bf5008f 100644 --- a/packages/core/src/runtime-attestation.ts +++ b/packages/core/src/runtime-attestation.ts @@ -2,6 +2,9 @@ import { hashProtocolPayload } from './protocol.js' export interface RuntimeAttestation { schema_version: 'fides.runtime_attestation.v1' + id: string + issuer: string + subject: string attestation_id: string agent_id: string provider: 'mock-tee' | 'null' | 'aws-nitro' | 'intel-sgx' | 'amd-sev' | 'container-image' | 'reproducible-build' | string @@ -11,6 +14,7 @@ export interface RuntimeAttestation { enclave_measurement?: string issued_at: string expires_at: string + payload_hash: string signature: string } @@ -41,12 +45,17 @@ export function isRuntimeAttestationExpired(attestation: RuntimeAttestation, now export function createRuntimeAttestation(input: RuntimeAttestationIssueInput & { provider: RuntimeAttestation['provider'] + issuer?: string signature: string }): RuntimeAttestation { const issuedAt = input.issuedAt ?? new Date().toISOString() - return { + const id = crypto.randomUUID() + const payload = { schema_version: 'fides.runtime_attestation.v1', - attestation_id: crypto.randomUUID(), + id, + issuer: input.issuer ?? input.provider, + subject: input.agentId, + attestation_id: id, agent_id: input.agentId, provider: input.provider, code_hash: input.codeHash, @@ -62,6 +71,11 @@ export function createRuntimeAttestation(input: RuntimeAttestationIssueInput & { }), issued_at: issuedAt, expires_at: input.expiresAt ?? new Date(Date.now() + DEFAULT_ATTESTATION_TTL_MS).toISOString(), + } satisfies Omit + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), signature: input.signature, } } diff --git a/packages/core/test/runtime-attestation.test.ts b/packages/core/test/runtime-attestation.test.ts index 23f9aa2..df61671 100644 --- a/packages/core/test/runtime-attestation.test.ts +++ b/packages/core/test/runtime-attestation.test.ts @@ -18,6 +18,9 @@ describe('runtime attestation v2', () => { expect(attestation).toMatchObject({ schema_version: 'fides.runtime_attestation.v1', + id: expect.any(String), + issuer: 'mock-tee', + subject: 'did:fides:agent', agent_id: 'did:fides:agent', provider: 'mock-tee', code_hash: `sha256:${'a'.repeat(64)}`, @@ -25,6 +28,8 @@ describe('runtime attestation v2', () => { policy_hash: `sha256:${'c'.repeat(64)}`, enclave_measurement: expect.stringMatching(/^sha256:/), }) + expect(attestation.attestation_id).toBe(attestation.id) + expect(attestation.payload_hash).toMatch(/^sha256:/) expect(await provider.verify(attestation)).toBe(true) expect(await verifyRuntimeAttestation(attestation, provider)).toBe(true) }) @@ -53,6 +58,9 @@ describe('runtime attestation v2', () => { }) expect(attestation.provider).toBe('null') + expect(attestation.issuer).toBe('null') + expect(attestation.subject).toBe('did:fides:agent') + expect(attestation.payload_hash).toMatch(/^sha256:/) expect(await provider.verify(attestation)).toBe(false) }) }) From 3152ccca5297545dee3147925d868cadbb664b31 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:51:34 +0300 Subject: [PATCH 082/282] feat(core): extend typed error vocabulary --- docs/inspection/fides-report.md | 2 +- docs/protocol/error-vocabulary.md | 7 ++++++- packages/core/src/errors.ts | 25 +++++++++++++++++++++++++ packages/core/test/errors.test.ts | 18 ++++++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index ad6e7e7..5d6403a 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -74,7 +74,7 @@ Local evidence: | Kill switch | Present | `packages/runtime/src/index.ts`, `packages/cli/src/commands/killswitch.ts`, `services/agentd/src/index.ts`. | | Evidence privacy | Present, basic | `packages/evidence/src/index.ts` supports public/private/redacted/hash-only export modes. | | Version negotiation | Present | `packages/core/src/versioning.ts`, `packages/core/src/discovery.ts`, and `packages/discovery/src/orchestrator.ts` negotiate and filter discovery candidates by protocol compatibility. | -| Typed errors | Partial | `packages/shared/src/errors.ts` has broad classes, but not stable code/category/severity/retryable envelopes. | +| Typed errors | Present, evolving | `packages/core/src/errors.ts` defines stable `ErrorEnvelope` objects with code, category, severity, retryable, message, and details across identity, AgentCard, capability, trust, policy, approval, session, attestation, DHT, evidence, revocation, incident, kill switch, and version errors. | | Explainability | Partial | Guard and policy return factors/explanations in `packages/guard/src/index.ts` and `packages/policy/src/index.ts`. | | Adversarial simulation | Present as test, incomplete harness | `tests/adversarial/adversarial.test.ts`; no `agentd simulate adversarial` command found. | | Interop adapters | Partial | SDK and CLI have A2A/FIDES-era surfaces; explicit MCP/A2A/OAPS/OSP/AP2/x402/Sardis adapter package not found. | diff --git a/docs/protocol/error-vocabulary.md b/docs/protocol/error-vocabulary.md index 4b8fb4d..e4b7506 100644 --- a/docs/protocol/error-vocabulary.md +++ b/docs/protocol/error-vocabulary.md @@ -19,11 +19,15 @@ Each error includes: ## Required Codes -The core vocabulary includes identity, AgentCard, capability, trust, policy, approval, session, attestation, DHT, evidence, revocation, kill switch, and version errors. +The core vocabulary includes identity, AgentCard, capability, trust, policy, +approval, session, attestation, DHT, evidence, revocation, incident, kill +switch, and version errors. Examples: - `IDENTITY_INVALID_SIGNATURE` +- `IDENTITY_KEY_UNBOUND` +- `AGENT_CARD_INVALID_SIGNATURE` - `AGENT_CARD_EXPIRED` - `CAPABILITY_NOT_FOUND` - `TRUST_BELOW_THRESHOLD` @@ -35,6 +39,7 @@ Examples: - `DHT_POINTER_TAMPERED` - `EVIDENCE_CHAIN_BROKEN` - `REVOCATION_ACTIVE` +- `INCIDENT_ACTIVE` - `KILL_SWITCH_ACTIVE` - `VERSION_INCOMPATIBLE` diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 144d15f..4dacef9 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -11,6 +11,7 @@ export type FidesErrorCategory = | 'dht' | 'evidence' | 'revocation' + | 'incident' | 'kill_switch' | 'version' | 'internal' @@ -24,6 +25,18 @@ export const FIDES_ERROR_CODES = { retryable: false, message: 'Identity signature is invalid', }, + IDENTITY_KEY_UNBOUND: { + category: 'identity', + severity: 'critical', + retryable: false, + message: 'Identity DID is not bound to the advertised public key', + }, + AGENT_CARD_INVALID_SIGNATURE: { + category: 'agent_card', + severity: 'error', + retryable: false, + message: 'AgentCard signature is invalid', + }, AGENT_CARD_EXPIRED: { category: 'agent_card', severity: 'error', @@ -120,6 +133,18 @@ export const FIDES_ERROR_CODES = { retryable: false, message: 'An active revocation blocks this action', }, + INCIDENT_ACTIVE: { + category: 'incident', + severity: 'critical', + retryable: false, + message: 'An active incident requires review before execution', + }, + INCIDENT_INVALID: { + category: 'incident', + severity: 'error', + retryable: false, + message: 'Incident record is invalid', + }, KILL_SWITCH_ACTIVE: { category: 'kill_switch', severity: 'critical', diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts index aa53335..9cce92f 100644 --- a/packages/core/test/errors.test.ts +++ b/packages/core/test/errors.test.ts @@ -37,4 +37,22 @@ describe('error envelopes', () => { expect(isErrorEnvelope({ code: 'NOPE' })).toBe(false) expect(isErrorEnvelope(null)).toBe(false) }) + + it('covers incident and key-binding failures as stable protocol errors', () => { + expect(createErrorEnvelope('IDENTITY_KEY_UNBOUND')).toMatchObject({ + code: 'IDENTITY_KEY_UNBOUND', + category: 'identity', + severity: 'critical', + retryable: false, + }) + expect(createErrorEnvelope('AGENT_CARD_INVALID_SIGNATURE')).toMatchObject({ + code: 'AGENT_CARD_INVALID_SIGNATURE', + category: 'agent_card', + }) + expect(createErrorEnvelope('INCIDENT_ACTIVE')).toMatchObject({ + code: 'INCIDENT_ACTIVE', + category: 'incident', + severity: 'critical', + }) + }) }) From f14905dfe839b77a8264b6e27e4c72dc9719a767 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:54:23 +0300 Subject: [PATCH 083/282] feat(core): hash trust result objects --- docs/inspection/fides-report.md | 2 +- docs/protocol/trust-model.md | 10 ++++++ packages/core/src/trust.ts | 20 ++++++++++-- packages/core/test/trust.test.ts | 43 ++++++++++++++++++++++++++ packages/policy/README.md | 4 +++ packages/policy/test/policy-v2.test.ts | 4 +++ 6 files changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index 5d6403a..da41753 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -59,7 +59,7 @@ Local evidence: | Federation | Present, local mock | `packages/core/src/registry.ts` defines `RegistryPeerRecord`; `packages/discovery/src/federation-provider.ts` verifies signed peer records for candidate-only federation discovery. | | Trust graph | Present | `services/trust-graph/src/services/graph.ts`, `services/trust-graph/src/services/trust-service.ts`. | | Capability-specific reputation | Partial | `services/trust-graph/src/db/migrations/003_capability_scoring.sql`, `services/trust-graph/src/services/capability-scoring.ts`. | -| Context-specific trust scoring | Partial | Trust edges include optional capability/context, but no full v2 scoring component model. | +| Context-specific trust scoring | Present, evolving | `packages/core/src/trust.ts` defines componentized `TrustResult` scoring with identity, publisher, trust anchor, capability fit, evidence, policy compliance, runtime safety, peer attestation, incident, novelty, and context-boundary components plus canonical `payload_hash`. | | Policy engine | Present, simple | `packages/policy/src/index.ts`, `services/policy-engine/src/index.ts`. | | Delegation tokens | Present | `packages/core/src/delegation.ts`. | | Session grants | Present, not v2-complete | `packages/core/src/delegation.ts`, `packages/core/src/session-store.ts`, `services/agentd/src/index.ts`. | diff --git a/docs/protocol/trust-model.md b/docs/protocol/trust-model.md index 8fde6bb..db49bb1 100644 --- a/docs/protocol/trust-model.md +++ b/docs/protocol/trust-model.md @@ -11,6 +11,11 @@ Current implementation anchors: A `TrustResult` includes: +- id +- issuer +- subject +- agent id +- capability - score - band - reasons @@ -18,6 +23,11 @@ A `TrustResult` includes: - evidence refs - required controls - computed timestamp +- payload hash + +`payload_hash` is computed with the shared canonical JSON digest over the +machine-readable trust result. Session grants and policy decisions can bind to +that hash without treating trust as authority. ## Components diff --git a/packages/core/src/trust.ts b/packages/core/src/trust.ts index eb97e30..b193860 100644 --- a/packages/core/src/trust.ts +++ b/packages/core/src/trust.ts @@ -1,4 +1,5 @@ import type { CapabilityControl, CapabilityDescriptor } from './capability.js' +import { hashProtocolPayload, type HashValue } from './protocol.js' export type TrustBand = 'unknown' | 'low' | 'medium' | 'high' | 'verified' @@ -38,6 +39,9 @@ export interface TrustReason { export interface TrustResult { schema_version: 'fides.trust.result.v1' + id: string + issuer: string + subject: string agent_id: string capability: string score: number @@ -47,10 +51,13 @@ export interface TrustResult { evidence_refs: string[] required_controls: CapabilityControl[] computed_at: string + payload_hash: HashValue } export interface ComputeTrustResultInput { agentId: string + issuer?: string + resultId?: string capability: CapabilityDescriptor components: TrustScoreComponents evidenceRefs?: string[] @@ -140,8 +147,12 @@ export function computeTrustResult(input: ComputeTrustResultInput): TrustResult if (input.components.contextBoundaryPenalty > 0) riskFlags.push('context_boundary') if (input.components.noveltyPenalty > 0.4) riskFlags.push('limited_history') - return { + const computedAt = input.computedAt ?? new Date().toISOString() + const payload = { schema_version: 'fides.trust.result.v1', + id: input.resultId ?? crypto.randomUUID(), + issuer: input.issuer ?? 'fides.trust-engine', + subject: input.agentId, agent_id: input.agentId, capability: input.capability.id, score: Number(clamp01(score).toFixed(4)), @@ -150,6 +161,11 @@ export function computeTrustResult(input: ComputeTrustResultInput): TrustResult risk_flags: riskFlags, evidence_refs: input.evidenceRefs ?? [], required_controls: Array.from(requiredControls), - computed_at: input.computedAt ?? new Date().toISOString(), + computed_at: computedAt, + } satisfies Omit + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), } } diff --git a/packages/core/test/trust.test.ts b/packages/core/test/trust.test.ts index 034f822..54bdf38 100644 --- a/packages/core/test/trust.test.ts +++ b/packages/core/test/trust.test.ts @@ -58,19 +58,27 @@ describe('TrustResult v2', () => { const result = computeTrustResult({ agentId: 'did:fides:agent', + issuer: 'did:fides:trust-engine', + resultId: 'trust_result_1', capability: mediumCapability, components, evidenceRefs: ['evt_1'], + computedAt: '2026-05-30T00:00:00.000Z', }) expect(result).toMatchObject({ schema_version: 'fides.trust.result.v1', + id: 'trust_result_1', + issuer: 'did:fides:trust-engine', + subject: 'did:fides:agent', agent_id: 'did:fides:agent', capability: 'invoice.reconcile', band: 'high', evidence_refs: ['evt_1'], required_controls: [], + computed_at: '2026-05-30T00:00:00.000Z', }) + expect(result.payload_hash).toMatch(/^sha256:/) expect(result.score).toBeGreaterThan(0.6) expect(result.reasons.map(reason => reason.component)).toEqual(expect.arrayContaining([ 'IdentityScore', @@ -104,4 +112,39 @@ describe('TrustResult v2', () => { ])) expect(result.risk_flags).toEqual(expect.arrayContaining(['runtime_safety_low'])) }) + + it('changes payload_hash when trust components alter the computed decision surface', () => { + const base: Parameters[0] = { + agentId: 'did:fides:agent', + resultId: 'trust_result_stable', + issuer: 'did:fides:trust-engine', + capability: mediumCapability, + components: { + identity: 0.8, + publisher: 0.7, + trustAnchors: 0.6, + capabilityFit: 0.9, + evidence: 0.7, + policyCompliance: 0.8, + runtimeSafety: 0.6, + peerAttestation: 0.4, + incidentPenalty: 0.1, + noveltyPenalty: 0.05, + contextBoundaryPenalty: 0, + }, + computedAt: '2026-05-30T00:00:00.000Z', + } + + const highTrust = computeTrustResult(base) + const penalized = computeTrustResult({ + ...base, + components: { + ...base.components, + incidentPenalty: 0.9, + }, + }) + + expect(highTrust.payload_hash).not.toBe(penalized.payload_hash) + expect(penalized.risk_flags).toContain('incident_history') + }) }) diff --git a/packages/policy/README.md b/packages/policy/README.md index 7c0ec72..5b6a373 100644 --- a/packages/policy/README.md +++ b/packages/policy/README.md @@ -38,6 +38,9 @@ const decision = evaluateFidesPolicy({ }, trustResult: { schema_version: 'fides.trust.result.v1', + id: 'trust_result_1', + issuer: 'did:fides:trust-engine', + subject: 'did:fides:invoice-agent', agent_id: 'did:fides:invoice-agent', capability: 'invoice.reconcile', score: 0.82, @@ -47,6 +50,7 @@ const decision = evaluateFidesPolicy({ evidence_refs: ['evt_1'], required_controls: [], computed_at: new Date().toISOString(), + payload_hash: 'sha256:...', }, requestedScopes: ['invoice:read'], }) diff --git a/packages/policy/test/policy-v2.test.ts b/packages/policy/test/policy-v2.test.ts index 2158b8a..7190f1a 100644 --- a/packages/policy/test/policy-v2.test.ts +++ b/packages/policy/test/policy-v2.test.ts @@ -19,6 +19,9 @@ const capability = (riskLevel: CapabilityDescriptor['riskLevel']): CapabilityDes const trust = (band: TrustResult['band'], score: number): TrustResult => ({ schema_version: 'fides.trust.result.v1', + id: `trust_${band}_${score}`, + issuer: 'did:fides:trust-engine', + subject: 'did:fides:agent', agent_id: 'did:fides:agent', capability: 'invoice.reconcile', score, @@ -28,6 +31,7 @@ const trust = (band: TrustResult['band'], score: number): TrustResult => ({ evidence_refs: ['evt_1'], required_controls: [], computed_at: '2026-05-29T00:00:00.000Z', + payload_hash: `sha256:${'0'.repeat(64)}`, }) describe('FIDES policy v2', () => { From 1c0c502e388731416c67e6dd600248fc331211f7 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:56:05 +0300 Subject: [PATCH 084/282] feat(core): hash reputation records --- docs/inspection/fides-report.md | 2 +- docs/protocol/reputation-model.md | 5 ++++ packages/core/src/reputation.ts | 21 ++++++++++++++-- packages/core/test/reputation.test.ts | 35 +++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index da41753..3e617c0 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -58,7 +58,7 @@ Local evidence: | DHT discovery | Present, local simulator | `packages/core/src/dht.ts` defines signed `DHTPointerRecord`; `packages/discovery/src/dht-provider.ts` rejects tampered, expired, hash-mismatched, or revoked pointer candidates. | | Federation | Present, local mock | `packages/core/src/registry.ts` defines `RegistryPeerRecord`; `packages/discovery/src/federation-provider.ts` verifies signed peer records for candidate-only federation discovery. | | Trust graph | Present | `services/trust-graph/src/services/graph.ts`, `services/trust-graph/src/services/trust-service.ts`. | -| Capability-specific reputation | Partial | `services/trust-graph/src/db/migrations/003_capability_scoring.sql`, `services/trust-graph/src/services/capability-scoring.ts`. | +| Capability-specific reputation | Present, evolving | `packages/core/src/reputation.ts` defines canonical-hashable capability-specific `ReputationRecord` objects with publisher/principal scope, incident and context-boundary penalties; trust graph services provide additional scoring infrastructure. | | Context-specific trust scoring | Present, evolving | `packages/core/src/trust.ts` defines componentized `TrustResult` scoring with identity, publisher, trust anchor, capability fit, evidence, policy compliance, runtime safety, peer attestation, incident, novelty, and context-boundary components plus canonical `payload_hash`. | | Policy engine | Present, simple | `packages/policy/src/index.ts`, `services/policy-engine/src/index.ts`. | | Delegation tokens | Present | `packages/core/src/delegation.ts`. | diff --git a/docs/protocol/reputation-model.md b/docs/protocol/reputation-model.md index 07423e1..ab62021 100644 --- a/docs/protocol/reputation-model.md +++ b/docs/protocol/reputation-model.md @@ -8,6 +8,7 @@ Current implementation anchor: ## Signals +- canonical `ReputationRecord` id, issuer, subject, and payload hash - capability-specific success rate - observed volume confidence - publisher weight @@ -17,3 +18,7 @@ Current implementation anchor: ## Boundaries Reputation may also be principal-specific where possible. Context laundering should be penalized when a reputation signal is reused outside the capability or principal context where it was earned. + +`payload_hash` is computed over the machine-readable reputation record, including +capability and optional principal scope. This lets trust and policy cite an +exact reputation snapshot without allowing reputation to become permission. diff --git a/packages/core/src/reputation.ts b/packages/core/src/reputation.ts index 7675b5b..467da8b 100644 --- a/packages/core/src/reputation.ts +++ b/packages/core/src/reputation.ts @@ -1,3 +1,5 @@ +import { hashProtocolPayload, type HashValue } from './protocol.js' + export interface ReputationReason { factor: 'success_rate' | 'volume_confidence' | 'publisher_weight' | 'incident_penalty' | 'context_boundary_penalty' value: number @@ -6,6 +8,9 @@ export interface ReputationReason { export interface ReputationRecord { schema_version: 'fides.reputation.record.v1' + id: string + issuer: string + subject: string agent_id: string publisher_id?: string principal_id?: string @@ -18,10 +23,13 @@ export interface ReputationRecord { context_boundary_penalty: number reasons: ReputationReason[] computed_at: string + payload_hash: HashValue } export interface ComputeCapabilityReputationInput { agentId: string + issuer?: string + recordId?: string publisherId?: string principalId?: string capability: string @@ -48,6 +56,7 @@ export function computeCapabilityReputation(input: ComputeCapabilityReputationIn const publisherWeight = clamp01(input.publisherWeight ?? 0.5) const incidentPenalty = Math.min(1, incidentCount * 0.18) const contextBoundaryPenalty = input.contextBoundaryMismatch ? 0.2 : 0 + const computedAt = input.computedAt ?? new Date().toISOString() const score = clamp01( (successRate * 0.48) + @@ -58,8 +67,11 @@ export function computeCapabilityReputation(input: ComputeCapabilityReputationIn contextBoundaryPenalty ) - return { + const payload = { schema_version: 'fides.reputation.record.v1', + id: input.recordId ?? crypto.randomUUID(), + issuer: input.issuer ?? 'fides.reputation-engine', + subject: input.agentId, agent_id: input.agentId, publisher_id: input.publisherId, principal_id: input.principalId, @@ -77,7 +89,12 @@ export function computeCapabilityReputation(input: ComputeCapabilityReputationIn { factor: 'incident_penalty', value: incidentPenalty, description: 'Penalty from incidents scoped to this capability or agent' }, { factor: 'context_boundary_penalty', value: contextBoundaryPenalty, description: 'Penalty for applying reputation outside its capability context' }, ], - computed_at: input.computedAt ?? new Date().toISOString(), + computed_at: computedAt, + } satisfies Omit + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), } } diff --git a/packages/core/test/reputation.test.ts b/packages/core/test/reputation.test.ts index cd3c6ff..82effb0 100644 --- a/packages/core/test/reputation.test.ts +++ b/packages/core/test/reputation.test.ts @@ -8,6 +8,8 @@ describe('capability-specific reputation v2', () => { it('does not let one capability reputation imply another capability', () => { const paymentRecord = createReputationRecord({ agentId: 'did:fides:agent', + issuer: 'did:fides:reputation-engine', + recordId: 'rep_payments_execute', publisherId: 'did:fides:publisher', capability: 'payments.execute', successfulInvocations: 20, @@ -27,6 +29,13 @@ describe('capability-specific reputation v2', () => { }) expect(paymentRecord.capability).toBe('payments.execute') + expect(paymentRecord).toMatchObject({ + id: 'rep_payments_execute', + issuer: 'did:fides:reputation-engine', + subject: 'did:fides:agent', + schema_version: 'fides.reputation.record.v1', + }) + expect(paymentRecord.payload_hash).toMatch(/^sha256:/) expect(calendarRecord.capability).toBe('calendar.schedule') expect(paymentRecord.score).toBeGreaterThan(calendarRecord.score) }) @@ -44,9 +53,35 @@ describe('capability-specific reputation v2', () => { }) expect(result.score).toBeLessThan(0.75) + expect(result.payload_hash).toMatch(/^sha256:/) expect(result.reasons.map(reason => reason.factor)).toEqual(expect.arrayContaining([ 'incident_penalty', 'context_boundary_penalty', ])) }) + + it('changes payload_hash when reputation context changes', () => { + const base = { + agentId: 'did:fides:agent', + recordId: 'rep_stable', + issuer: 'did:fides:reputation-engine', + capability: 'invoice.reconcile', + successfulInvocations: 10, + failedInvocations: 0, + incidentCount: 0, + publisherWeight: 0.7, + computedAt: '2026-05-30T00:00:00.000Z', + } + + const scoped = computeCapabilityReputation({ + ...base, + principalId: 'did:fides:principal-a', + }) + const otherPrincipal = computeCapabilityReputation({ + ...base, + principalId: 'did:fides:principal-b', + }) + + expect(scoped.payload_hash).not.toBe(otherPrincipal.payload_hash) + }) }) From bdc0ae7033617298f2083a1abb2fae926c1a90f2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:58:01 +0300 Subject: [PATCH 085/282] feat(core): bind session grant object identity --- docs/inspection/fides-report.md | 2 +- docs/protocol/delegation-and-sessions.md | 6 ++++ packages/core/src/delegation.ts | 15 +++++++++- packages/core/test/session-grant-v2.test.ts | 31 +++++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index 3e617c0..74f5ef7 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -62,7 +62,7 @@ Local evidence: | Context-specific trust scoring | Present, evolving | `packages/core/src/trust.ts` defines componentized `TrustResult` scoring with identity, publisher, trust anchor, capability fit, evidence, policy compliance, runtime safety, peer attestation, incident, novelty, and context-boundary components plus canonical `payload_hash`. | | Policy engine | Present, simple | `packages/policy/src/index.ts`, `services/policy-engine/src/index.ts`. | | Delegation tokens | Present | `packages/core/src/delegation.ts`. | -| Session grants | Present, not v2-complete | `packages/core/src/delegation.ts`, `packages/core/src/session-store.ts`, `services/agentd/src/index.ts`. | +| Session grants | Present, evolving | `packages/core/src/delegation.ts` defines scoped v2 `SessionGrant` objects with shared `id`, `issuer`, `subject`, session id, requester, target, principal, capability, scopes, policy/trust hashes, nonce, audience, expiry, and payload hash; `packages/core/src/session-store.ts` and `services/agentd/src/index.ts` use them locally. | | Capability invocation | Partial | Guard/agentd authorization exists; no generic signed InvocationRequest/InvocationResult protocol object found. | | Runtime attestation | Present | `packages/core/src/runtime-attestation.ts` defines canonical-hashable v2 `RuntimeAttestation` objects; `packages/runtime/src/index.ts` provides MockTEE, HTTP TEE, build, container, package, and GitHub adapter-ready providers. | | TEE-ready attestation | Present as adapter boundary | `packages/runtime/src/index.ts`. | diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index a69d979..bba5206 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -10,7 +10,9 @@ Current implementation anchors: ## SessionGrant Fields +- `id` - `session_id` +- `subject` - `requester_agent_id` - `target_agent_id` - `principal_id` @@ -26,6 +28,10 @@ Current implementation anchors: - `issuer` - canonical signature +`id` and `session_id` are the same value for compatibility with older call +sites. `subject` is the target agent id, so the shared protocol object envelope +binds to the same target as the session authority. + Replay protection is required through nonce tracking. ## Invocation Binding diff --git a/packages/core/src/delegation.ts b/packages/core/src/delegation.ts index df13d42..a482857 100644 --- a/packages/core/src/delegation.ts +++ b/packages/core/src/delegation.ts @@ -42,7 +42,9 @@ export interface SessionGrant { export interface SessionGrantV2 { schema_version: 'fides.session_grant.v1' + id: string session_id: string + subject: string requester_agent_id: string target_agent_id: string principal_id: string @@ -102,9 +104,12 @@ export function createDelegationToken(input: DelegationInput): DelegationToken { } export function createSessionGrantV2(input: SessionGrantV2Input): SessionGrantV2 { + const sessionId = crypto.randomUUID() const payload = { schema_version: 'fides.session_grant.v1' as const, - session_id: crypto.randomUUID(), + id: sessionId, + session_id: sessionId, + subject: input.targetAgentId, requester_agent_id: input.requesterAgentId, target_agent_id: input.targetAgentId, principal_id: input.principalId, @@ -181,9 +186,17 @@ export function validateDelegationToken(token: DelegationToken): { valid: boolea export function validateSessionGrantV2(session: SessionGrantV2): { valid: boolean; errors: string[] } { const errors: string[] = [] if (session.schema_version !== 'fides.session_grant.v1') errors.push('SessionGrant.schema_version is invalid') + if (!session.id) errors.push('SessionGrant.id is required') if (!session.session_id) errors.push('SessionGrant.session_id is required') + if (session.id && session.session_id && session.id !== session.session_id) { + errors.push('SessionGrant.id must match SessionGrant.session_id') + } + if (!session.subject) errors.push('SessionGrant.subject is required') if (!session.requester_agent_id) errors.push('SessionGrant.requester_agent_id is required') if (!session.target_agent_id) errors.push('SessionGrant.target_agent_id is required') + if (session.subject && session.target_agent_id && session.subject !== session.target_agent_id) { + errors.push('SessionGrant.subject must match SessionGrant.target_agent_id') + } if (!session.principal_id) errors.push('SessionGrant.principal_id is required') if (!session.capability) errors.push('SessionGrant.capability is required') if (!session.scopes || session.scopes.length === 0) errors.push('SessionGrant.scopes must not be empty') diff --git a/packages/core/test/session-grant-v2.test.ts b/packages/core/test/session-grant-v2.test.ts index bfb5155..f5799b4 100644 --- a/packages/core/test/session-grant-v2.test.ts +++ b/packages/core/test/session-grant-v2.test.ts @@ -27,6 +27,8 @@ describe('SessionGrant v2', () => { expect(grant).toMatchObject({ schema_version: 'fides.session_grant.v1', + id: grant.session_id, + subject: 'did:fides:target', requester_agent_id: 'did:fides:requester', target_agent_id: 'did:fides:target', principal_id: 'did:fides:principal', @@ -37,6 +39,7 @@ describe('SessionGrant v2', () => { audience: ['did:fides:target'], issuer: issuer.did, }) + expect(grant.id).toBe(grant.session_id) expect(grant.nonce).toBeTruthy() expect(validateSessionGrantV2(grant)).toEqual({ valid: true, errors: [] }) expect(isSessionGrantV2Expired(grant)).toBe(false) @@ -60,4 +63,32 @@ describe('SessionGrant v2', () => { const signed = await signSessionGrantV2(grant, issuer.privateKey, issuer.did) expect(await verifySignedSessionGrantV2(signed)).toBe(true) }) + + it('rejects grants whose shared object ids or subjects drift from session binding', async () => { + const issuer = await createIdentityKeyPair() + const grant = createSessionGrantV2({ + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:target', + principalId: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['invoice:read'], + constraints: {}, + policyHash: 'sha256:policy', + trustResultHash: 'sha256:trust', + issuer: issuer.did, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }) + + expect(validateSessionGrantV2({ + ...grant, + id: 'different-id', + subject: 'did:fides:other-target', + })).toEqual({ + valid: false, + errors: [ + 'SessionGrant.id must match SessionGrant.session_id', + 'SessionGrant.subject must match SessionGrant.target_agent_id', + ], + }) + }) }) From 3489745c3fc130960185c42e9c39c62158428b8f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 11:59:00 +0300 Subject: [PATCH 086/282] docs: refresh fides inspection status --- docs/inspection/fides-report.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index 74f5ef7..6c4eeb9 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -63,14 +63,14 @@ Local evidence: | Policy engine | Present, simple | `packages/policy/src/index.ts`, `services/policy-engine/src/index.ts`. | | Delegation tokens | Present | `packages/core/src/delegation.ts`. | | Session grants | Present, evolving | `packages/core/src/delegation.ts` defines scoped v2 `SessionGrant` objects with shared `id`, `issuer`, `subject`, session id, requester, target, principal, capability, scopes, policy/trust hashes, nonce, audience, expiry, and payload hash; `packages/core/src/session-store.ts` and `services/agentd/src/index.ts` use them locally. | -| Capability invocation | Partial | Guard/agentd authorization exists; no generic signed InvocationRequest/InvocationResult protocol object found. | +| Capability invocation | Present, evolving | `packages/core/src/invocation.ts` defines signed `InvocationRequest` and `InvocationResult` protocol objects with shared issuer/subject/payload hash fields, schema validation helpers, and policy preflight mapping. | | Runtime attestation | Present | `packages/core/src/runtime-attestation.ts` defines canonical-hashable v2 `RuntimeAttestation` objects; `packages/runtime/src/index.ts` provides MockTEE, HTTP TEE, build, container, package, and GitHub adapter-ready providers. | | TEE-ready attestation | Present as adapter boundary | `packages/runtime/src/index.ts`. | | MockTEE | Present | `packages/runtime/src/index.ts`. | | Evidence ledger | Present, package-level | `packages/evidence/src/index.ts`; persisted locally by agentd authority store. | | Revocation records | Present | `packages/core/src/revocation.ts`, `services/agentd/src/index.ts`, `services/trust-graph/src/db/migrations/002_revocations.sql`. | | Incident records | Present | `packages/core/src/revocation.ts`, `services/agentd/src/index.ts`, `services/trust-graph/src/db/migrations/002_revocations.sql`. | -| Approval primitives | Partial | Guard and policy can require approval; I could not find first-class ApprovalRequest/ApprovalDecision protocol objects in core. | +| Approval primitives | Present, evolving | `packages/core/src/approval.ts` defines first-class `ApprovalRequest`, `ApprovalDecision`, and `KillSwitchRule` protocol objects with canonical payload hashes and signing helpers. | | Kill switch | Present | `packages/runtime/src/index.ts`, `packages/cli/src/commands/killswitch.ts`, `services/agentd/src/index.ts`. | | Evidence privacy | Present, basic | `packages/evidence/src/index.ts` supports public/private/redacted/hash-only export modes. | | Version negotiation | Present | `packages/core/src/versioning.ts`, `packages/core/src/discovery.ts`, and `packages/discovery/src/orchestrator.ts` negotiate and filter discovery candidates by protocol compatibility. | @@ -145,9 +145,9 @@ Treat the current repo as an advanced prototype, not a blank MVP. The v2 work sh 1. Freeze the current implemented surface as baseline. 2. Consolidate core protocol objects under `packages/core`. -3. Add missing v2 protocol objects and stable error/version vocabularies. +3. Continue hardening v2 protocol objects and stable error/version vocabularies. 4. Convert discovery from DID resolution to capability + constraints resolution. 5. Replace DHT direct-card storage with signed pointer records. -6. Promote approvals, invocation, revocation, incidents, and evidence into first-class signed objects. +6. Continue promoting revocation, incidents, evidence, and authority lifecycle surfaces into first-class signed objects. 7. Align CLI/API/SDK/demo surfaces to `agentd` v2. 8. Keep AGIT/OAPS/OSP/Sardis as semantic or adapter inputs only; no runtime dependency on OAPS. From 457f662cedc9b6170fd7494201fc35a4b522daf4 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:01:42 +0300 Subject: [PATCH 087/282] feat(core): bind lifecycle record subjects --- docs/protocol/incidents.md | 5 +++++ docs/protocol/revocation.md | 4 ++++ packages/core/src/revocation.ts | 6 ++++++ packages/core/test/revocation-incident-v2.test.ts | 6 ++++++ 4 files changed, 21 insertions(+) diff --git a/docs/protocol/incidents.md b/docs/protocol/incidents.md index 6efc369..44b543d 100644 --- a/docs/protocol/incidents.md +++ b/docs/protocol/incidents.md @@ -19,6 +19,11 @@ Current implementation anchor: Incidents carry severity, evidence refs, resolution status, trust penalty, and reputation penalty. +`IncidentRecordV2` uses the shared protocol object envelope: `id`, `issuer`, +`subject`, timestamps, and `payload_hash`. `issuer` is the reporter and +`subject` is the affected agent id. Resolution changes recompute the payload +hash so trust and policy can cite the exact incident state they evaluated. + ## Evidence The local root daemon appends a hash-only `incident.reported` event when an diff --git a/docs/protocol/revocation.md b/docs/protocol/revocation.md index a68b0e0..a5e56eb 100644 --- a/docs/protocol/revocation.md +++ b/docs/protocol/revocation.md @@ -19,6 +19,10 @@ Current implementation anchor: Revocation must be checked before trust, policy, session, and invocation flows complete. +`RevocationRecordV2` uses the shared protocol object envelope: `id`, `issuer`, +`subject`, timestamps, and `payload_hash`. The subject is the revoked target id, +so policy and evidence can bind to the exact authority surface being disabled. + ## Evidence The local root daemon appends a hash-only `revocation.recorded` event when a diff --git a/packages/core/src/revocation.ts b/packages/core/src/revocation.ts index 3f37636..db64d41 100644 --- a/packages/core/src/revocation.ts +++ b/packages/core/src/revocation.ts @@ -34,6 +34,7 @@ export interface RevocationRecordV2 { schema_version: 'fides.revocation.record.v1' id: string issuer: string + subject: string target_type: RevocationTargetType target_id: string reason: string @@ -47,6 +48,8 @@ export interface RevocationRecordV2 { export interface IncidentRecordV2 { schema_version: 'fides.incident.record.v1' id: string + issuer: string + subject: string reporter: string target_agent_id: string severity: 'low' | 'medium' | 'high' | 'critical' @@ -144,6 +147,7 @@ export function createRevocationRecordV2(input: RevocationInputV2): RevocationRe schema_version: 'fides.revocation.record.v1' as const, id: crypto.randomUUID(), issuer: input.issuer, + subject: input.targetId, target_type: input.targetType, target_id: input.targetId, reason: input.reason, @@ -163,6 +167,8 @@ export function createIncidentRecordV2(input: IncidentInputV2): IncidentRecordV2 const payload = { schema_version: 'fides.incident.record.v1' as const, id: crypto.randomUUID(), + issuer: input.reporter, + subject: input.targetAgentId, reporter: input.reporter, target_agent_id: input.targetAgentId, severity: input.severity, diff --git a/packages/core/test/revocation-incident-v2.test.ts b/packages/core/test/revocation-incident-v2.test.ts index 53a35a7..a39771c 100644 --- a/packages/core/test/revocation-incident-v2.test.ts +++ b/packages/core/test/revocation-incident-v2.test.ts @@ -24,11 +24,13 @@ describe('revocation and incident v2 records', () => { expect(record).toMatchObject({ schema_version: 'fides.revocation.record.v1', issuer: issuer.did, + subject: 'sess_123', target_type: 'session', target_id: 'sess_123', status: 'active', evidence_refs: ['evt_1'], }) + expect(record.payload_hash).toMatch(/^sha256:/) const signed = await signRevocationRecordV2(record, issuer.privateKey, issuer.did) expect(await verifySignedRevocationRecordV2(signed)).toBe(true) @@ -47,6 +49,8 @@ describe('revocation and incident v2 records', () => { expect(incident).toMatchObject({ schema_version: 'fides.incident.record.v1', + issuer: reporter.did, + subject: 'did:fides:agent', reporter: reporter.did, target_agent_id: 'did:fides:agent', severity: 'high', @@ -55,6 +59,7 @@ describe('revocation and incident v2 records', () => { evidence_refs: ['evt_2'], }) expect(incident.trust_penalty).toBeGreaterThan(0) + expect(incident.payload_hash).toMatch(/^sha256:/) const signed = await signIncidentRecordV2(incident, reporter.privateKey, reporter.did) expect(await verifySignedIncidentRecordV2(signed)).toBe(true) @@ -62,5 +67,6 @@ describe('revocation and incident v2 records', () => { const resolved = resolveIncidentRecordV2(incident, 'false_positive') expect(resolved.resolution_status).toBe('false_positive') expect(resolved.resolved_at).toBeDefined() + expect(resolved.payload_hash).not.toBe(incident.payload_hash) }) }) From a4f307a48a1b4ae298c44d97e95ecc09090ca90b Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:03:29 +0300 Subject: [PATCH 088/282] feat(evidence): add privacy-aware v2 exports --- docs/protocol/evidence-ledger.md | 12 ++++++ packages/evidence/src/index.ts | 51 +++++++++++++++++++++++++ packages/evidence/test/evidence.test.ts | 33 ++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/docs/protocol/evidence-ledger.md b/docs/protocol/evidence-ledger.md index c43fb08..8f4889d 100644 --- a/docs/protocol/evidence-ledger.md +++ b/docs/protocol/evidence-ledger.md @@ -30,3 +30,15 @@ verified against an anchored root without disclosing the entire log. Export should preserve enough metadata to audit without leaking sensitive inputs or outputs. + +## Privacy-Aware Export + +`packages/evidence/src/index.ts` provides V2 export helpers: + +- `redactEvidenceEventV2(event, options)` +- `exportEvidenceEventsV2(events, options)` + +Default export behavior honors each event's `privacy_mode`. Hash-only events +retain input/output/policy hashes but omit metadata by default. Private exports +remove hashes, decisions, risk level, and metadata. Public exports can include +metadata when explicitly requested or when the export mode is public. diff --git a/packages/evidence/src/index.ts b/packages/evidence/src/index.ts index 40dbbb8..8ac3234 100644 --- a/packages/evidence/src/index.ts +++ b/packages/evidence/src/index.ts @@ -90,6 +90,11 @@ export interface EvidenceEventV2 { metadata?: Record } +export interface EvidenceEventV2ExportOptions { + privacy_mode?: EvidencePrivacyMode + include_metadata?: boolean +} + export interface EvidenceEventV2Input { event_id?: string type: EvidenceEventType @@ -213,6 +218,52 @@ export function verifyEvidenceEventsV2(events: EvidenceEventV2[]): boolean { return true } +export function redactEvidenceEventV2( + event: EvidenceEventV2, + options: EvidenceEventV2ExportOptions = {} +): EvidenceEventV2 { + const privacyMode = options.privacy_mode ?? event.privacy_mode + const includeMetadata = options.include_metadata ?? privacyMode === 'public' + const exported: EvidenceEventV2 = { + ...event, + privacy_mode: privacyMode, + ...(includeMetadata ? {} : { metadata: undefined }), + } + + if (privacyMode === 'public') return exported + + if (privacyMode === 'private') { + return { + ...exported, + input_hash: undefined, + output_hash: undefined, + policy_hash: undefined, + decision: undefined, + risk_level: undefined, + metadata: undefined, + } + } + + if (privacyMode === 'redacted') { + return { + ...exported, + metadata: includeMetadata ? exported.metadata : undefined, + } + } + + return { + ...exported, + metadata: undefined, + } +} + +export function exportEvidenceEventsV2( + events: EvidenceEventV2[], + options: EvidenceEventV2ExportOptions = {} +): EvidenceEventV2[] { + return events.map(event => redactEvidenceEventV2(event, options)) +} + /** * Build a Merkle tree from event hashes and return the root. */ diff --git a/packages/evidence/test/evidence.test.ts b/packages/evidence/test/evidence.test.ts index 34f0c84..a023a4a 100644 --- a/packages/evidence/test/evidence.test.ts +++ b/packages/evidence/test/evidence.test.ts @@ -6,7 +6,9 @@ import { buildMerkleProof, createEvidenceChain, createEvidenceEventV2, + exportEvidenceEventsV2, hashEvidenceValue, + redactEvidenceEventV2, redactEvent, signEvidenceEventV2, verifyEvidenceChain, @@ -189,4 +191,35 @@ describe('Evidence Ledger', () => { expect(verifyEvidenceEventsV2(events)).toBe(true) expect(verifyEvidenceEventsV2([{ ...second, prev_event_hash: 'wrong' }])).toBe(false) }) + + it('exports v2 events according to privacy mode without exposing metadata by default', () => { + const event = createEvidenceEventV2({ + type: 'capability.completed', + actor: 'did:fides:agent', + subject: 'did:fides:target', + input: { invoiceId: 'inv_123', secret: 'hidden-input' }, + output: { status: 'ok', secret: 'hidden-output' }, + policy: { decision: 'allow' }, + decision: 'allow', + risk_level: 'medium', + metadata: { rawPrompt: 'do not export this' }, + timestamp: '2026-05-29T00:00:00.000Z', + }) + + const hashOnly = redactEvidenceEventV2(event) + expect(hashOnly.input_hash).toMatch(/^sha256:/) + expect(hashOnly.output_hash).toMatch(/^sha256:/) + expect(hashOnly.metadata).toBeUndefined() + expect(JSON.stringify(hashOnly)).not.toContain('hidden-input') + expect(JSON.stringify(hashOnly)).not.toContain('rawPrompt') + + const privateExport = redactEvidenceEventV2(event, { privacy_mode: 'private' }) + expect(privateExport.input_hash).toBeUndefined() + expect(privateExport.output_hash).toBeUndefined() + expect(privateExport.policy_hash).toBeUndefined() + expect(privateExport.decision).toBeUndefined() + + const publicExport = exportEvidenceEventsV2([event], { privacy_mode: 'public' }) + expect(publicExport[0].metadata).toEqual({ rawPrompt: 'do not export this' }) + }) }) From b32d5bb40fd3d2a709772e8569db4a8f22048fd7 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:04:43 +0300 Subject: [PATCH 089/282] docs: clarify agentd cli alias --- docs/inspection/fides-report.md | 10 +++++----- packages/cli/README.md | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/inspection/fides-report.md b/docs/inspection/fides-report.md index 6c4eeb9..9a5c500 100644 --- a/docs/inspection/fides-report.md +++ b/docs/inspection/fides-report.md @@ -76,9 +76,9 @@ Local evidence: | Version negotiation | Present | `packages/core/src/versioning.ts`, `packages/core/src/discovery.ts`, and `packages/discovery/src/orchestrator.ts` negotiate and filter discovery candidates by protocol compatibility. | | Typed errors | Present, evolving | `packages/core/src/errors.ts` defines stable `ErrorEnvelope` objects with code, category, severity, retryable, message, and details across identity, AgentCard, capability, trust, policy, approval, session, attestation, DHT, evidence, revocation, incident, kill switch, and version errors. | | Explainability | Partial | Guard and policy return factors/explanations in `packages/guard/src/index.ts` and `packages/policy/src/index.ts`. | -| Adversarial simulation | Present as test, incomplete harness | `tests/adversarial/adversarial.test.ts`; no `agentd simulate adversarial` command found. | -| Interop adapters | Partial | SDK and CLI have A2A/FIDES-era surfaces; explicit MCP/A2A/OAPS/OSP/AP2/x402/Sardis adapter package not found. | -| CLI | Present, command name is `fides` | `packages/cli/src/index.ts`. Requested `agentd` CLI naming is not present. | +| Adversarial simulation | Present, local API-backed | `packages/cli/src/commands/simulate.ts` exposes `agentd simulate adversarial`; `services/agentd/src/index.ts` handles `/simulate/adversarial`. The harness remains prototype-level. | +| Interop adapters | Present, adapter-ready | `packages/adapters/src/index.ts` defines MCP, A2A, OAPS, OSP, AP2, x402, and Sardis adapter manifests and mapping contracts; production protocol integrations remain adapter-ready. | +| CLI | Present | `packages/cli/package.json` exposes both `fides` and `agentd` bins; `packages/cli/src/cli-name.ts` switches help text based on the invoked binary. | | Local HTTP API | Present at `/v1/*`, not requested exact endpoint set | `services/agentd/src/index.ts`, `docs/api/agentd.yaml`. | | SDK | Present | `packages/sdk/src/index.ts`, `packages/sdk/src/fides.ts`. | | Examples/demo | Present, not full requested v2 demo | `examples/`. | @@ -126,7 +126,7 @@ I could not find these in the repo as complete v2 implementations: - Full version negotiation. - Stable ErrorEnvelope vocabulary with code/category/severity/retryable/details. - MCP/A2A/OAPS/OSP/AP2/x402/Sardis adapter package. -- `agentd demo run` and `agentd simulate adversarial` CLI commands. +- Production-grade `agentd demo run` and `agentd simulate adversarial` scenarios beyond the current local API-backed prototype commands. - Requested local SQLite daemon storage layout. ## 7. Conflicts With FIDES v2 Architecture @@ -135,7 +135,7 @@ I could not find these in the repo as complete v2 implementations: - There are two AgentCard shapes: `packages/core/src/agent-card.ts` and `packages/shared/src/types.ts`. They need consolidation. - DHT provider currently stores AgentCards directly; v2 requires DHT to provide signed pointers only and never act as a trust source. - Policy actions use `approve-required` and `dry-run`; the user-facing spec uses `require_approval`, `dry_run_only`, `scope_limit`, and `risk_limit`. This needs vocabulary normalization or compatibility mapping. -- CLI binary is `fides`, while requested commands are under `agentd`. Decide whether `agentd` becomes an alias/binary or a subcommand. +- CLI exposes both `fides` and `agentd` binaries; the remaining gap is production-grade coverage of the requested command behavior, not the binary name. - Service APIs use `/v1/*` and differ from the requested local HTTP API paths. Add compatibility routes or document versioned API mapping. - FIDES currently contains payment examples such as `payments.execute`; generic FIDES must keep execution payment-specific behavior in Sardis and support only generic dry-run/payment-prep patterns. diff --git a/packages/cli/README.md b/packages/cli/README.md index 9176070..21ccc67 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -19,12 +19,12 @@ fides identity list fides identity show did:fides:... fides sign https://api.example.com/data --method GET fides card publish agent-card.json --registry-url http://localhost:7346 -fides demo run --agentd-url http://localhost:7345 -fides simulate adversarial --agentd-url http://localhost:7345 -fides daemon status --agentd-url http://localhost:7345 +agentd demo run --agentd-url http://localhost:7345 +agentd simulate adversarial --agentd-url http://localhost:7345 +agentd daemon status --agentd-url http://localhost:7345 ``` -Use `fides --help` and command-specific `--help` output for the full command surface. +Use `fides --help`, `agentd --help`, and command-specific `--help` output for the full command surface. Both binaries point to the same CLI; `agentd` is the preferred name for local authority, daemon, demo, and simulation workflows. ## License From 2796023fafb48348d978ce24cc589589ffa89000 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:06:15 +0300 Subject: [PATCH 090/282] feat(adapters): include trust in generic mappings --- docs/protocol/interop-adapters.md | 3 ++- packages/adapters/src/index.ts | 1 + packages/adapters/test/adapters.test.ts | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/protocol/interop-adapters.md b/docs/protocol/interop-adapters.md index 8603ef8..4a3511a 100644 --- a/docs/protocol/interop-adapters.md +++ b/docs/protocol/interop-adapters.md @@ -17,7 +17,8 @@ Current implementation anchor: - x402 - Sardis -Adapters map identity, AgentCards, capabilities, delegation, policy, evidence, invocation, and payment/action flows where relevant. +Adapters map identity, AgentCards, capabilities, trust, delegation, policy, +evidence, invocation, and payment/action flows where relevant. Payment-specific execution remains outside generic FIDES. diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 001bed6..400a417 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -265,6 +265,7 @@ export function defaultSurfacesForAdapter(kind: AdapterKind): AdapterProtocolSur 'identity', 'agent_card', 'capability', + 'trust', 'delegation', 'policy', 'evidence', diff --git a/packages/adapters/test/adapters.test.ts b/packages/adapters/test/adapters.test.ts index e9fbda1..9ee6315 100644 --- a/packages/adapters/test/adapters.test.ts +++ b/packages/adapters/test/adapters.test.ts @@ -97,6 +97,7 @@ describe('FIDES interop adapter interfaces', () => { 'identity', 'agent_card', 'capability', + 'trust', 'delegation', 'policy', 'evidence', @@ -108,6 +109,7 @@ describe('FIDES interop adapter interfaces', () => { expect(defaultSurfacesForAdapter('sardis')).toContain('payment_action_flow') expect(defaultSurfacesForAdapter('x402')).toContain('payment_action_flow') expect(defaultSurfacesForAdapter('ap2')).toContain('payment_action_flow') + expect(defaultSurfacesForAdapter('oaps')).toContain('trust') expect(defaultSurfacesForAdapter('oaps')).not.toContain('payment_action_flow') const manifest = createAdapterManifest({ kind: 'sardis', name: 'Sardis adapter' }) From b6c7b14ed96907f9699dfe80355d6870b6691790 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:08:39 +0300 Subject: [PATCH 091/282] feat(sdk): expose candidate-only discovery results --- docs/sdk-reference.md | 5 +++++ packages/sdk/src/fides-client.ts | 2 ++ packages/sdk/test/fides-client.test.ts | 15 +++++++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 88514e8..44feff1 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -29,6 +29,11 @@ await client.agents.register({ agentCardId: identity.identity.did }) await client.agents.list() await client.agents.inspect(identity.identity.did) const results = await client.discovery.find({ capability: 'invoice.reconcile' }) +// Discovery returns candidates only. It does not grant authority. +const candidate = results.candidates?.[0] +if (candidate?.authority !== 'candidate_only') { + throw new Error('Unexpected authoritative discovery result') +} await client.discovery.local({ capability: 'invoice.reconcile' }) await client.discovery.registry({ capability: 'invoice.reconcile', diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 06542a3..9a79552 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -30,10 +30,12 @@ export interface FidesDiscoveryQuery { export interface FidesProviderRecord { agentId?: string agent_id?: string + authority?: 'candidate_only' cardId?: string capability?: string capabilities?: string[] authorityGranted?: false + evidence_refs?: string[] agentCardUrl?: string agent_card_url?: string agentCardHash?: string diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index d49f72d..fe24b33 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -425,7 +425,14 @@ describe('FidesClient', () => { if (String(url).endsWith('/agents')) { return new Response(JSON.stringify({ agents: [{ agentId: 'did:fides:agent' }] }), { status: 200 }) } - return new Response(JSON.stringify({ authorityGranted: false, candidates: [{ agentId: 'did:fides:agent' }] }), { status: 200 }) + return new Response(JSON.stringify({ + authorityGranted: false, + candidates: [{ + agentId: 'did:fides:agent', + authority: 'candidate_only', + evidence_refs: ['evt_discovery'], + }], + }), { status: 200 }) })) const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) @@ -438,7 +445,11 @@ describe('FidesClient', () => { await expect(client.agents.inspect('did:fides:agent')).resolves.toMatchObject({ agentId: 'did:fides:agent' }) await expect(client.discovery.find({ capability: 'invoice.reconcile' })).resolves.toMatchObject({ authorityGranted: false, - candidates: [{ agentId: 'did:fides:agent' }], + candidates: [{ + agentId: 'did:fides:agent', + authority: 'candidate_only', + evidence_refs: ['evt_discovery'], + }], }) expect(calls.map(call => call.url)).toEqual([ From 4fa0d7d2b8c07c082929cc338473a4ad6faa74ba Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:10:20 +0300 Subject: [PATCH 092/282] docs(api): add federation discovery contract --- docs/api/agentd.yaml | 94 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 77e6ede..b1a031a 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -21,6 +21,7 @@ tags: - name: Health - name: Identity - name: Card + - name: Discovery - name: Trust - name: Policy - name: Evidence @@ -580,6 +581,29 @@ paths: schema: $ref: "#/components/schemas/KillSwitchResponse" + /discover/federation: + post: + operationId: discoverFederation + tags: [Discovery] + summary: Discover candidates through federated registry peers + description: | + Queries local mock federation peers for capability candidates. + Federation expands discovery only; returned candidates are marked + `authority: candidate_only` and never grant invocation authority. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoveryQuery" + responses: + "200": + description: Federation candidates and rejected candidates + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoveryResponse" + /metrics: get: operationId: getMetrics @@ -697,6 +721,76 @@ components: type: string enum: [invalid-domain, invalid-did, record-not-found, resolver-error] + DiscoveryQuery: + type: object + properties: + capability: + type: string + example: invoice.reconcile + constraints: + type: object + supported_versions: + type: array + items: + type: string + example: ["fides.v2.0"] + required_versions: + type: array + items: + type: string + example: ["fides.v2.0"] + limit: + type: integer + minimum: 1 + + DiscoveryCandidate: + type: object + properties: + provider: + type: string + agentId: + type: string + capability: + type: string + authority: + type: string + enum: [candidate_only] + verified: + type: boolean + rank: + type: number + evidence_refs: + type: array + items: + type: string + explanations: + type: array + items: + type: string + + DiscoveryResponse: + type: object + required: [authorityGranted] + properties: + provider: + type: string + example: federation + capability: + type: string + candidates: + type: array + items: + $ref: "#/components/schemas/DiscoveryCandidate" + rejectedCandidates: + type: array + items: + $ref: "#/components/schemas/DiscoveryCandidate" + authorityGranted: + type: boolean + enum: [false] + explanation: + type: string + CardResponse: type: object properties: From c4edc49dbdcb648a45821df94a04a2cf8a4071e1 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:12:26 +0300 Subject: [PATCH 093/282] feat(sdk): support evidence export privacy options --- docs/sdk-reference.md | 2 +- packages/sdk/README.md | 2 +- packages/sdk/src/fides-client.ts | 7 ++++++- packages/sdk/test/fides-client.test.ts | 6 +++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 44feff1..e6a9038 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -151,7 +151,7 @@ const evidence = await client.evidence.append({ }) await client.evidence.inspect(evidence.event.event_id) await client.evidence.verify() -await client.evidence.export() +await client.evidence.export({ privacy_mode: 'hash_only', include_metadata: false }) ``` `identity.createAgent`, `identity.list`, and `identity.show` target the root diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 8efeb06..c4a2ff7 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -145,7 +145,7 @@ const evidence = await client.evidence.append({ }) await client.evidence.inspect(evidence.event.event_id) await client.evidence.verify() -await client.evidence.export() +await client.evidence.export({ privacy_mode: 'hash_only', include_metadata: false }) ``` The local identity API returns public identity data only; it does not return diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 9a79552..6f0afcf 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -103,6 +103,11 @@ export interface FidesSignedInvocationRequest { issuedAt?: string } +export interface FidesEvidenceExportRequest { + privacy_mode?: 'public' | 'private' | 'redacted' | 'hash_only' + include_metadata?: boolean +} + export interface FidesInvocationResponse { authorityGranted: boolean session: SessionGrantV2 @@ -246,7 +251,7 @@ export class FidesClient { list: () => this.get('/evidence'), inspect: (eventId: string) => this.get(`/evidence/${encodeURIComponent(eventId)}`), verify: () => this.post('/evidence/verify', {}), - export: () => this.post('/evidence/export', {}), + export: (body: FidesEvidenceExportRequest = {}) => this.post('/evidence/export', body), } readonly demo = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index fe24b33..ac3e922 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -89,7 +89,7 @@ describe('FidesClient', () => { await client.evidence.list() await client.evidence.inspect('evt_1') await client.evidence.verify() - await client.evidence.export() + await client.evidence.export({ privacy_mode: 'hash_only', include_metadata: false }) await client.demo.run() await client.simulate.adversarial() await client.invoke({ sessionId: 'sess_1', input: { invoiceId: 'inv_123' } }) @@ -224,6 +224,10 @@ describe('FidesClient', () => { capability: 'invoice.reconcile', agentId: 'did:fides:agent', }) + expect(JSON.parse(calls[52].init?.body as string)).toEqual({ + privacy_mode: 'hash_only', + include_metadata: false, + }) }) it('uses the root identity API served by local agentd', async () => { From 75e993682a0a1ad80f8f9016fd8fc4b97672be00 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:17:11 +0300 Subject: [PATCH 094/282] feat(agentd): mirror local state into sqlite tables --- docs/api-reference.md | 7 +- docs/deployment.md | 15 ++- services/agentd/src/index.ts | 1 + services/agentd/src/storage.ts | 169 +++++++++++++++++++++++++++ services/agentd/test/storage.test.ts | 52 ++++++++- 5 files changed, 235 insertions(+), 9 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 70d5dde..5dc5430 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -98,8 +98,11 @@ mock/local providers, and the root daemon persists its local v2 state through a SQLite-backed snapshot store by default outside tests. The default path is `~/.fides/fides.sqlite`; set `AGENTD_SQLITE_PATH` to override it or `AGENTD_LOCAL_STATE=memory` to disable persistence for ephemeral local runs. -This store is a daemon snapshot, not the final normalized SQLite table model for -production hardening. +This store keeps a daemon snapshot as the source of truth and mirrors the local +FIDES v2 collections into JSON index tables such as `identities`, +`agent_cards`, `capabilities`, `discovery_records`, `sessions`, +`evidence_events`, `revocations`, `incidents`, and `kill_switch_rules` for +local inspection and future normalized migrations. `POST /registry/start`, `POST /registry/publish`, `POST /registry/search`, and `GET /registry/index` provide a local mock registry over registered AgentCards. diff --git a/docs/deployment.md b/docs/deployment.md index ce90611..47cf89f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -78,11 +78,16 @@ EvidenceEvents. | `AGENTD_LOCAL_STATE` | `sqlite` outside tests, `memory` in tests | no | `sqlite` persists the root v2 local daemon snapshot. `memory` keeps the root v2 prototype ephemeral for local test runs. | | `AGENTD_SQLITE_PATH` | `~/.fides/fides.sqlite` | no | SQLite file path for the root v2 local daemon snapshot store. | -The SQLite store currently writes a single schema-versioned snapshot row plus a -local migration ledger. It is durable across daemon restarts, but it is not yet -the final normalized table layout. Local identity private key material used for -prototype signing is included in this snapshot and should be protected by local -filesystem permissions; OS-backed encryption or hardware-backed key storage is a +The SQLite store writes a schema-versioned root snapshot plus JSON index tables +for the local FIDES v2 collections: identities, trust anchors, attestations, +AgentCards, agents, capabilities, discovery/DHT/registry/relay records, trust +and reputation results, policy decisions, approvals, delegations, sessions, +evidence events, revocations, incidents, and kill switch rules. The snapshot is +the source of truth for this local prototype; the index tables make the local +database inspectable and migration-ready for the final normalized storage +layout. Local identity private key material used for prototype signing is +included in this snapshot and should be protected by local filesystem +permissions; OS-backed encryption or hardware-backed key storage is a production hardening item. ### Registry Store diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 2084f58..4646d1a 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -3550,6 +3550,7 @@ async function runLocalAdversarialSimulation() { schema_version: 'fides.invocation.request.v1', id: 'inv_req_malicious', issuer: requester.identity.did, + subject: malicious.card.identity.did, session_id: 'missing-session', requester_agent_id: requester.identity.did, target_agent_id: malicious.card.identity.did, diff --git a/services/agentd/src/storage.ts b/services/agentd/src/storage.ts index acf7889..7170ce6 100644 --- a/services/agentd/src/storage.ts +++ b/services/agentd/src/storage.ts @@ -697,6 +697,7 @@ export class SqliteLocalDaemonStateStore implements LocalDaemonStateStore { VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET snapshot = excluded.snapshot, updated_at = excluded.updated_at `).run('root', JSON.stringify(normalized), normalized.updatedAt) + mirrorLocalDaemonStateTables(db, normalized) } async healthCheck(): Promise<{ ok: boolean; kind: 'sqlite'; path: string; detail?: string }> { @@ -747,11 +748,179 @@ export class SqliteLocalDaemonStateStore implements LocalDaemonStateStore { ); INSERT OR IGNORE INTO agentd_local_state_migrations (id) VALUES ('001_local_state_snapshot'); `) + ensureLocalDaemonStateIndexTables(db) this.db = db return db as Awaited> } } +type SqliteDatabase = Awaited> + +const LOCAL_DAEMON_STATE_INDEX_TABLES = [ + 'identities', + 'trust_anchors', + 'attestations', + 'agent_cards', + 'agents', + 'capabilities', + 'discovery_records', + 'dht_records', + 'registry_records', + 'relay_records', + 'trust_results', + 'reputation_records', + 'policy_decisions', + 'approvals', + 'delegations', + 'sessions', + 'evidence_events', + 'revocations', + 'incidents', + 'kill_switch_rules', +] as const + +type LocalDaemonStateIndexTable = typeof LOCAL_DAEMON_STATE_INDEX_TABLES[number] + +function ensureLocalDaemonStateIndexTables(db: { exec(statement: string): void }): void { + for (const table of LOCAL_DAEMON_STATE_INDEX_TABLES) { + db.exec(` + CREATE TABLE IF NOT EXISTS ${table} ( + id TEXT PRIMARY KEY, + record TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `) + } +} + +function mirrorLocalDaemonStateTables(db: SqliteDatabase, snapshot: LocalDaemonStateSnapshot): void { + const collections: Record = { + identities: snapshot.identities, + trust_anchors: collectTrustAnchors(snapshot.agentCards), + attestations: snapshot.runtimeAttestations, + agent_cards: snapshot.agentCards, + agents: snapshot.agents, + capabilities: collectCapabilities(snapshot.agentCards), + discovery_records: collectDiscoveryRecords(snapshot), + dht_records: snapshot.dhtPointers, + registry_records: snapshot.registryRecords, + relay_records: snapshot.relayRecords, + trust_results: snapshot.trustResults, + reputation_records: snapshot.reputationRecords, + policy_decisions: collectPolicyDecisions(snapshot.sessionGrants), + approvals: [...snapshot.approvals, ...snapshot.approvalDecisions], + delegations: snapshot.delegationTokens, + sessions: snapshot.sessionGrants, + evidence_events: snapshot.evidenceEvents, + revocations: snapshot.revocationRecords, + incidents: snapshot.incidentRecords, + kill_switch_rules: snapshot.killSwitchRules, + } + + for (const [table, rows] of Object.entries(collections) as Array<[LocalDaemonStateIndexTable, unknown[]]>) { + db.prepare(`DELETE FROM ${table}`).run() + const insert = db.prepare(`INSERT INTO ${table} (id, record, updated_at) VALUES (?, ?, ?)`) + rows.forEach((row, index) => { + insert.run(localStateRowId(table, row, index), JSON.stringify(row), snapshot.updatedAt) + }) + } +} + +function collectTrustAnchors(agentCards: unknown[]): unknown[] { + return agentCards.flatMap((card) => { + if (!card || typeof card !== 'object') return [] + const anchors = (card as { trustAnchors?: unknown; trust_anchors?: unknown }).trustAnchors + ?? (card as { trustAnchors?: unknown; trust_anchors?: unknown }).trust_anchors + return Array.isArray(anchors) ? anchors : [] + }) +} + +function collectCapabilities(agentCards: unknown[]): unknown[] { + return agentCards.flatMap((card) => { + if (!card || typeof card !== 'object') return [] + const capabilities = (card as { capabilities?: unknown }).capabilities + if (!Array.isArray(capabilities)) return [] + const cardId = (card as { id?: unknown }).id + return capabilities.map((capability) => ({ + ...(capability && typeof capability === 'object' ? capability as Record : { value: capability }), + agent_card_id: typeof cardId === 'string' ? cardId : undefined, + })) + }) +} + +function collectDiscoveryRecords(snapshot: LocalDaemonStateSnapshot): unknown[] { + return [ + ...snapshot.dhtPointers.map(record => ({ provider: 'dht', ...objectRecord(record) })), + ...snapshot.registryRecords.map(record => ({ provider: 'registry', ...objectRecord(record) })), + ...snapshot.relayRecords.map(record => ({ provider: 'relay', ...objectRecord(record) })), + ] +} + +function collectPolicyDecisions(sessionGrants: unknown[]): unknown[] { + return sessionGrants.flatMap((record) => { + if (!record || typeof record !== 'object') return [] + const policy = (record as { policy?: unknown }).policy + if (!policy || typeof policy !== 'object') return [] + const session = (record as { session?: { session_id?: unknown } }).session + return [{ + ...(policy as Record), + session_id: typeof session?.session_id === 'string' ? session.session_id : undefined, + }] + }) +} + +function objectRecord(value: unknown): Record { + return value && typeof value === 'object' ? value as Record : { value } +} + +function localStateRowId(table: LocalDaemonStateIndexTable, row: unknown, index: number): string { + const record = objectRecord(row) + if (table === 'capabilities') { + const cardId = typeof record.agent_card_id === 'string' ? record.agent_card_id : 'unknown-card' + const capabilityId = typeof record.id === 'string' + ? record.id + : typeof record.capability_id === 'string' + ? record.capability_id + : `capability-${index}` + return `${cardId}:${capabilityId}` + } + if (table === 'discovery_records') { + const provider = typeof record.provider === 'string' ? record.provider : 'unknown-provider' + const agentId = typeof record.agent_id === 'string' + ? record.agent_id + : typeof record.agentId === 'string' + ? record.agentId + : `record-${index}` + const capability = typeof record.capability === 'string' ? record.capability : 'unknown-capability' + return `${provider}:${agentId}:${capability}:${index}` + } + if (table === 'trust_results' || table === 'reputation_records') { + const agentId = typeof record.agent_id === 'string' + ? record.agent_id + : typeof record.agentId === 'string' + ? record.agentId + : `agent-${index}` + const capability = typeof record.capability === 'string' ? record.capability : `capability-${index}` + return `${agentId}:${capability}` + } + for (const key of [ + 'id', + 'did', + 'agent_id', + 'agentId', + 'cardId', + 'event_id', + 'session_id', + 'attestation_id', + 'capability_id', + 'capabilityId', + ]) { + const value = record[key] + if (typeof value === 'string' && value.length > 0) return value + } + return `${table}:${index}` +} + export function emptyLocalDaemonStateSnapshot(updatedAt = new Date().toISOString()): LocalDaemonStateSnapshot { return { schemaVersion: 'fides.agentd.local_state.v1', diff --git a/services/agentd/test/storage.test.ts b/services/agentd/test/storage.test.ts index e975529..21ee384 100644 --- a/services/agentd/test/storage.test.ts +++ b/services/agentd/test/storage.test.ts @@ -1,6 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { createRequire } from 'node:module' import postgres from 'postgres' import { afterEach, describe, expect, it } from 'vitest' import { appendEvidenceEvent, createEvidenceChain } from '@fides/evidence' @@ -19,6 +20,7 @@ import { const tempDirs: string[] = [] const postgresUrl = process.env.AGENTD_DATABASE_URL || process.env.DATABASE_URL const postgresTestRequired = process.env.AGENTD_POSTGRES_TEST_REQUIRED === 'true' +const require = createRequire(import.meta.url) afterEach(async () => { await Promise.all(tempDirs.map(dir => rm(dir, { recursive: true, force: true }))) @@ -149,9 +151,21 @@ describe('agentd authority stores', () => { const snapshot = { ...emptyLocalDaemonStateSnapshot('2026-01-01T00:00:00.000Z'), identities: [{ did: 'did:fides:agent' }], - agentCards: [{ id: 'card-1' }], + agentCards: [{ + id: 'card-1', + trustAnchors: [{ id: 'anchor-1', type: 'github' }], + capabilities: [{ id: 'invoice.reconcile' }], + }], agents: [{ agentId: 'did:fides:agent', cardId: 'card-1' }], + dhtPointers: [{ agent_id: 'did:fides:agent', capability: 'invoice.reconcile' }], + registryRecords: [{ id: 'registry-record-1', agentId: 'did:fides:agent' }], + relayRecords: [{ id: 'relay-record-1', agentId: 'did:fides:agent' }], evidenceEvents: [{ event_id: 'evt-1' }], + runtimeAttestations: [{ attestation_id: 'att-1' }], + sessionGrants: [{ session: { session_id: 'sess-1' }, policy: { id: 'policy-decision-1', decision: 'allow' } }], + revocationRecords: [{ id: 'rev-1' }], + incidentRecords: [{ id: 'inc-1' }], + killSwitchRules: [{ id: 'kill-1' }], } await store.save(snapshot) @@ -164,10 +178,44 @@ describe('agentd authority stores', () => { expect(loaded).toMatchObject({ schemaVersion: 'fides.agentd.local_state.v1', identities: [{ did: 'did:fides:agent' }], - agentCards: [{ id: 'card-1' }], + agentCards: [expect.objectContaining({ id: 'card-1' })], agents: [{ agentId: 'did:fides:agent', cardId: 'card-1' }], evidenceEvents: [{ event_id: 'evt-1' }], }) + + const { DatabaseSync } = require('node:sqlite') as typeof import('node:sqlite') + const db = new DatabaseSync(path, { readOnly: true }) + try { + const tables = db.prepare(` + SELECT name FROM sqlite_master + WHERE type = 'table' + ORDER BY name + `).all() as Array<{ name: string }> + expect(tables.map(table => table.name)).toEqual(expect.arrayContaining([ + 'identities', + 'trust_anchors', + 'attestations', + 'agent_cards', + 'agents', + 'capabilities', + 'discovery_records', + 'dht_records', + 'registry_records', + 'relay_records', + 'policy_decisions', + 'sessions', + 'evidence_events', + 'revocations', + 'incidents', + 'kill_switch_rules', + ])) + expect((db.prepare('SELECT COUNT(*) AS count FROM identities').get() as { count: number }).count).toBe(1) + expect((db.prepare('SELECT COUNT(*) AS count FROM capabilities').get() as { count: number }).count).toBe(1) + expect((db.prepare('SELECT COUNT(*) AS count FROM discovery_records').get() as { count: number }).count).toBe(3) + expect((db.prepare('SELECT COUNT(*) AS count FROM policy_decisions').get() as { count: number }).count).toBe(1) + } finally { + db.close() + } }) it('rejects unsafe configured authority schema names', () => { From 55e03cfd7b6f56d6e7a3e366221aa57edf3ca640 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:19:55 +0300 Subject: [PATCH 095/282] feat(adapters): add rust primitive adapter contract --- packages/adapters/README.md | 23 ++++++ packages/adapters/src/index.ts | 93 +++++++++++++++++++++++++ packages/adapters/test/adapters.test.ts | 40 +++++++++++ packages/rust-sdk/README.md | 67 +++++++++++++----- 4 files changed, 207 insertions(+), 16 deletions(-) diff --git a/packages/adapters/README.md b/packages/adapters/README.md index 68d7e84..86c2378 100644 --- a/packages/adapters/README.md +++ b/packages/adapters/README.md @@ -14,11 +14,34 @@ This package describes how external protocols map into FIDES identity, AgentCard identity, AgentCards, capabilities, discovery candidates, trust, policy, delegation, sessions, approvals, invocation, evidence, attestations, revocations, incidents, and payment action-flow references. +- `RustPrimitiveAdapter` is the optional Rust/AGIT primitive boundary for + canonical JSON, hashing, canonical object signing, signature verification, + evidence hash-chain operations, Merkle proofs, and DAG primitives. Payment execution remains outside generic FIDES. AP2, x402, and Sardis adapters can expose payment action-flow mappings, but FIDES treats those as interop references for policy, authority, and evidence. +## Rust Adapter Readiness + +FIDES v2 is TS-first. Rust is not required at runtime for the first working +version. The Rust primitive adapter contract exists so a future AGIT/Rust core +can accelerate or harden primitives without changing public protocol objects or +Promise-based SDK APIs. + +Rust adapters must preserve the single canonical signing model used by FIDES. +They may implement: + +- canonical JSON serialization +- hashing +- canonical object signing and verification +- evidence hash-chain append/verify helpers +- Merkle proof creation/verification +- DAG primitives for evidence lineage + +Protocol objects remain framework-agnostic JSON. Adapters must not introduce a +Rust-specific wire format. + ## License MIT diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 400a417..30e7d47 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -177,6 +177,70 @@ export interface AdapterCoverageReport { missing: AdapterProtocolSurface[] } +export const RUST_PRIMITIVE_SURFACES = [ + 'canonical_json', + 'hashing', + 'object_signing', + 'signature_verification', + 'evidence_hash_chain', + 'merkle_proofs', + 'dag_primitives', +] as const + +export type RustPrimitiveSurface = typeof RUST_PRIMITIVE_SURFACES[number] + +export interface RustPrimitiveAdapterManifest { + schema_version: 'fides.rust_primitive_adapter.manifest.v1' + id: string + name: string + version: string + surfaces: RustPrimitiveSurface[] + runtime_dependency_required: false + created_at: string +} + +export interface CanonicalObjectSigningInput { + object: Record + issuer: string + privateKeyRef: string +} + +export interface CanonicalObjectVerificationInput { + signedObject: Record + publicKeyRef: string +} + +export interface EvidenceHashChainInput { + previousEventHash?: string + eventPayload: Record +} + +export interface EvidenceHashChainResult { + eventHash: string + previousEventHash?: string +} + +export interface MerkleProofInput { + leaves: string[] + leaf: string +} + +export interface MerkleProofResult { + root: string + leaf: string + proof: string[] +} + +export interface RustPrimitiveAdapter { + readonly manifest: RustPrimitiveAdapterManifest + canonicalizeJson?(value: unknown): Promise | string + hashBytes?(bytes: Uint8Array, algorithm?: 'sha256'): Promise | string + signCanonicalObject?(input: CanonicalObjectSigningInput): Promise> | Record + verifyCanonicalObject?(input: CanonicalObjectVerificationInput): Promise | boolean + appendEvidenceHash?(input: EvidenceHashChainInput): Promise | EvidenceHashChainResult + createMerkleProof?(input: MerkleProofInput): Promise | MerkleProofResult +} + export interface FidesInteropAdapter { readonly kind: AdapterKind readonly manifest: AdapterManifest @@ -294,3 +358,32 @@ export function validateAdapterCoverage( missing, } } + +export function createRustPrimitiveAdapterManifest(input: { + name: string + version?: string + surfaces?: RustPrimitiveSurface[] + createdAt?: string +}): RustPrimitiveAdapterManifest { + return { + schema_version: 'fides.rust_primitive_adapter.manifest.v1', + id: crypto.randomUUID(), + name: input.name, + version: input.version ?? '0.1.0', + surfaces: input.surfaces ?? [...RUST_PRIMITIVE_SURFACES], + runtime_dependency_required: false, + created_at: input.createdAt ?? new Date().toISOString(), + } +} + +export function validateRustPrimitiveAdapterCoverage( + manifest: RustPrimitiveAdapterManifest, + requiredSurfaces: RustPrimitiveSurface[] +): { valid: boolean; missing: RustPrimitiveSurface[] } { + const provided = new Set(manifest.surfaces) + const missing = requiredSurfaces.filter(surface => !provided.has(surface)) + return { + valid: missing.length === 0, + missing, + } +} diff --git a/packages/adapters/test/adapters.test.ts b/packages/adapters/test/adapters.test.ts index 9ee6315..1a1036f 100644 --- a/packages/adapters/test/adapters.test.ts +++ b/packages/adapters/test/adapters.test.ts @@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest' import { ADAPTER_KINDS, ADAPTER_PROTOCOL_SURFACES, + RUST_PRIMITIVE_SURFACES, createAdapterManifest, createAdapterMapping, createInteropMappingSet, + createRustPrimitiveAdapterManifest, defaultSurfacesForAdapter, isAdapterProtocolSurface, isPaymentAdapterKind, validateAdapterCoverage, + validateRustPrimitiveAdapterCoverage, } from '../src/index.js' describe('FIDES interop adapter interfaces', () => { @@ -174,4 +177,41 @@ describe('FIDES interop adapter interfaces', () => { missing: ['revocation'], }) }) + + it('declares Rust primitive adapter surfaces without making Rust a runtime dependency', () => { + expect(RUST_PRIMITIVE_SURFACES).toEqual([ + 'canonical_json', + 'hashing', + 'object_signing', + 'signature_verification', + 'evidence_hash_chain', + 'merkle_proofs', + 'dag_primitives', + ]) + + const manifest = createRustPrimitiveAdapterManifest({ + name: 'AGIT Rust primitive adapter', + version: '0.1.0', + surfaces: ['canonical_json', 'hashing', 'evidence_hash_chain'], + createdAt: '2026-01-01T00:00:00.000Z', + }) + + expect(manifest).toMatchObject({ + schema_version: 'fides.rust_primitive_adapter.manifest.v1', + name: 'AGIT Rust primitive adapter', + version: '0.1.0', + surfaces: ['canonical_json', 'hashing', 'evidence_hash_chain'], + runtime_dependency_required: false, + created_at: '2026-01-01T00:00:00.000Z', + }) + + expect(validateRustPrimitiveAdapterCoverage(manifest, [ + 'canonical_json', + 'hashing', + 'merkle_proofs', + ])).toEqual({ + valid: false, + missing: ['merkle_proofs'], + }) + }) }) diff --git a/packages/rust-sdk/README.md b/packages/rust-sdk/README.md index 58cf1d0..ff19060 100644 --- a/packages/rust-sdk/README.md +++ b/packages/rust-sdk/README.md @@ -1,23 +1,58 @@ -# FIDES Rust SDK +# FIDES Rust Adapter Contract -Rust implementation of the FIDES trust protocol client. +FIDES v2 is TS-first. Rust is adapter-ready, not required for the first working +version. -## Status +This directory documents the future Rust boundary for performance-critical or +audit-critical primitives. The active TypeScript contract lives in +`@fides/adapters` as `RustPrimitiveAdapter`. -Not yet implemented — placeholder for future development. +## Intended Sources -## Planned Tech Stack +- AGIT Rust core concepts for hash chains, lineage, DAG primitives, Merkle + proofs, canonicalization, and high-performance hashing. +- FIDES TypeScript protocol objects for the canonical wire model. -- Rust 1.75+ -- ed25519-dalek (cryptography) -- reqwest (HTTP client) -- serde (serialization) -- tokio (async runtime) +## Adapter Surfaces -## Planned Features +Future Rust adapters may implement: -- Ed25519 keypair generation and management -- RFC 9421 HTTP Message Signatures -- Discovery service client -- Trust graph API client -- High-performance signature verification +- canonical JSON serialization +- hashing +- canonical object signing +- canonical object signature verification +- evidence hash-chain append and verification helpers +- Merkle proof creation and verification +- DAG primitives for evidence lineage + +## Hard Constraints + +- Rust must not become a runtime dependency for the TypeScript SDK, CLI, daemon, + or public protocol objects. +- Rust adapters must preserve the FIDES canonical object signing model. +- Rust adapters must not introduce a separate wire format. +- Public SDK APIs remain Promise-based TypeScript APIs. +- Effect, if used internally, must not leak into Rust adapter protocol objects. + +## Current Status + +Adapter-ready contract only. No Rust crate is required or published yet. + +Use `@fides/adapters` for the current manifest and coverage helpers: + +```ts +import { + createRustPrimitiveAdapterManifest, + validateRustPrimitiveAdapterCoverage, +} from '@fides/adapters' + +const manifest = createRustPrimitiveAdapterManifest({ + name: 'AGIT Rust primitive adapter', + surfaces: ['canonical_json', 'hashing', 'evidence_hash_chain'], +}) + +validateRustPrimitiveAdapterCoverage(manifest, [ + 'canonical_json', + 'hashing', +]) +``` From ae0db740891bb9758af955f06cd41c1ebf127941 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:24:18 +0300 Subject: [PATCH 096/282] feat(sdk): add agit primitive bridge --- docs/sdk-reference.md | 27 ++++++ packages/sdk/README.md | 24 +++++ packages/sdk/src/index.ts | 7 ++ packages/sdk/src/integrations/agit.ts | 124 ++++++++++++++++++++++++++ packages/sdk/test/agit.test.ts | 79 ++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 packages/sdk/test/agit.test.ts diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index e6a9038..26e034f 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -204,3 +204,30 @@ from daemon-side verification. Advanced authority flows can use `AgentdClient`. `AgentdClient.health()` reads `GET /health` and returns typed authority-store and local-state-store status, including the SQLite snapshot path when the daemon exposes it. + +## AGIT / Rust Primitive Bridge + +`AgitPrimitiveBridge` gives the SDK an adapter-ready boundary for future +AGIT/Rust primitives without requiring Rust in the first working FIDES v2 +runtime. + +```ts +import { AgitPrimitiveBridge } from '@fides/sdk' + +const bridge = new AgitPrimitiveBridge() + +const canonical = await bridge.canonicalizeJson({ b: 2, a: 1 }) +const hash = await bridge.hashObject({ event_id: 'evt_1' }) +const chained = await bridge.appendEvidenceHash({ + previousEventHash: '0', + eventPayload: { event_id: 'evt_1', type: 'policy.evaluated' }, +}) +const proof = await bridge.createMerkleProof({ + leaves: [hash, chained.eventHash], + leaf: chained.eventHash, +}) +``` + +The bridge delegates to a supplied `RustPrimitiveAdapter` when present. +Otherwise it uses TypeScript canonical JSON and SHA-256 fallbacks. Public SDK +APIs remain Promise-based, and protocol objects stay FIDES-native JSON. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index c4a2ff7..c3eb4c8 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -163,6 +163,30 @@ attestations that can satisfy high-risk session policy when passed as an individual events, verify the root hash chain, and export the current local ledger. +## AGIT / Rust Primitive Bridge + +```typescript +import { AgitPrimitiveBridge } from '@fides/sdk' + +const bridge = new AgitPrimitiveBridge() + +const canonical = await bridge.canonicalizeJson({ b: 2, a: 1 }) +const objectHash = await bridge.hashObject({ event_id: 'evt_1' }) +const chained = await bridge.appendEvidenceHash({ + previousEventHash: '0', + eventPayload: { event_id: 'evt_1', type: 'policy.evaluated' }, +}) +const proof = await bridge.createMerkleProof({ + leaves: [objectHash, chained.eventHash], + leaf: chained.eventHash, +}) +``` + +`AgitPrimitiveBridge` is TS-first and works without Rust. A future AGIT/Rust +adapter can be supplied for canonical JSON, hashing, evidence hash-chain, +Merkle, and DAG primitives while preserving FIDES protocol objects and the +Promise-based SDK surface. + ```typescript import { AgentdClient } from '@fides/sdk' diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0a80d56..d4b88a6 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -152,8 +152,15 @@ export { // Integration exports export { + AgitPrimitiveBridge, AgitCommitSigner, TrustGatedAccess, + type AgitEvidenceHashChainInput, + type AgitEvidenceHashChainResult, + type AgitMerkleProofInput, + type AgitMerkleProofResult, + type AgitPrimitiveBridgeOptions, + type AgitRustPrimitiveAdapter, type CommitSignature, type CommitVerification, type TrustGateResult, diff --git a/packages/sdk/src/integrations/agit.ts b/packages/sdk/src/integrations/agit.ts index 89b0260..450e3f4 100644 --- a/packages/sdk/src/integrations/agit.ts +++ b/packages/sdk/src/integrations/agit.ts @@ -22,6 +22,9 @@ */ import type { TrustAttestation, TrustScore } from '@fides/shared' +import { canonicalJson, hashProtocolPayload } from '@fides/core' +import { sha256 } from '@noble/hashes/sha256' +import { bytesToHex } from '@noble/hashes/utils' import { generateKeyPair, sign, verify } from '../identity/keypair.js' import { generateDID, parseDID, isValidDID } from '../identity/did.js' import type { KeyStore } from '../identity/keystore.js' @@ -51,6 +54,127 @@ export interface TrustGateResult { reason?: string } +export interface AgitEvidenceHashChainInput { + previousEventHash?: string + eventPayload: Record +} + +export interface AgitEvidenceHashChainResult { + eventHash: string + previousEventHash?: string +} + +export interface AgitMerkleProofInput { + leaves: string[] + leaf: string +} + +export interface AgitMerkleProofResult { + root: string + leaf: string + proof: string[] +} + +export interface AgitRustPrimitiveAdapter { + canonicalizeJson?(value: unknown): Promise | string + hashBytes?(bytes: Uint8Array, algorithm?: 'sha256'): Promise | string + appendEvidenceHash?(input: AgitEvidenceHashChainInput): Promise | AgitEvidenceHashChainResult + createMerkleProof?(input: AgitMerkleProofInput): Promise | AgitMerkleProofResult +} + +export interface AgitPrimitiveBridgeOptions { + rustAdapter?: AgitRustPrimitiveAdapter +} + +/** + * AgitPrimitiveBridge provides the adapter-ready boundary between FIDES and + * future AGIT/Rust primitives. Rust is optional: when no adapter is supplied, + * the SDK uses the same TypeScript canonical JSON and hashing model as FIDES. + */ +export class AgitPrimitiveBridge { + constructor(private readonly options: AgitPrimitiveBridgeOptions = {}) {} + + get rustAdapter(): AgitRustPrimitiveAdapter | undefined { + return this.options.rustAdapter + } + + async canonicalizeJson(value: unknown): Promise { + return this.options.rustAdapter?.canonicalizeJson + ? this.options.rustAdapter.canonicalizeJson(value) + : canonicalJson(value) + } + + async hashBytes(bytes: Uint8Array): Promise { + return this.options.rustAdapter?.hashBytes + ? this.options.rustAdapter.hashBytes(bytes, 'sha256') + : `sha256:${bytesToHex(sha256(bytes))}` + } + + async hashObject(value: unknown): Promise { + const canonical = await this.canonicalizeJson(value) + return this.hashBytes(new TextEncoder().encode(canonical)) + } + + async appendEvidenceHash(input: AgitEvidenceHashChainInput): Promise { + if (this.options.rustAdapter?.appendEvidenceHash) { + return this.options.rustAdapter.appendEvidenceHash(input) + } + + return { + previousEventHash: input.previousEventHash, + eventHash: hashProtocolPayload({ + prev_event_hash: input.previousEventHash ?? '0', + event_payload: input.eventPayload, + }), + } + } + + async createMerkleProof(input: AgitMerkleProofInput): Promise { + if (this.options.rustAdapter?.createMerkleProof) { + return this.options.rustAdapter.createMerkleProof(input) + } + return createLocalMerkleProof(input) + } +} + +function createLocalMerkleProof(input: AgitMerkleProofInput): AgitMerkleProofResult { + const leafIndex = input.leaves.indexOf(input.leaf) + if (leafIndex === -1) { + throw new Error('Merkle proof leaf is not present in leaves') + } + + let index = leafIndex + let level = input.leaves + const proof: string[] = [] + + while (level.length > 1) { + const siblingIndex = index % 2 === 0 ? index + 1 : index - 1 + proof.push(level[siblingIndex] ?? level[index]) + index = Math.floor(index / 2) + level = nextMerkleLevel(level) + } + + return { + root: level[0] ?? input.leaf, + leaf: input.leaf, + proof, + } +} + +function nextMerkleLevel(level: string[]): string[] { + const next: string[] = [] + for (let i = 0; i < level.length; i += 2) { + const left = level[i] + const right = level[i + 1] ?? left + next.push(hashPair(left, right)) + } + return next +} + +function hashPair(left: string, right: string): string { + return `sha256:${bytesToHex(sha256(new TextEncoder().encode(`${left}:${right}`)))}` +} + /** * AgitCommitSigner — Signs and verifies agit commits with FIDES identity. * diff --git a/packages/sdk/test/agit.test.ts b/packages/sdk/test/agit.test.ts new file mode 100644 index 0000000..fe6b9bf --- /dev/null +++ b/packages/sdk/test/agit.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { AgitPrimitiveBridge } from '../src/integrations/agit.js' +import type { AgitRustPrimitiveAdapter } from '../src/integrations/agit.js' + +describe('AGIT primitive bridge', () => { + it('uses TypeScript canonical JSON and hashing when no Rust adapter is installed', async () => { + const bridge = new AgitPrimitiveBridge() + + await expect(bridge.canonicalizeJson({ b: 2, a: 1 })).resolves.toBe('{"a":1,"b":2}') + await expect(bridge.hashObject({ b: 2, a: 1 })).resolves.toMatch(/^sha256:/) + + const first = await bridge.appendEvidenceHash({ + eventPayload: { event_id: 'evt_1', type: 'policy.evaluated' }, + }) + const second = await bridge.appendEvidenceHash({ + previousEventHash: first.eventHash, + eventPayload: { event_id: 'evt_2', type: 'capability.invoked' }, + }) + + expect(first.eventHash).toMatch(/^sha256:/) + expect(second.previousEventHash).toBe(first.eventHash) + expect(second.eventHash).not.toBe(first.eventHash) + }) + + it('delegates primitive work to a supplied Rust adapter', async () => { + const adapter: AgitRustPrimitiveAdapter = { + canonicalizeJson: () => 'rust-canonical-json', + hashBytes: () => 'sha256:rust-hash', + appendEvidenceHash: input => ({ + previousEventHash: input.previousEventHash, + eventHash: 'sha256:rust-event-hash', + }), + createMerkleProof: input => ({ + root: 'sha256:rust-root', + leaf: input.leaf, + proof: ['sha256:rust-proof'], + }), + } + const bridge = new AgitPrimitiveBridge({ rustAdapter: adapter }) + + await expect(bridge.canonicalizeJson({ a: 1 })).resolves.toBe('rust-canonical-json') + await expect(bridge.hashBytes(new Uint8Array([1, 2, 3]))).resolves.toBe('sha256:rust-hash') + await expect(bridge.appendEvidenceHash({ + previousEventHash: 'sha256:previous', + eventPayload: { event_id: 'evt' }, + })).resolves.toEqual({ + previousEventHash: 'sha256:previous', + eventHash: 'sha256:rust-event-hash', + }) + await expect(bridge.createMerkleProof({ + leaves: ['sha256:a', 'sha256:b'], + leaf: 'sha256:b', + })).resolves.toEqual({ + root: 'sha256:rust-root', + leaf: 'sha256:b', + proof: ['sha256:rust-proof'], + }) + }) + + it('creates local Merkle proofs without Rust', async () => { + const bridge = new AgitPrimitiveBridge() + const leaves = [ + 'sha256:leaf-a', + 'sha256:leaf-b', + 'sha256:leaf-c', + ] + + const proof = await bridge.createMerkleProof({ leaves, leaf: 'sha256:leaf-b' }) + + expect(proof).toMatchObject({ + leaf: 'sha256:leaf-b', + proof: expect.arrayContaining(['sha256:leaf-a']), + }) + expect(proof.root).toMatch(/^sha256:/) + + await expect(bridge.createMerkleProof({ leaves, leaf: 'sha256:missing' })) + .rejects.toThrow('Merkle proof leaf is not present in leaves') + }) +}) From e71bf334102d9518e27df13875b0defa87022507 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:27:29 +0300 Subject: [PATCH 097/282] feat(cli): add privacy options to evidence export --- docs/cli-reference.md | 6 +++++ packages/cli/src/commands/evidence.ts | 27 +++++++++++++++++++++- packages/cli/test/commands.test.ts | 33 +++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 37270db..2dfae60 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -59,6 +59,7 @@ agentd invoke did:fides:... --capability invoice.reconcile --input invoice.json agentd invoke --session-id sess_... --input invoice.json agentd invoke --dry-run did:fides:... --capability payments.prepare --input payment.json agentd evidence verify +agentd evidence export --privacy-mode hash_only --no-metadata agentd daemon status ``` @@ -90,6 +91,11 @@ Input defaults to `{}` and can be supplied with `--input` or `--input-json`. Use `--dry-run` to request dry-run execution; discovery is never treated as authority by this command. +`evidence export` defaults to the daemon's privacy-aware export behavior. Use +`--privacy-mode public`, `private`, `redacted`, or `hash_only` to request a +specific export view, and `--no-metadata` when exported evidence should omit +metadata fields. `hash-only` is accepted as a CLI alias for `hash_only`. + `daemon status` calls `GET /health` and prints upstream checks, the authority store, and the root v2 local state store. When SQLite local state is enabled, the status output includes the SQLite path used for the daemon snapshot. diff --git a/packages/cli/src/commands/evidence.ts b/packages/cli/src/commands/evidence.ts index 67a196c..047a625 100644 --- a/packages/cli/src/commands/evidence.ts +++ b/packages/cli/src/commands/evidence.ts @@ -50,11 +50,15 @@ export function createEvidenceCommand(): Command { cmd.command('export') .description('Export evidence events') + .option('--privacy-mode ', 'Evidence privacy mode: public, private, redacted, hash_only') + .option('--include-metadata', 'Include evidence metadata in export', true) + .option('--no-metadata', 'Exclude evidence metadata from export') .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') .option('--json', 'Print JSON only') .action(async (options) => { try { - const result = await postJson(`${baseUrl(options.agentdUrl)}/evidence/export`, {}) + const body = evidenceExportBody(options) + const result = await postJson(`${baseUrl(options.agentdUrl)}/evidence/export`, body) printResult('Evidence export:', result, options) } catch (error) { console.error('Error:', error instanceof Error ? error.message : String(error)) @@ -68,3 +72,24 @@ export function createEvidenceCommand(): Command { function baseUrl(url: string): string { return url.replace(/\/+$/, '') } + +function evidenceExportBody(options: { privacyMode?: string; includeMetadata?: boolean; metadata?: boolean }): Record { + const body: Record = {} + if (options.privacyMode !== undefined) { + const mode = normalizePrivacyMode(options.privacyMode) + if (!['public', 'private', 'redacted', 'hash_only'].includes(mode)) { + throw new Error('--privacy-mode must be one of public, private, redacted, hash_only') + } + body.privacy_mode = mode + } + if (typeof options.metadata === 'boolean') { + body.include_metadata = options.metadata + } else if (typeof options.includeMetadata === 'boolean') { + body.include_metadata = options.includeMetadata + } + return body +} + +function normalizePrivacyMode(mode: string): string { + return mode === 'hash-only' ? 'hash_only' : mode +} diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 7fd7733..43842cc 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -1362,5 +1362,38 @@ describe('CLI Commands', () => { }) ); }); + + it('evidence export should pass privacy and metadata options to agentd', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + format: 'json', + valid: true, + events: [], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createEvidenceCommand } = await import('../src/commands/evidence.js'); + const cmd = createEvidenceCommand(); + + await cmd.parseAsync([ + 'export', + '--privacy-mode', + 'hash-only', + '--no-metadata', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://agentd.test/evidence/export', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + privacy_mode: 'hash_only', + include_metadata: false, + }), + }) + ); + }); }); }); From a4680e5c27b52271eb0b14e5802fc625830c89ce Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:29:34 +0300 Subject: [PATCH 098/282] docs(api): align evidence privacy enum --- docs/api/agentd.yaml | 2 +- tests/e2e/agentd-openapi-contract.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index b1a031a..f01cc05 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -897,7 +897,7 @@ components: properties: level: type: string - enum: [public, private, redacted, hash-only] + enum: [public, private, redacted, hash_only] redactionKey: type: string diff --git a/tests/e2e/agentd-openapi-contract.test.ts b/tests/e2e/agentd-openapi-contract.test.ts index f67f914..8c2708d 100644 --- a/tests/e2e/agentd-openapi-contract.test.ts +++ b/tests/e2e/agentd-openapi-contract.test.ts @@ -111,6 +111,11 @@ describe('Agentd OpenAPI contract', () => { expect(openApi).toContain('ApiKeyAuth:') }) + it('documents runtime evidence privacy modes', () => { + expect(openApi).toContain('enum: [public, private, redacted, hash_only]') + expect(openApi).not.toContain('enum: [public, private, redacted, hash-only]') + }) + it('documents API key auth on mutating v1 operations', () => { const mutatingV1Operations = [ 'post /v1/policy/evaluate', From 3ba09eb77d0531b646ebdf0b1e08f65da4d4dcd5 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:33:45 +0300 Subject: [PATCH 099/282] fix(examples): use generated fides identities --- docs/protocol/fides-v2-spec.md | 4 ++-- examples/calendar-agent.ts | 14 ++++++------- examples/demo.ts | 14 ++++++++----- examples/invoice-agent.ts | 18 ++++++++--------- examples/payment-agent.ts | 18 ++++++++--------- examples/requester-agent.ts | 27 ++++++++++--------------- packages/evidence/src/index.ts | 3 ++- packages/evidence/test/evidence.test.ts | 7 ++++--- packages/guard/demo.ts | 15 +++++++++----- 9 files changed, 60 insertions(+), 60 deletions(-) diff --git a/docs/protocol/fides-v2-spec.md b/docs/protocol/fides-v2-spec.md index 977c7b0..45836bf 100644 --- a/docs/protocol/fides-v2-spec.md +++ b/docs/protocol/fides-v2-spec.md @@ -355,7 +355,7 @@ interface EvidenceEvent { } interface EvidencePrivacy { - level: 'public' | 'private' | 'redacted' | 'hash-only' + level: 'public' | 'private' | 'redacted' | 'hash_only' redactionKey?: string } ``` @@ -378,7 +378,7 @@ The Merkle root is computed over all event hashes in the chain using SHA-256: | `public` | Full payload visible | | `private` | Payload nullified on export | | `redacted` | Payload replaced with `[REDACTED]` | -| `hash-only` | Only hash verifiable, payload nullified | +| `hash_only` | Only hash verifiable, payload nullified | --- diff --git a/examples/calendar-agent.ts b/examples/calendar-agent.ts index a96e409..6bcce8b 100644 --- a/examples/calendar-agent.ts +++ b/examples/calendar-agent.ts @@ -10,7 +10,7 @@ * Run: npx tsx examples/calendar-agent.ts */ -import { createIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' import { evaluatePolicy, type PolicyBundle } from '@fides/policy' @@ -29,13 +29,11 @@ async function main() { console.log('📝 Step 1: Creating Agent Identity') console.log('─'.repeat(40)) - const calendarAgent = createIdentity('did:fides:calendar-agent', 'agent', { - name: 'Calendar Assistant', - version: '1.0.0', - }) - const user = createIdentity('did:fides:user-alice', 'principal', { - name: 'Alice', + const { identity: calendarAgent } = await createAgentIdentity() + calendarAgent.metadata = { name: 'Calendar Assistant', version: '1.0.0' } + const { identity: user } = await createPrincipalIdentity({ type: 'individual', + displayName: 'Alice', }) console.log(` Agent: ${calendarAgent.did}`) @@ -233,7 +231,7 @@ async function main() { action: 'calendar:list', target: 'week-view', payload: { start: '2026-05-05', end: '2026-05-12' }, - privacy: { level: 'hash-only' as const }, + privacy: { level: 'hash_only' as const }, }, { id: 'evt-003', diff --git a/examples/demo.ts b/examples/demo.ts index 80195fa..fac29f1 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -9,7 +9,8 @@ */ import { - createIdentity, + createAgentIdentity, + createPrincipalIdentity, classifyCapabilityRisk, validateAgentCard, createDelegationToken, @@ -28,9 +29,12 @@ async function demo() { console.log('='.repeat(60)) console.log('\nStep 1: Creating identities') - const alice = createIdentity('did:fides:alice', 'agent', { name: 'Alice Assistant' }) - const bob = createIdentity('did:fides:bob', 'agent', { name: 'Bob Scheduler' }) - const charlie = createIdentity('did:fides:charlie', 'principal', { name: 'Charlie User' }) + const { identity: alice } = await createAgentIdentity() + const { identity: bob } = await createAgentIdentity() + const { identity: charlie } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Charlie User', + }) console.log(` Alice: ${alice.did}`) console.log(` Bob: ${bob.did}`) console.log(` Charlie: ${charlie.did}`) @@ -124,7 +128,7 @@ async function demo() { let chain = createEvidenceChain() for (const event of [ { id: 'e1', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'email:send', payload: {}, privacy: { level: 'redacted' as const } }, - { id: 'e2', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'calendar:create', payload: {}, privacy: { level: 'hash-only' as const } }, + { id: 'e2', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'calendar:create', payload: {}, privacy: { level: 'hash_only' as const } }, { id: 'e3', type: 'policy', timestamp: new Date().toISOString(), actor: alice.did, action: 'evaluate', payload: {}, privacy: { level: 'public' as const } }, ]) { chain = appendEvidenceEvent(chain, event, 'demo-signature') diff --git a/examples/invoice-agent.ts b/examples/invoice-agent.ts index c3ff0b4..9e6f542 100644 --- a/examples/invoice-agent.ts +++ b/examples/invoice-agent.ts @@ -11,7 +11,7 @@ * Run: npx tsx examples/invoice-agent.ts */ -import { createIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' import { evaluatePolicy, type PolicyBundle } from '@fides/policy' @@ -30,17 +30,15 @@ async function main() { console.log('📝 Step 1: Creating Identities') console.log('─'.repeat(40)) - const invoiceAgent = createIdentity('did:fides:invoice-agent', 'agent', { - name: 'Invoice Processor', - version: '1.0.0', - }) - const financeManager = createIdentity('did:fides:finance-mgr', 'principal', { - name: 'Finance Manager', + const { identity: invoiceAgent } = await createAgentIdentity() + invoiceAgent.metadata = { name: 'Invoice Processor', version: '1.0.0' } + const { identity: financeManager } = await createPrincipalIdentity({ type: 'individual', + displayName: 'Finance Manager', }) - const cfo = createIdentity('did:fides:cfo', 'principal', { - name: 'CFO', + const { identity: cfo } = await createPrincipalIdentity({ type: 'individual', + displayName: 'CFO', }) console.log(` Invoice Agent: ${invoiceAgent.did}`) @@ -269,7 +267,7 @@ async function main() { action: 'invoice:create', target: invoiceAgent.did, payload: { maxSpend: '50000.00', maxActions: 100 }, - privacy: { level: 'hash-only' as const }, + privacy: { level: 'hash_only' as const }, }, { id: 'audit-002', diff --git a/examples/payment-agent.ts b/examples/payment-agent.ts index a099515..70085fd 100644 --- a/examples/payment-agent.ts +++ b/examples/payment-agent.ts @@ -11,7 +11,7 @@ * Run: npx tsx examples/payment-agent.ts */ -import { createIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' import { evaluatePolicy, type PolicyBundle } from '@fides/policy' @@ -30,17 +30,15 @@ async function main() { console.log('📝 Step 1: Creating Identities') console.log('─'.repeat(40)) - const paymentAgent = createIdentity('did:fides:payment-agent', 'agent', { - name: 'Payment Processor', - version: '1.0.0', - }) - const merchant = createIdentity('did:fides:merchant-acme', 'principal', { - name: 'ACME Corp', + const { identity: paymentAgent } = await createAgentIdentity() + paymentAgent.metadata = { name: 'Payment Processor', version: '1.0.0' } + const { identity: merchant } = await createPrincipalIdentity({ type: 'organization', + displayName: 'ACME Corp', }) - const customer = createIdentity('did:fides:customer-bob', 'principal', { - name: 'Bob Customer', + const { identity: customer } = await createPrincipalIdentity({ type: 'individual', + displayName: 'Bob Customer', }) console.log(` Payment Agent: ${paymentAgent.did}`) @@ -258,7 +256,7 @@ async function main() { action: 'payment:charge', target: paymentAgent.did, payload: { maxSpend: '100000.00', maxActions: 1000 }, - privacy: { level: 'hash-only' as const }, + privacy: { level: 'hash_only' as const }, }, { id: 'pay-002', diff --git a/examples/requester-agent.ts b/examples/requester-agent.ts index 850aa14..a7b411b 100644 --- a/examples/requester-agent.ts +++ b/examples/requester-agent.ts @@ -11,7 +11,7 @@ * Run: npx tsx examples/requester-agent.ts */ -import { createIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' import { evaluatePolicy, type PolicyBundle } from '@fides/policy' @@ -30,13 +30,11 @@ async function main() { console.log('📝 Step 1: Creating Identities') console.log('─'.repeat(40)) - const requesterAgent = createIdentity('did:fides:requester', 'agent', { - name: 'Task Orchestrator', - version: '1.0.0', - }) - const user = createIdentity('did:fides:user-alice', 'principal', { - name: 'Alice', + const { identity: requesterAgent } = await createAgentIdentity() + requesterAgent.metadata = { name: 'Task Orchestrator', version: '1.0.0' } + const { identity: user } = await createPrincipalIdentity({ type: 'individual', + displayName: 'Alice', }) console.log(` Requester: ${requesterAgent.did}`) @@ -48,9 +46,8 @@ async function main() { console.log('─'.repeat(40)) // Calendar service provider - const calendarAgent = createIdentity('did:fides:calendar-svc', 'agent', { - name: 'Calendar Service', - }) + const { identity: calendarAgent } = await createAgentIdentity() + calendarAgent.metadata = { name: 'Calendar Service' } const calendarCapabilities: CapabilityDescriptor[] = [ { id: 'calendar:create', @@ -86,9 +83,8 @@ async function main() { } // Payment service provider - const paymentAgent = createIdentity('did:fides:payment-svc', 'agent', { - name: 'Payment Service', - }) + const { identity: paymentAgent } = await createAgentIdentity() + paymentAgent.metadata = { name: 'Payment Service' } const paymentCapabilities: CapabilityDescriptor[] = [ { id: 'payment:charge', @@ -124,9 +120,8 @@ async function main() { } // Invoice service provider - const invoiceAgent = createIdentity('did:fides:invoice-svc', 'agent', { - name: 'Invoice Service', - }) + const { identity: invoiceAgent } = await createAgentIdentity() + invoiceAgent.metadata = { name: 'Invoice Service' } const invoiceCapabilities: CapabilityDescriptor[] = [ { id: 'invoice:create', diff --git a/packages/evidence/src/index.ts b/packages/evidence/src/index.ts index 8ac3234..1da1344 100644 --- a/packages/evidence/src/index.ts +++ b/packages/evidence/src/index.ts @@ -9,7 +9,7 @@ import { bytesToHex } from '@noble/hashes/utils' import { canonicalJson, signObject, verifyObject } from '@fides/core' export interface EvidencePrivacy { - level: 'public' | 'private' | 'redacted' | 'hash-only' + level: 'public' | 'private' | 'redacted' | 'hash_only' | 'hash-only' redactionKey?: string } @@ -408,6 +408,7 @@ export function redactEvent(event: EvidenceEvent, level?: EvidencePrivacy['level return { ...event, payload: null } case 'redacted': return { ...event, payload: '[REDACTED]' } + case 'hash_only': case 'hash-only': return { ...event, payload: null, hash: event.hash } default: diff --git a/packages/evidence/test/evidence.test.ts b/packages/evidence/test/evidence.test.ts index a023a4a..e92214e 100644 --- a/packages/evidence/test/evidence.test.ts +++ b/packages/evidence/test/evidence.test.ts @@ -62,7 +62,7 @@ describe('Evidence Ledger', () => { actor: 'did:fides:alice', action: 'read', payload: { file: 'doc1' }, - privacy: { level: 'hash-only' }, + privacy: { level: 'hash_only' }, }, 'sig1') chain = appendEvidenceEvent(chain, { id: 'evt_2', @@ -71,7 +71,7 @@ describe('Evidence Ledger', () => { actor: 'did:fides:policy', action: 'evaluate', payload: { decision: 'allow' }, - privacy: { level: 'hash-only' }, + privacy: { level: 'hash_only' }, }, 'sig2') chain = appendEvidenceEvent(chain, { id: 'evt_3', @@ -80,7 +80,7 @@ describe('Evidence Ledger', () => { actor: 'did:fides:bob', action: 'write', payload: { file: 'doc2' }, - privacy: { level: 'hash-only' }, + privacy: { level: 'hash_only' }, }, 'sig3') const proof = buildEvidenceMerkleProof(chain, 'evt_2') @@ -134,6 +134,7 @@ describe('Evidence Ledger', () => { expect(redactEvent(event, 'public').payload).toEqual({ secret: 'data' }) expect(redactEvent(event, 'private').payload).toBeNull() expect(redactEvent(event, 'redacted').payload).toBe('[REDACTED]') + expect(redactEvent(event, 'hash_only').payload).toBeNull() expect(redactEvent(event, 'hash-only').payload).toBeNull() }) diff --git a/packages/guard/demo.ts b/packages/guard/demo.ts index fbca377..b92b93f 100644 --- a/packages/guard/demo.ts +++ b/packages/guard/demo.ts @@ -5,7 +5,7 @@ * Run: pnpm demo */ -import { createIdentity, classifyCapabilityRisk, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' +import { createAgentIdentity, createPrincipalIdentity, classifyCapabilityRisk, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { evaluatePolicy } from '@fides/policy' import { createEvidenceChain, appendEvidenceEvent, buildMerkleRoot, verifyEvidenceChain } from '@fides/evidence' @@ -20,9 +20,14 @@ async function demo() { // Step 1: Identities console.log('📝 Step 1: Creating Identities') - const alice = createIdentity('did:fides:alice', 'agent', { name: 'Alice Assistant' }) - const bob = createIdentity('did:fides:bob', 'agent', { name: 'Bob Scheduler' }) - const charlie = createIdentity('did:fides:charlie', 'principal', { name: 'Charlie User' }) + const { identity: alice } = await createAgentIdentity() + alice.metadata = { name: 'Alice Assistant' } + const { identity: bob } = await createAgentIdentity() + bob.metadata = { name: 'Bob Scheduler' } + const { identity: charlie } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Charlie User', + }) console.log(` Alice: ${alice.did}`) console.log(` Bob: ${bob.did}`) console.log(` Charlie: ${charlie.did}`) @@ -90,7 +95,7 @@ async function demo() { let chain = createEvidenceChain() for (const evt of [ { id: 'e1', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'email:send', payload: {}, privacy: { level: 'redacted' as const } }, - { id: 'e2', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'calendar:create', payload: {}, privacy: { level: 'hash-only' as const } }, + { id: 'e2', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'calendar:create', payload: {}, privacy: { level: 'hash_only' as const } }, { id: 'e3', type: 'policy', timestamp: new Date().toISOString(), actor: alice.did, action: 'evaluate', payload: {}, privacy: { level: 'public' as const } }, ]) { chain = appendEvidenceEvent(chain, evt, 'mock-sig') From 2f81f7d8bfebfd50f23b8ef44ca867f2af25f437 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:34:34 +0300 Subject: [PATCH 100/282] docs(discovery): document url-less resolution --- docs/protocol/discovery.md | 26 ++++++++++++++++++++++++++ packages/discovery/README.md | 15 +++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/docs/protocol/discovery.md b/docs/protocol/discovery.md index 3e2de35..13fa702 100644 --- a/docs/protocol/discovery.md +++ b/docs/protocol/discovery.md @@ -28,6 +28,32 @@ Current implementation anchors: 9. Return ranked candidates with explanations. 10. Emit evidence. +## URL-less Discovery + +FIDES discovery does not require every candidate to already expose an HTTP URL. +An endpoint URL is transport metadata, not identity, trust, or authority. + +Current support: + +- Local discovery can resolve from daemon-held AgentCards without endpoint URLs. + `agentd` marks these candidates with `resolution.urlRequired: false` and the + reason `url_not_required_for_local_discovery`. +- DHT discovery can resolve a signed capability pointer to a daemon-held + AgentCard. The DHT pointer is only a hint; it is not a trust source and it + does not grant authority. +- Relay discovery can advertise presence and endpoint hints for NAT-hidden + agents. A relay hint is not an authority decision. +- Registry discovery can return AgentCards that have no callable endpoint yet, + but invocation still requires a later transport/session path. + +URL-dependent cases: + +- Well-known discovery requires a domain or DID-to-domain mapping because the + discovery mechanism itself is HTTP `.well-known`. +- Capability invocation eventually needs a transport path, relay route, local + process binding, or adapter-specific execution channel. Discovery alone only + returns candidates. + The package-level `DiscoveryOrchestrator` supports capability-query providers, candidate explanations, provider scoping, ranking, and protocol version negotiation. Incompatible provider or legacy DID-resolution candidates are diff --git a/packages/discovery/README.md b/packages/discovery/README.md index 979d474..7256528 100644 --- a/packages/discovery/README.md +++ b/packages/discovery/README.md @@ -4,6 +4,12 @@ Composable agent discovery providers for FIDES. This package defines the discovery provider interface plus local, well-known, registry, relay, and DHT-ready provider implementations. Use it when an agent runtime needs to resolve AgentCards through multiple sources with deterministic priority and fallback behavior. +Discovery resolves candidates, not authority. Local and DHT-backed discovery can +work without an HTTP endpoint URL when the runtime already has the AgentCard or +can resolve it from a signed pointer. Endpoint URLs are transport metadata and +may be added later through relay, registry, well-known, or adapter-specific +channels. + ## Installation ```bash @@ -21,6 +27,15 @@ const discovery = new DiscoveryOrchestrator([provider]) const result = await discovery.resolve('did:fides:agent') ``` +For capability search without requiring a URL: + +```typescript +const candidates = await discovery.discover({ capability: 'invoice.reconcile' }) +``` + +The returned candidates must still pass signature verification, trust scoring, +policy evaluation, and scoped session grant issuance before invocation. + ## License MIT From 67127543c96cc6bd7b8d2e30f725a76185aa93ae Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:36:01 +0300 Subject: [PATCH 101/282] test(cli): cover demo and simulation commands --- packages/cli/test/commands.test.ts | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 43842cc..f1d886d 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -1334,6 +1334,58 @@ describe('CLI Commands', () => { ); }); + it('demo run should call the agentd demo endpoint', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + status: 'executed', + authority: { discoveryGrantsAuthority: false }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createDemoCommand } = await import('../src/commands/demo.js'); + const cmd = createDemoCommand(); + + await cmd.parseAsync([ + 'run', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://agentd.test/demo/run', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({}), + }) + ); + }); + + it('simulate adversarial should call the agentd simulation endpoint', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + status: 'detected', + authority: { discoveryGrantsAuthority: false }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createSimulateCommand } = await import('../src/commands/simulate.js'); + const cmd = createSimulateCommand(); + + await cmd.parseAsync([ + 'adversarial', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://agentd.test/simulate/adversarial', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({}), + }) + ); + }); + it('relay delete should remove messages by relay ID', async () => { process.env.SERVICE_API_KEY = 'relay-service-key'; const mockFetch = vi.fn(async () => new Response(JSON.stringify({ From 059cf9df19eccf14e1726f3c6162dfd94040b522 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:41:13 +0300 Subject: [PATCH 102/282] docs(api): document root agentd endpoints --- docs/api/agentd.yaml | 800 ++++++++++++++++++++++ tests/e2e/agentd-openapi-contract.test.ts | 75 ++ 2 files changed, 875 insertions(+) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index f01cc05..f07e946 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -50,6 +50,758 @@ paths: schema: $ref: "#/components/schemas/HealthResponse" + /identities: + post: + operationId: createLocalIdentity + tags: [Identity] + summary: Create a local FIDES identity + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + get: + operationId: listLocalIdentities + tags: [Identity] + summary: List local FIDES identities + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /identities/{id}: + get: + operationId: getLocalIdentity + tags: [Identity] + summary: Read a local FIDES identity + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + "404": + $ref: "#/components/responses/Error" + + /attestations: + post: + operationId: createRuntimeAttestation + tags: [Attestation] + summary: Issue a local runtime attestation + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + + /attestations/{id}: + get: + operationId: getRuntimeAttestation + tags: [Attestation] + summary: Read a local runtime attestation + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + "404": + $ref: "#/components/responses/Error" + + /attestations/{id}/verify: + post: + operationId: verifyRuntimeAttestation + tags: [Attestation] + summary: Verify a local runtime attestation + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /agent-cards: + post: + operationId: createLocalAgentCard + tags: [Card] + summary: Create a local AgentCard + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + + /agent-cards/{id}: + get: + operationId: getLocalAgentCard + tags: [Card] + summary: Read a local AgentCard + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + "404": + $ref: "#/components/responses/Error" + + /agent-cards/{id}/sign: + post: + operationId: signLocalAgentCard + tags: [Card] + summary: Sign a local AgentCard + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/IdParam" + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /agent-cards/{id}/verify: + post: + operationId: verifyLocalAgentCard + tags: [Card] + summary: Verify a local signed AgentCard + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /agents/register: + post: + operationId: registerLocalAgent + tags: [Discovery] + summary: Register a local AgentCard for discovery + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + + /agents: + get: + operationId: listLocalAgents + tags: [Discovery] + summary: List locally registered agents + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /agents/{id}: + get: + operationId: getLocalAgent + tags: [Discovery] + summary: Inspect a locally registered agent + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + "404": + $ref: "#/components/responses/Error" + + /discover: + post: + operationId: discoverAgents + tags: [Discovery] + summary: Discover agent candidates across enabled providers + requestBody: + $ref: "#/components/requestBodies/DiscoveryQuery" + responses: + "200": + $ref: "#/components/responses/Discovery" + + /discover/local: + post: + operationId: discoverLocalAgents + tags: [Discovery] + summary: Discover local agent candidates + requestBody: + $ref: "#/components/requestBodies/DiscoveryQuery" + responses: + "200": + $ref: "#/components/responses/Discovery" + + /discover/well-known: + post: + operationId: discoverWellKnownAgents + tags: [Discovery] + summary: Discover well-known agent candidates + requestBody: + $ref: "#/components/requestBodies/DiscoveryQuery" + responses: + "200": + $ref: "#/components/responses/Discovery" + + /discover/registry: + post: + operationId: discoverRegistryAgents + tags: [Discovery] + summary: Discover registry agent candidates + requestBody: + $ref: "#/components/requestBodies/DiscoveryQuery" + responses: + "200": + $ref: "#/components/responses/Discovery" + + /discover/relay: + post: + operationId: discoverRelayAgents + tags: [Discovery] + summary: Discover relay agent candidates + requestBody: + $ref: "#/components/requestBodies/DiscoveryQuery" + responses: + "200": + $ref: "#/components/responses/Discovery" + + /discover/dht: + post: + operationId: discoverDhtAgents + tags: [Discovery] + summary: Discover DHT pointer candidates + requestBody: + $ref: "#/components/requestBodies/DiscoveryQuery" + responses: + "200": + $ref: "#/components/responses/Discovery" + + /trust/evaluate: + post: + operationId: evaluateLocalTrust + tags: [Trust] + summary: Evaluate capability-scoped local trust + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /trust/{id}: + get: + operationId: getLocalTrust + tags: [Trust] + summary: Read local trust results for an agent + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /reputation/update: + post: + operationId: updateLocalReputation + tags: [Trust] + summary: Update capability-specific local reputation + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /reputation/{id}: + get: + operationId: getLocalReputation + tags: [Trust] + summary: Read local reputation for an agent + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /policy/evaluate: + post: + operationId: evaluateLocalPolicy + tags: [Policy] + summary: Evaluate policy before execution + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /approvals: + post: + operationId: createLocalApprovalRequest + tags: [Policy] + summary: Create an approval request + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + get: + operationId: listLocalApprovals + tags: [Policy] + summary: List local approval requests + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /approvals/{id}/approve: + post: + operationId: approveLocalApprovalRequest + tags: [Policy] + summary: Approve a pending approval request + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/IdParam" + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /approvals/{id}/deny: + post: + operationId: denyLocalApprovalRequest + tags: [Policy] + summary: Deny a pending approval request + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/IdParam" + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /delegations: + post: + operationId: createLocalDelegation + tags: [Authority] + summary: Create a local DelegationToken + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + + /sessions: + post: + operationId: createLocalSession + tags: [Authority] + summary: Create a scoped SessionGrant + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + + /sessions/{id}: + get: + operationId: getLocalSession + tags: [Authority] + summary: Read a local SessionGrant + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + "404": + $ref: "#/components/responses/Error" + + /sessions/{id}/verify: + post: + operationId: verifyLocalSession + tags: [Authority] + summary: Verify a local SessionGrant + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /invoke: + post: + operationId: invokeLocalCapability + tags: [Authority] + summary: Invoke a capability through a verified SessionGrant + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /evidence: + post: + operationId: appendLocalEvidence + tags: [Evidence] + summary: Append a local evidence event + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + get: + operationId: listLocalEvidence + tags: [Evidence] + summary: List local evidence events + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /evidence/{id}: + get: + operationId: getLocalEvidenceEvent + tags: [Evidence] + summary: Inspect a local evidence event + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + "404": + $ref: "#/components/responses/Error" + + /evidence/verify: + post: + operationId: verifyLocalEvidence + tags: [Evidence] + summary: Verify the local evidence hash chain + security: + - ApiKeyAuth: [] + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /evidence/export: + post: + operationId: exportLocalEvidence + tags: [Evidence] + summary: Export privacy-aware local evidence + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /revocations: + post: + operationId: recordLocalRevocation + tags: [Authority] + summary: Record a local revocation + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + get: + operationId: listLocalRevocations + tags: [Authority] + summary: List local revocations + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /revocations/{id}: + get: + operationId: getLocalRevocation + tags: [Authority] + summary: Inspect a local revocation + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + "404": + $ref: "#/components/responses/Error" + + /incidents: + post: + operationId: recordLocalIncident + tags: [Authority] + summary: Record a local incident + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + get: + operationId: listLocalIncidents + tags: [Authority] + summary: List local incidents + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /incidents/{id}: + get: + operationId: getLocalIncident + tags: [Authority] + summary: Inspect a local incident + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + "404": + $ref: "#/components/responses/Error" + + /incidents/{id}/resolve: + post: + operationId: resolveLocalIncident + tags: [Authority] + summary: Resolve a local incident + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/IdParam" + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /killswitch: + post: + operationId: createLocalKillSwitchRule + tags: [Kill Switch] + summary: Enable a local kill switch rule + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + get: + operationId: listLocalKillSwitchRules + tags: [Kill Switch] + summary: List local kill switch rules + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /killswitch/{id}: + delete: + operationId: disableLocalKillSwitchRule + tags: [Kill Switch] + summary: Disable a local kill switch rule + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /dht/start: + post: + operationId: startLocalDht + tags: [Discovery] + summary: Start the local DHT simulator + security: + - ApiKeyAuth: [] + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /dht/publish: + post: + operationId: publishLocalDhtPointer + tags: [Discovery] + summary: Publish a signed local DHT capability pointer + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + + /dht/find: + get: + operationId: findLocalDhtPointers + tags: [Discovery] + summary: Find local DHT pointers by capability + parameters: + - name: capability + in: query + required: false + schema: + type: string + responses: + "200": + $ref: "#/components/responses/JsonObject" + post: + operationId: postFindLocalDhtPointers + tags: [Discovery] + summary: Find local DHT pointers by request body + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /registry/start: + post: + operationId: startLocalRegistry + tags: [Discovery] + summary: Start the local registry mode + security: + - ApiKeyAuth: [] + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /registry/publish: + post: + operationId: publishLocalRegistryRecord + tags: [Discovery] + summary: Publish a local registry record + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + + /registry/search: + post: + operationId: searchLocalRegistry + tags: [Discovery] + summary: Search local registry records + requestBody: + $ref: "#/components/requestBodies/DiscoveryQuery" + responses: + "200": + $ref: "#/components/responses/Discovery" + + /registry/index: + get: + operationId: getLocalRegistryIndex + tags: [Discovery] + summary: Read the local registry index + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /relay/start: + post: + operationId: startLocalRelay + tags: [Discovery] + summary: Start the local relay simulator + security: + - ApiKeyAuth: [] + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /relay/register: + post: + operationId: registerLocalRelayPresence + tags: [Discovery] + summary: Register local relay presence + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + $ref: "#/components/responses/JsonObject" + + /relay/discover: + post: + operationId: discoverLocalRelayPresence + tags: [Discovery] + summary: Discover local relay presence + requestBody: + $ref: "#/components/requestBodies/DiscoveryQuery" + responses: + "200": + $ref: "#/components/responses/Discovery" + + /.well-known/fides.json: + get: + operationId: getWellKnownFides + tags: [Discovery] + summary: Read the local FIDES well-known document + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /.well-known/agents.json: + get: + operationId: getWellKnownAgents + tags: [Discovery] + summary: Read local well-known agent index + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /.well-known/agents/{id}.json: + get: + operationId: getWellKnownAgent + tags: [Discovery] + summary: Read a local well-known AgentCard document + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /demo/run: + post: + operationId: runLocalDemo + tags: [Authority] + summary: Run the full local Agent Trust Fabric demo + security: + - ApiKeyAuth: [] + responses: + "200": + $ref: "#/components/responses/JsonObject" + + /simulate/adversarial: + post: + operationId: runAdversarialSimulation + tags: [Authority] + summary: Run local adversarial simulation scenarios + security: + - ApiKeyAuth: [] + responses: + "200": + $ref: "#/components/responses/JsonObject" + /v1/identities/{did}: get: operationId: resolveIdentity @@ -633,6 +1385,13 @@ components: `agentd:killswitch:write`, or `*`. parameters: + IdParam: + name: id + in: path + required: true + schema: + type: string + DidParam: name: did in: path @@ -641,7 +1400,48 @@ components: type: string example: "did:fides:agentd-test-01" + requestBodies: + JsonObject: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + + DiscoveryQuery: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoveryQuery" + + responses: + JsonObject: + description: JSON response body + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + + Discovery: + description: Candidate-only discovery response + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoveryResponse" + + Error: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + schemas: + JsonObject: + type: object + additionalProperties: true + ErrorResponse: type: object required: [error] diff --git a/tests/e2e/agentd-openapi-contract.test.ts b/tests/e2e/agentd-openapi-contract.test.ts index 8c2708d..a4473e8 100644 --- a/tests/e2e/agentd-openapi-contract.test.ts +++ b/tests/e2e/agentd-openapi-contract.test.ts @@ -116,6 +116,81 @@ describe('Agentd OpenAPI contract', () => { expect(openApi).not.toContain('enum: [public, private, redacted, hash-only]') }) + it('documents root v2 local Agent Trust Fabric endpoints', () => { + const expectedOperations = [ + 'post /identities', + 'get /identities', + 'get /identities/{id}', + 'post /attestations', + 'get /attestations/{id}', + 'post /attestations/{id}/verify', + 'post /agent-cards', + 'get /agent-cards/{id}', + 'post /agent-cards/{id}/sign', + 'post /agent-cards/{id}/verify', + 'post /agents/register', + 'get /agents', + 'get /agents/{id}', + 'post /discover', + 'post /discover/local', + 'post /discover/well-known', + 'post /discover/registry', + 'post /discover/relay', + 'post /discover/dht', + 'post /discover/federation', + 'post /trust/evaluate', + 'get /trust/{id}', + 'post /reputation/update', + 'get /reputation/{id}', + 'post /policy/evaluate', + 'post /approvals', + 'get /approvals', + 'post /approvals/{id}/approve', + 'post /approvals/{id}/deny', + 'post /delegations', + 'post /sessions', + 'get /sessions/{id}', + 'post /sessions/{id}/verify', + 'post /invoke', + 'post /evidence', + 'get /evidence', + 'get /evidence/{id}', + 'post /evidence/verify', + 'post /evidence/export', + 'post /revocations', + 'get /revocations', + 'get /revocations/{id}', + 'post /incidents', + 'get /incidents', + 'get /incidents/{id}', + 'post /incidents/{id}/resolve', + 'post /killswitch', + 'get /killswitch', + 'delete /killswitch/{id}', + 'post /dht/start', + 'post /dht/publish', + 'get /dht/find', + 'post /dht/find', + 'post /registry/start', + 'post /registry/publish', + 'post /registry/search', + 'get /registry/index', + 'post /relay/start', + 'post /relay/register', + 'post /relay/discover', + 'get /.well-known/fides.json', + 'get /.well-known/agents.json', + 'get /.well-known/agents/{id}.json', + 'post /demo/run', + 'post /simulate/adversarial', + ] + + for (const operation of expectedOperations) { + const [method, path] = operation.split(' ') + expect(agentdPaths.get(path), operation).toContain(method) + } + }) + it('documents API key auth on mutating v1 operations', () => { const mutatingV1Operations = [ 'post /v1/policy/evaluate', From 202052524f56c78d78a7858acec9e0543b540f81 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:42:11 +0300 Subject: [PATCH 103/282] test(api): guard agentd openapi route drift --- tests/e2e/agentd-openapi-contract.test.ts | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/e2e/agentd-openapi-contract.test.ts b/tests/e2e/agentd-openapi-contract.test.ts index a4473e8..e4afd13 100644 --- a/tests/e2e/agentd-openapi-contract.test.ts +++ b/tests/e2e/agentd-openapi-contract.test.ts @@ -5,6 +5,8 @@ import { AgentdClient } from '@fides/sdk' const openApiPath = resolve(process.cwd(), '../../docs/api/agentd.yaml') const openApi = readFileSync(openApiPath, 'utf8') +const agentdSourcePath = resolve(process.cwd(), '../../services/agentd/src/index.ts') +const agentdSource = readFileSync(agentdSourcePath, 'utf8') const agentdPaths = extractOpenApiPaths(openApi) const securedOperations = extractApiKeySecuredOperations(openApi) @@ -191,6 +193,17 @@ describe('Agentd OpenAPI contract', () => { } }) + it('keeps root v2 runtime routes documented in OpenAPI', () => { + const runtimeOperations = extractAgentdRuntimeRoutes(agentdSource) + .filter(operation => operation.path.startsWith('/')) + .filter(operation => !operation.path.startsWith('/v1/')) + .filter(operation => operation.path !== '/metrics') + + for (const operation of runtimeOperations) { + expect(agentdPaths.get(operation.path), `${operation.method} ${operation.path}`).toContain(operation.method) + } + }) + it('documents API key auth on mutating v1 operations', () => { const mutatingV1Operations = [ 'post /v1/policy/evaluate', @@ -223,6 +236,24 @@ function normalizeAgentdPath(url: string): string { return decoded } +function extractAgentdRuntimeRoutes(source: string): Array<{ method: string; path: string }> { + const routes: Array<{ method: string; path: string }> = [] + const routeRegex = /app\.(get|post|delete)\('([^']+)'/g + let match: RegExpExecArray | null + while ((match = routeRegex.exec(source))) { + routes.push({ + method: match[1], + path: normalizeAgentdRuntimePath(match[2]), + }) + } + return routes +} + +function normalizeAgentdRuntimePath(path: string): string { + if (path === '/.well-known/agents/*') return '/.well-known/agents/{id}.json' + return path.replace(/\/:[^/]+/g, '/{id}') +} + function extractOpenApiPaths(source: string): Map { const paths = new Map() let inPaths = false From 4b6890bbf94cbfa5853de33806f6a7407c19b89f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:43:50 +0300 Subject: [PATCH 104/282] test(sdk): cover trust and reputation getters --- packages/sdk/test/fides-client.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index ac3e922..d357bb5 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -38,7 +38,9 @@ describe('FidesClient', () => { await client.discovery.dht({ capability: 'invoice.reconcile' }) await client.discovery.federation({ capability: 'invoice.reconcile' }) await client.trust.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + await client.trust.get('did:fides:agent') await client.reputation.update({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + await client.reputation.get('did:fides:agent') await client.policy.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.delegations.create({ delegator: 'did:fides:principal', @@ -107,7 +109,9 @@ describe('FidesClient', () => { 'http://localhost:4817/discover/dht', 'http://localhost:4817/discover/federation', 'http://localhost:4817/trust/evaluate', + 'http://localhost:4817/trust/did%3Afides%3Aagent', 'http://localhost:4817/reputation/update', + 'http://localhost:4817/reputation/did%3Afides%3Aagent', 'http://localhost:4817/policy/evaluate', 'http://localhost:4817/delegations', 'http://localhost:4817/approvals', @@ -165,7 +169,9 @@ describe('FidesClient', () => { 'POST', 'POST', 'POST', + 'GET', 'POST', + 'GET', 'POST', 'POST', 'POST', @@ -215,16 +221,16 @@ describe('FidesClient', () => { supported_versions: ['fides.v2.0'], required_versions: ['fides.v2.0'], }) - expect(JSON.parse(calls[37].init?.body as string)).toEqual({ + expect(JSON.parse(calls[39].init?.body as string)).toEqual({ capability: 'invoice.reconcile', supported_versions: ['fides.v2.0'], required_versions: ['fides.v2.0'], }) - expect(JSON.parse(calls[43].init?.body as string)).toEqual({ + expect(JSON.parse(calls[45].init?.body as string)).toEqual({ capability: 'invoice.reconcile', agentId: 'did:fides:agent', }) - expect(JSON.parse(calls[52].init?.body as string)).toEqual({ + expect(JSON.parse(calls[54].init?.body as string)).toEqual({ privacy_mode: 'hash_only', include_metadata: false, }) From 30c659c911fec5966411b549ccc3ee2401f7b1d9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:47:53 +0300 Subject: [PATCH 105/282] feat(cli): add root session incident killswitch commands --- docs/cli-reference.md | 23 +++ packages/cli/src/commands/authority-utils.ts | 16 ++ packages/cli/src/commands/incident.ts | 84 +++++++++- packages/cli/src/commands/killswitch.ts | 85 ++++++++++ packages/cli/src/commands/session.ts | 67 +++++++- packages/cli/test/commands.test.ts | 157 +++++++++++++++++++ 6 files changed, 425 insertions(+), 7 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2dfae60..838e694 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -58,6 +58,15 @@ agentd dht find --capability invoice.reconcile agentd invoke did:fides:... --capability invoice.reconcile --input invoice.json --requested-scopes invoice:read agentd invoke --session-id sess_... --input invoice.json agentd invoke --dry-run did:fides:... --capability payments.prepare --input payment.json +agentd session request did:fides:... --capability invoice.reconcile --requested-scopes invoice:read +agentd session verify sess_... +agentd incident report did:fides:... --severity high --category unauthorized_action --description "policy bypass" +agentd incident list +agentd incident inspect inc_... +agentd incident resolve inc_... +agentd killswitch enable --capability payments.prepare --reason "incident response" +agentd killswitch list +agentd killswitch disable ks_... agentd evidence verify agentd evidence export --privacy-mode hash_only --no-metadata agentd daemon status @@ -91,6 +100,20 @@ Input defaults to `{}` and can be supplied with `--input` or `--input-json`. Use `--dry-run` to request dry-run execution; discovery is never treated as authority by this command. +`session request`, `session show`, and `session verify` use the root v2 local +agentd session endpoints. The older `session create` and `session revoke` +commands remain available for the legacy signed `DelegationToken` `/v1` +authority path. + +`incident report/list/inspect/resolve` use the root v2 incident endpoints by +default. Passing `--private-key-hex` keeps the legacy signed `/v1/incidents` +path available for compatibility with existing authority records. + +`killswitch enable/list/disable` use root v2 kill switch rules. The older +`engage`, `disengage`, and `status` commands are local-file controls kept for +legacy demos; use the root v2 commands when testing policy-before-execution in +agentd. + `evidence export` defaults to the daemon's privacy-aware export behavior. Use `--privacy-mode public`, `private`, `redacted`, or `hash_only` to request a specific export view, and `--no-metadata` when exported evidence should omit diff --git a/packages/cli/src/commands/authority-utils.ts b/packages/cli/src/commands/authority-utils.ts index 5f445a7..c3284b9 100644 --- a/packages/cli/src/commands/authority-utils.ts +++ b/packages/cli/src/commands/authority-utils.ts @@ -80,3 +80,19 @@ export async function getJson(url: string): Promise { } return payload } + +export async function deleteJson(url: string): Promise { + const headers: Record = {} + const apiKey = process.env.FIDES_API_KEY || process.env.SERVICE_API_KEY + if (apiKey) { + headers['X-API-Key'] = apiKey + } + + const response = await fetch(url, { method: 'DELETE', headers }) + const text = await response.text() + const payload = text ? JSON.parse(text) : {} + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${JSON.stringify(payload)}`) + } + return payload +} diff --git a/packages/cli/src/commands/incident.ts b/packages/cli/src/commands/incident.ts index 782d31a..1074474 100644 --- a/packages/cli/src/commands/incident.ts +++ b/packages/cli/src/commands/incident.ts @@ -1,5 +1,5 @@ import { Command } from 'commander' -import { derivePublicKeyHex, parseList, postJson, printResult } from './authority-utils.js' +import { derivePublicKeyHex, getJson, parseList, postJson, printResult } from './authority-utils.js' import { createIncidentRecord, signIncidentRecord } from '@fides/core' export function createIncidentCommand(): Command { @@ -8,17 +8,38 @@ export function createIncidentCommand(): Command { cmd.command('report') .description('Report a policy, runtime, delegation, or trust incident') - .requiredOption('--actor ', 'Actor DID') - .requiredOption('--type ', 'Incident type') + .argument('[agent-id]', 'Target agent DID for root v2 incident reporting') + .option('--actor ', 'Legacy v1 actor DID') + .option('--type ', 'Legacy v1 incident type') .requiredOption('--severity ', 'Incident severity') .requiredOption('--description ', 'Incident description') - .requiredOption('--reporter ', 'Reporter DID') - .requiredOption('--private-key-hex ', 'Reporter Ed25519 private key') + .option('--category ', 'Root v2 incident category', 'suspicious_behavior') + .option('--reporter ', 'Reporter DID') + .option('--private-key-hex ', 'Reporter Ed25519 private key for legacy v1 signed incidents') .option('--evidence-refs ', 'Comma-separated evidence references') .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') .option('--json', 'Print JSON only') - .action(async (options) => { + .action(async (agentId, options) => { try { + if (!options.privateKeyHex) { + const targetAgentId = agentId ?? options.actor + if (!targetAgentId) { + throw new Error('agent-id or --actor is required for root incident reporting') + } + const result = await postJson(`${baseUrl(options.agentdUrl)}/incidents`, { + targetAgentId, + severity: options.severity, + category: options.category, + description: options.description, + ...(options.reporter && { reporter: options.reporter }), + evidenceRefs: parseList(options.evidenceRefs), + }) + printResult('Incident recorded:', result, options) + return + } + if (!options.actor || !options.type || !options.reporter) { + throw new Error('--actor, --type, --reporter, and --private-key-hex are required for legacy signed incidents') + } const privateKey = Buffer.from(options.privateKeyHex, 'hex') const record = await signIncidentRecord(createIncidentRecord({ actor: options.actor, @@ -39,5 +60,56 @@ export function createIncidentCommand(): Command { } }) + cmd.command('list') + .description('List root v2 incidents') + .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await getJson(`${baseUrl(options.agentdUrl)}/incidents`) + printResult('Incidents:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('inspect') + .description('Inspect a root v2 incident') + .argument('', 'Incident ID') + .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (incidentId, options) => { + try { + const result = await getJson(`${baseUrl(options.agentdUrl)}/incidents/${encodeURIComponent(incidentId)}`) + printResult('Incident:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('resolve') + .description('Resolve a root v2 incident') + .argument('', 'Incident ID') + .option('--status ', 'Resolution status: resolved, dismissed, false_positive', 'resolved') + .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (incidentId, options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/incidents/${encodeURIComponent(incidentId)}/resolve`, { + status: options.status, + }) + printResult('Incident resolved:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + return cmd } + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/commands/killswitch.ts b/packages/cli/src/commands/killswitch.ts index ad3d9e2..01f3560 100644 --- a/packages/cli/src/commands/killswitch.ts +++ b/packages/cli/src/commands/killswitch.ts @@ -2,6 +2,7 @@ import { Command } from 'commander' import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { homedir } from 'node:os' +import { deleteJson, getJson, postJson, printResult } from './authority-utils.js' const KILLSTATE_PATH = join(homedir(), '.fides', 'killswitch.json') @@ -30,6 +31,63 @@ export function createKillswitchCommand(): Command { const cmd = new Command('killswitch') .description('Kill switch control') + cmd.command('enable') + .description('Enable a root v2 kill switch rule through agentd') + .option('--agent ', 'Kill an agent') + .option('--publisher ', 'Kill a publisher') + .option('--capability ', 'Kill a capability') + .option('--session ', 'Kill a session') + .option('--principal ', 'Kill a principal') + .option('--risk-class ', 'Kill a risk class') + .option('--reason ', 'Kill switch reason', 'Enabled by CLI') + .option('--issuer ', 'Issuer DID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const target = killSwitchTarget(options) + const result = await postJson(`${baseUrl(options.agentdUrl)}/killswitch`, { + targetType: target.targetType, + target: target.target, + reason: options.reason, + ...(options.issuer && { issuer: options.issuer }), + }) + printResult('Kill switch enabled:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('disable') + .description('Disable a root v2 kill switch rule through agentd') + .argument('', 'Kill switch rule ID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (ruleId, options) => { + try { + const result = await deleteJson(`${baseUrl(options.agentdUrl)}/killswitch/${encodeURIComponent(ruleId)}`) + printResult('Kill switch disabled:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('list') + .description('List root v2 kill switch rules through agentd') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await getJson(`${baseUrl(options.agentdUrl)}/killswitch`) + printResult('Kill switch rules:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + cmd.command('engage') .description('Engage kill switch') .option('--global', 'Engage globally') @@ -105,3 +163,30 @@ export function createKillswitchCommand(): Command { return cmd } + +function killSwitchTarget(options: { + agent?: string + publisher?: string + capability?: string + session?: string + principal?: string + riskClass?: string +}): { targetType: string; target: string } { + const targets = [ + ['agent', options.agent], + ['publisher', options.publisher], + ['capability', options.capability], + ['session', options.session], + ['principal', options.principal], + ['risk_class', options.riskClass], + ].filter(([, value]) => typeof value === 'string') as Array<[string, string]> + if (targets.length !== 1) { + throw new Error('provide exactly one of --agent, --publisher, --capability, --session, --principal, or --risk-class') + } + const [targetType, target] = targets[0] + return { targetType, target } +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index b9fed90..18b2083 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -1,10 +1,71 @@ import { Command } from 'commander' -import { parseTokenInput, postJson, printResult } from './authority-utils.js' +import { getJson, parseList, parseJsonObject, parseTokenInput, postJson, printResult } from './authority-utils.js' export function createSessionCommand(): Command { const cmd = new Command('session') .description('Create and revoke delegated agentd sessions') + cmd.command('request') + .description('Request a root v2 SessionGrant for an agent capability') + .argument('', 'Target agent DID') + .requiredOption('--capability ', 'Capability ID') + .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') + .option('--principal-id ', 'Principal DID') + .option('--requester-agent-id ', 'Requester agent DID') + .option('--requested-scopes ', 'Comma-separated requested scopes') + .option('--constraints-json ', 'Session constraints JSON object') + .option('--attestation-id ', 'Runtime attestation ID') + .option('--approval-granted', 'Mark approval as granted for policy evaluation') + .option('--json', 'Print JSON only') + .action(async (agentId, options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/sessions`, { + agentId, + capability: options.capability, + requestedScopes: parseList(options.requestedScopes), + ...(options.principalId && { principalId: options.principalId }), + ...(options.requesterAgentId && { requesterAgentId: options.requesterAgentId }), + ...(options.constraintsJson && { constraints: parseJsonObject(options.constraintsJson) }), + ...(options.attestationId && { attestationId: options.attestationId }), + ...(options.approvalGranted && { approvalGranted: true }), + }) + printResult('Session requested:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exit(1) + } + }) + + cmd.command('verify') + .description('Verify a root v2 SessionGrant') + .argument('', 'Session ID') + .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (sessionId, options) => { + try { + const result = await postJson(`${baseUrl(options.agentdUrl)}/sessions/${encodeURIComponent(sessionId)}/verify`, {}) + printResult('Session verification:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exit(1) + } + }) + + cmd.command('show') + .description('Read a root v2 SessionGrant') + .argument('', 'Session ID') + .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (sessionId, options) => { + try { + const result = await getJson(`${baseUrl(options.agentdUrl)}/sessions/${encodeURIComponent(sessionId)}`) + printResult('Session:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exit(1) + } + }) + cmd.command('create') .description('Create an agentd SessionGrant from a DelegationToken') .requiredOption('--capability ', 'Capability ID') @@ -52,3 +113,7 @@ export function createSessionCommand(): Command { return cmd } + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index f1d886d..52f4c4b 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -56,6 +56,7 @@ vi.mock('node:dns/promises', () => ({ })); vi.mock('node:os', () => ({ + homedir: vi.fn(() => '/tmp/test-home'), default: { homedir: vi.fn(() => '/tmp/test-home'), }, @@ -1386,6 +1387,162 @@ describe('CLI Commands', () => { ); }); + it('session request and verify should use root agentd session endpoints', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + authorized: true, + session: { session_id: 'sess_cli' }, + valid: true, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createSessionCommand } = await import('../src/commands/session.js'); + const cmd = createSessionCommand(); + + await cmd.parseAsync([ + 'request', + 'did:fides:agent', + '--capability', + 'invoice.reconcile', + '--requested-scopes', + 'read:invoices,write:evidence', + '--principal-id', + 'did:fides:principal', + '--requester-agent-id', + 'did:fides:requester', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync([ + 'verify', + 'sess_cli', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/sessions', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + agentId: 'did:fides:agent', + capability: 'invoice.reconcile', + requestedScopes: ['read:invoices', 'write:evidence'], + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'http://agentd.test/sessions/sess_cli/verify', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({}), + }) + ); + }); + + it('incident list inspect and resolve should use root agentd incident endpoints', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + record: { id: 'inc_1' }, + records: [{ id: 'inc_1' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createIncidentCommand } = await import('../src/commands/incident.js'); + const cmd = createIncidentCommand(); + + await cmd.parseAsync([ + 'report', + 'did:fides:agent', + '--severity', + 'high', + '--category', + 'unauthorized_action', + '--description', + 'policy bypass', + '--reporter', + 'did:fides:principal', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync(['list', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['inspect', 'inc_1', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['resolve', 'inc_1', '--status', 'resolved', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/incidents', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + targetAgentId: 'did:fides:agent', + severity: 'high', + category: 'unauthorized_action', + description: 'policy bypass', + reporter: 'did:fides:principal', + evidenceRefs: [], + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith(2, 'http://agentd.test/incidents', expect.objectContaining({ method: 'GET' })); + expect(mockFetch).toHaveBeenNthCalledWith(3, 'http://agentd.test/incidents/inc_1', expect.objectContaining({ method: 'GET' })); + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + 'http://agentd.test/incidents/inc_1/resolve', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ status: 'resolved' }), + }) + ); + }); + + it('killswitch enable disable and list should use root agentd kill switch endpoints', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + rule: { id: 'ks_1' }, + rules: [{ id: 'ks_1' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createKillswitchCommand } = await import('../src/commands/killswitch.js'); + const cmd = createKillswitchCommand(); + + await cmd.parseAsync([ + 'enable', + '--capability', + 'payments.prepare', + '--reason', + 'incident response', + '--issuer', + 'did:fides:operator', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync(['list', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['disable', 'ks_1', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/killswitch', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + targetType: 'capability', + target: 'payments.prepare', + reason: 'incident response', + issuer: 'did:fides:operator', + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith(2, 'http://agentd.test/killswitch', expect.objectContaining({ method: 'GET' })); + expect(mockFetch).toHaveBeenNthCalledWith(3, 'http://agentd.test/killswitch/ks_1', expect.objectContaining({ method: 'DELETE' })); + }); + it('relay delete should remove messages by relay ID', async () => { process.env.SERVICE_API_KEY = 'relay-service-key'; const mockFetch = vi.fn(async () => new Response(JSON.stringify({ From a00de37761b9ef478e2a91f963cc17a8a39f47a1 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:49:22 +0300 Subject: [PATCH 106/282] feat(cli): add root revocation commands --- docs/cli-reference.md | 12 ++++ packages/cli/src/commands/revoke.ts | 105 ++++++++++++++++++++++++++-- packages/cli/test/commands.test.ts | 65 +++++++++++++++++ 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 838e694..f2d7b3e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -67,6 +67,13 @@ agentd incident resolve inc_... agentd killswitch enable --capability payments.prepare --reason "incident response" agentd killswitch list agentd killswitch disable ks_... +agentd revoke agent did:fides:... --reason "disabled" +agentd revoke key key_... --reason "rotated" +agentd revoke card card_... --reason "expired" +agentd revoke session sess_... --reason "replay risk" +agentd revoke attestation att_... --reason "expired attestation" +agentd revoke list +agentd revoke inspect rev_... agentd evidence verify agentd evidence export --privacy-mode hash_only --no-metadata agentd daemon status @@ -114,6 +121,11 @@ path available for compatibility with existing authority records. legacy demos; use the root v2 commands when testing policy-before-execution in agentd. +`revoke agent/key/identity/card/capability/session/attestation/publisher`, +`revoke list`, and `revoke inspect` use root v2 revocation records. Passing +`--private-key-hex` to `revoke agent` keeps the legacy signed `/v1/revocations` +path available for compatibility. + `evidence export` defaults to the daemon's privacy-aware export behavior. Use `--privacy-mode public`, `private`, `redacted`, or `hash_only` to request a specific export view, and `--no-metadata` when exported evidence should omit diff --git a/packages/cli/src/commands/revoke.ts b/packages/cli/src/commands/revoke.ts index 462942d..715e90c 100644 --- a/packages/cli/src/commands/revoke.ts +++ b/packages/cli/src/commands/revoke.ts @@ -1,5 +1,5 @@ import { Command } from 'commander' -import { derivePublicKeyHex, postJson, printResult } from './authority-utils.js' +import { derivePublicKeyHex, getJson, parseList, postJson, printResult } from './authority-utils.js' import { createRevocationRecord, signRevocationRecord } from '@fides/core' export function createRevokeCommand(): Command { @@ -9,13 +9,31 @@ export function createRevokeCommand(): Command { cmd.command('agent') .description('Revoke an agent DID across the authority fabric') .argument('', 'Agent DID') - .requiredOption('--revoked-by ', 'Revoking principal DID') - .requiredOption('--reason ', 'Revocation reason') - .requiredOption('--private-key-hex ', 'Revoking principal Ed25519 private key') + .option('--revoked-by ', 'Legacy v1 revoking principal DID') + .option('--reason ', 'Revocation reason', 'Revoked by CLI') + .option('--issuer ', 'Root v2 issuer DID') + .option('--private-key-hex ', 'Revoking principal Ed25519 private key for legacy v1 signed revocations') + .option('--evidence-refs ', 'Comma-separated evidence references') + .option('--expires-at ', 'Optional revocation expiry timestamp') .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') .option('--json', 'Print JSON only') .action(async (did, options) => { try { + if (!options.privateKeyHex) { + const result = await postRootRevocation(options.agentdUrl, { + targetType: 'agent', + targetId: did, + reason: options.reason, + issuer: options.issuer, + evidenceRefs: parseList(options.evidenceRefs), + expiresAt: options.expiresAt, + }) + printResult('Revocation recorded:', result, options) + return + } + if (!options.revokedBy) { + throw new Error('--revoked-by is required for legacy signed revocations') + } const privateKey = Buffer.from(options.privateKeyHex, 'hex') const record = await signRevocationRecord(createRevocationRecord({ did, @@ -33,5 +51,84 @@ export function createRevokeCommand(): Command { } }) + for (const targetType of ['key', 'identity', 'card', 'capability', 'session', 'attestation', 'publisher'] as const) { + cmd.command(targetType) + .description(`Record a root v2 ${targetType} revocation`) + .argument('', `${targetType} ID`) + .option('--reason ', 'Revocation reason', 'Revoked by CLI') + .option('--issuer ', 'Issuer DID') + .option('--evidence-refs ', 'Comma-separated evidence references') + .option('--expires-at ', 'Optional revocation expiry timestamp') + .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (id, options) => { + try { + const result = await postRootRevocation(options.agentdUrl, { + targetType: targetType === 'card' ? 'agent_card' : targetType, + targetId: id, + reason: options.reason, + issuer: options.issuer, + evidenceRefs: parseList(options.evidenceRefs), + expiresAt: options.expiresAt, + }) + printResult('Revocation recorded:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exit(1) + } + }) + } + + cmd.command('list') + .description('List root v2 revocations') + .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + try { + const result = await getJson(`${baseUrl(options.agentdUrl)}/revocations`) + printResult('Revocations:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + + cmd.command('inspect') + .description('Inspect a root v2 revocation by record ID or target ID') + .argument('', 'Revocation record ID or target ID') + .option('--agentd-url ', 'agentd base URL', 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (id, options) => { + try { + const result = await getJson(`${baseUrl(options.agentdUrl)}/revocations/${encodeURIComponent(id)}`) + printResult('Revocation:', result, options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + }) + return cmd } + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} + +function postRootRevocation(agentdUrl: string, body: { + targetType: string + targetId: string + reason?: string + issuer?: string + evidenceRefs?: string[] + expiresAt?: string +}): Promise { + return postJson(`${baseUrl(agentdUrl)}/revocations`, { + targetType: body.targetType, + targetId: body.targetId, + reason: body.reason, + ...(body.issuer && { issuer: body.issuer }), + ...(body.evidenceRefs && body.evidenceRefs.length > 0 ? { evidenceRefs: body.evidenceRefs } : {}), + ...(body.expiresAt && { expiresAt: body.expiresAt }), + }) +} diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 52f4c4b..c462005 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -953,6 +953,71 @@ describe('CLI Commands', () => { expect(body.revokerPublicKey).toMatch(/^[0-9a-f]{64}$/); }); + it('root revoke commands should record and inspect v2 revocations', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + record: { id: 'rev_1' }, + records: [{ id: 'rev_1' }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createRevokeCommand } = await import('../src/commands/revoke.js'); + const cmd = createRevokeCommand(); + + await cmd.parseAsync([ + 'agent', + 'did:fides:agent', + '--agentd-url', + 'http://agentd.test/', + '--issuer', + 'did:fides:operator', + '--reason', + 'disabled', + '--json', + ], { from: 'user' }); + await cmd.parseAsync([ + 'session', + 'sess_1', + '--agentd-url', + 'http://agentd.test/', + '--reason', + 'replay risk', + '--json', + ], { from: 'user' }); + await cmd.parseAsync(['list', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['inspect', 'rev_1', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/revocations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + targetType: 'agent', + targetId: 'did:fides:agent', + reason: 'disabled', + issuer: 'did:fides:operator', + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'http://agentd.test/revocations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + targetType: 'session', + targetId: 'sess_1', + reason: 'replay risk', + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith(3, 'http://agentd.test/revocations', expect.objectContaining({ method: 'GET' })); + expect(mockFetch).toHaveBeenNthCalledWith(4, 'http://agentd.test/revocations/rev_1', expect.objectContaining({ method: 'GET' })); + }); + it('incident report should call agentd incidents', async () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ recorded: true }), { status: 201, From 7e8444884c6060ed76fc60f22922a225ac733401 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:51:55 +0300 Subject: [PATCH 107/282] feat(cli): add root runtime attestation command --- docs/cli-reference.md | 7 ++++ packages/cli/src/commands/attest.ts | 34 ++++++++++++++++++++ packages/cli/src/index.ts | 2 ++ packages/cli/test/commands.test.ts | 50 ++++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/attest.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f2d7b3e..eb0b38b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -18,6 +18,7 @@ Current implementation anchors: - `session` - `invoke` - `authorize` +- `attest` - `runtime` - `revoke` - `incident` @@ -60,6 +61,7 @@ agentd invoke --session-id sess_... --input invoice.json agentd invoke --dry-run did:fides:... --capability payments.prepare --input payment.json agentd session request did:fides:... --capability invoice.reconcile --requested-scopes invoice:read agentd session verify sess_... +agentd attest runtime --agent did:fides:... --code-hash sha256:... --runtime-hash sha256:... --policy-hash sha256:... agentd incident report did:fides:... --severity high --category unauthorized_action --description "policy bypass" agentd incident list agentd incident inspect inc_... @@ -112,6 +114,11 @@ agentd session endpoints. The older `session create` and `session revoke` commands remain available for the legacy signed `DelegationToken` `/v1` authority path. +`attest runtime` uses the root v2 local agentd runtime attestation endpoint, +emits attestation evidence, and does not grant authority by itself. The older +`runtime attest` command remains a local MockTEE helper for standalone runtime +package checks. + `incident report/list/inspect/resolve` use the root v2 incident endpoints by default. Passing `--private-key-hex` keeps the legacy signed `/v1/incidents` path available for compatibility with existing authority records. diff --git a/packages/cli/src/commands/attest.ts b/packages/cli/src/commands/attest.ts new file mode 100644 index 0000000..56e1674 --- /dev/null +++ b/packages/cli/src/commands/attest.ts @@ -0,0 +1,34 @@ +import { Command } from 'commander' +import { postJson, printResult } from './authority-utils.js' + +export function createAttestCommand(): Command { + const cmd = new Command('attest') + .description('Issue root v2 attestations through local agentd') + + cmd.command('runtime') + .description('Issue a runtime attestation through agentd') + .requiredOption('--agent ', 'Agent DID') + .requiredOption('--code-hash ', 'Code hash') + .requiredOption('--runtime-hash ', 'Runtime hash') + .requiredOption('--policy-hash ', 'Policy hash') + .option('--enclave-measurement ', 'TEE enclave measurement hash') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + const body = { + agentId: options.agent, + codeHash: options.codeHash, + runtimeHash: options.runtimeHash, + policyHash: options.policyHash, + ...(options.enclaveMeasurement && { enclaveMeasurement: options.enclaveMeasurement }), + } + const result = await postJson(`${baseUrl(options.agentdUrl)}/attestations`, body) + printResult('Runtime attestation issued:', result, options) + }) + + return cmd +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a3247fd..3089f18 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -16,6 +16,7 @@ import { createDelegateCommand } from './commands/delegate.js'; import { createSessionCommand } from './commands/session.js'; import { createRevokeCommand } from './commands/revoke.js'; import { createIncidentCommand } from './commands/incident.js'; +import { createAttestCommand } from './commands/attest.js'; import { createPropagationCommand } from './commands/propagation.js'; import { createAuthorizeCommand } from './commands/authorize.js'; import { createRelayCommand } from './commands/relay.js'; @@ -52,6 +53,7 @@ program.addCommand(createDelegateCommand()); program.addCommand(createSessionCommand()); program.addCommand(createRevokeCommand()); program.addCommand(createIncidentCommand()); +program.addCommand(createAttestCommand()); program.addCommand(createPropagationCommand()); program.addCommand(createAuthorizeCommand()); program.addCommand(createRelayCommand()); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index c462005..c91d81a 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -163,10 +163,11 @@ describe('CLI Commands', () => { }); describe('v2 command surface', () => { - it('exposes registry, dht, evidence, demo, simulate, and invoke commands', async () => { + it('exposes registry, dht, evidence, attest, demo, simulate, and invoke commands', async () => { const { createRegistryCommand } = await import('../src/commands/registry.js'); const { createDhtCommand } = await import('../src/commands/dht.js'); const { createEvidenceCommand } = await import('../src/commands/evidence.js'); + const { createAttestCommand } = await import('../src/commands/attest.js'); const { createDemoCommand } = await import('../src/commands/demo.js'); const { createSimulateCommand } = await import('../src/commands/simulate.js'); const { createInvokeCommand } = await import('../src/commands/invoke.js'); @@ -174,6 +175,7 @@ describe('CLI Commands', () => { expect(createRegistryCommand().name()).toBe('registry'); expect(createDhtCommand().name()).toBe('dht'); expect(createEvidenceCommand().name()).toBe('evidence'); + expect(createAttestCommand().name()).toBe('attest'); expect(createDemoCommand().name()).toBe('demo'); expect(createSimulateCommand().name()).toBe('simulate'); expect(createInvokeCommand().name()).toBe('invoke'); @@ -1018,6 +1020,52 @@ describe('CLI Commands', () => { expect(mockFetch).toHaveBeenNthCalledWith(4, 'http://agentd.test/revocations/rev_1', expect.objectContaining({ method: 'GET' })); }); + it('attest runtime should issue a root v2 runtime attestation', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + attestation: { attestation_id: 'att_1' }, + evidenceRefs: ['evt_1'], + authorityGranted: false, + }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createAttestCommand } = await import('../src/commands/attest.js'); + const cmd = createAttestCommand(); + + await cmd.parseAsync([ + 'runtime', + '--agent', + 'did:fides:agent', + '--code-hash', + 'sha256:code', + '--runtime-hash', + 'sha256:runtime', + '--policy-hash', + 'sha256:policy', + '--enclave-measurement', + 'sha256:measurement', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://agentd.test/attestations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + agentId: 'did:fides:agent', + codeHash: 'sha256:code', + runtimeHash: 'sha256:runtime', + policyHash: 'sha256:policy', + enclaveMeasurement: 'sha256:measurement', + }), + }) + ); + }); + it('incident report should call agentd incidents', async () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ recorded: true }), { status: 201, From f5e389ce990d7d81cb8b02f1a0d19804b5d1162e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:52:45 +0300 Subject: [PATCH 108/282] feat(cli): add attestation inspect commands --- docs/cli-reference.md | 10 ++++++---- packages/cli/src/commands/attest.ts | 22 +++++++++++++++++++- packages/cli/test/commands.test.ts | 31 +++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index eb0b38b..a2e103a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -62,6 +62,8 @@ agentd invoke --dry-run did:fides:... --capability payments.prepare --input paym agentd session request did:fides:... --capability invoice.reconcile --requested-scopes invoice:read agentd session verify sess_... agentd attest runtime --agent did:fides:... --code-hash sha256:... --runtime-hash sha256:... --policy-hash sha256:... +agentd attest show att_... +agentd attest verify att_... agentd incident report did:fides:... --severity high --category unauthorized_action --description "policy bypass" agentd incident list agentd incident inspect inc_... @@ -114,10 +116,10 @@ agentd session endpoints. The older `session create` and `session revoke` commands remain available for the legacy signed `DelegationToken` `/v1` authority path. -`attest runtime` uses the root v2 local agentd runtime attestation endpoint, -emits attestation evidence, and does not grant authority by itself. The older -`runtime attest` command remains a local MockTEE helper for standalone runtime -package checks. +`attest runtime/show/verify` use the root v2 local agentd runtime attestation +endpoints. Issuing or verifying an attestation emits evidence and does not +grant authority by itself. The older `runtime attest` command remains a local +MockTEE helper for standalone runtime package checks. `incident report/list/inspect/resolve` use the root v2 incident endpoints by default. Passing `--private-key-hex` keeps the legacy signed `/v1/incidents` diff --git a/packages/cli/src/commands/attest.ts b/packages/cli/src/commands/attest.ts index 56e1674..077eeaf 100644 --- a/packages/cli/src/commands/attest.ts +++ b/packages/cli/src/commands/attest.ts @@ -1,5 +1,5 @@ import { Command } from 'commander' -import { postJson, printResult } from './authority-utils.js' +import { getJson, postJson, printResult } from './authority-utils.js' export function createAttestCommand(): Command { const cmd = new Command('attest') @@ -26,6 +26,26 @@ export function createAttestCommand(): Command { printResult('Runtime attestation issued:', result, options) }) + cmd.command('show') + .description('Inspect a runtime attestation from agentd') + .argument('', 'Attestation ID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (attestationId, options) => { + const result = await getJson(`${baseUrl(options.agentdUrl)}/attestations/${encodeURIComponent(attestationId)}`) + printResult('Runtime attestation:', result, options) + }) + + cmd.command('verify') + .description('Verify a runtime attestation through agentd') + .argument('', 'Attestation ID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (attestationId, options) => { + const result = await postJson(`${baseUrl(options.agentdUrl)}/attestations/${encodeURIComponent(attestationId)}/verify`, {}) + printResult('Runtime attestation verification:', result, options) + }) + return cmd } diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index c91d81a..6e1f7e3 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -1066,6 +1066,37 @@ describe('CLI Commands', () => { ); }); + it('attest show and verify should inspect root v2 runtime attestations', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + attestation: { attestation_id: 'att_1' }, + valid: true, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createAttestCommand } = await import('../src/commands/attest.js'); + const cmd = createAttestCommand(); + + await cmd.parseAsync(['show', 'att_1', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['verify', 'att_1', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/attestations/att_1', + expect.objectContaining({ method: 'GET' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'http://agentd.test/attestations/att_1/verify', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({}), + }) + ); + }); + it('incident report should call agentd incidents', async () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ recorded: true }), { status: 201, From 8e06be4479f135c7b72dd47493eb6e18ff783688 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:54:20 +0300 Subject: [PATCH 109/282] feat(cli): add agent registration commands --- docs/cli-reference.md | 9 ++++++ packages/cli/src/commands/agents.ts | 44 +++++++++++++++++++++++++++++ packages/cli/src/index.ts | 3 ++ packages/cli/test/commands.test.ts | 40 +++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/agents.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a2e103a..f3ddcbb 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -12,6 +12,8 @@ Current implementation anchors: - `init` - `identity` - `card` +- `register` +- `agents` - `discover` - `trust` - `policy` @@ -40,6 +42,9 @@ agentd identity list agentd identity show did:fides:... agentd identity domain challenge example.com did:fides:... agentd identity domain verify example.com did:fides:... +agentd register card_... +agentd agents list +agentd agents inspect did:fides:... agentd discover "reconcile invoices" --capability invoice.reconcile --provider local agentd discover --capability invoice.reconcile --provider registry --supported-versions fides.v2.0 --required-versions fides.v2.0 agentd discover --capability invoice.reconcile --provider relay --supported-versions fides.v2.0 @@ -88,6 +93,10 @@ Local identity files are stored under `~/.fides/identities` by default. Set `identity show` and `identity list` do not print private keys; private keys stay inside the local identity file. +`register` and `agents list/inspect` use the root v2 local agentd registration +endpoints. Registration records an AgentCard as a discovery candidate only; it +does not grant authority to invoke capabilities. + `discover --capability` targets local `agentd` capability discovery. Use `--provider local`, `well-known`, `registry`, `relay`, `dht`, `federation`, or `--all-providers` to choose the provider surface. These commands return diff --git a/packages/cli/src/commands/agents.ts b/packages/cli/src/commands/agents.ts new file mode 100644 index 0000000..0c067fd --- /dev/null +++ b/packages/cli/src/commands/agents.ts @@ -0,0 +1,44 @@ +import { Command } from 'commander' +import { getJson, postJson, printResult } from './authority-utils.js' + +export function createAgentsCommand(): Command { + const cmd = new Command('agents') + .description('Registered local agent candidates') + + cmd.command('list') + .description('List registered local agent candidates') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + const result = await getJson(`${baseUrl(options.agentdUrl)}/agents`) + printResult('Registered agents:', result, options) + }) + + cmd.command('inspect') + .description('Inspect a registered local agent candidate') + .argument('', 'Agent DID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (agentId, options) => { + const result = await getJson(`${baseUrl(options.agentdUrl)}/agents/${encodeURIComponent(agentId)}`) + printResult('Registered agent:', result, options) + }) + + return cmd +} + +export function createRegisterCommand(): Command { + return new Command('register') + .description('Register a local AgentCard as a discovery candidate') + .argument('', 'Local AgentCard ID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (agentCardId, options) => { + const result = await postJson(`${baseUrl(options.agentdUrl)}/agents/register`, { agentCardId }) + printResult('Agent registered:', result, options) + }) +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3089f18..d024781 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -22,6 +22,7 @@ import { createAuthorizeCommand } from './commands/authorize.js'; import { createRelayCommand } from './commands/relay.js'; import { createRegistryCommand } from './commands/registry.js'; import { createIdentityCommand } from './commands/identity.js'; +import { createAgentsCommand, createRegisterCommand } from './commands/agents.js'; import { createDhtCommand } from './commands/dht.js'; import { createEvidenceCommand } from './commands/evidence.js'; import { createDemoCommand } from './commands/demo.js'; @@ -59,6 +60,8 @@ program.addCommand(createAuthorizeCommand()); program.addCommand(createRelayCommand()); program.addCommand(createRegistryCommand()); program.addCommand(createIdentityCommand()); +program.addCommand(createRegisterCommand()); +program.addCommand(createAgentsCommand()); program.addCommand(createDhtCommand()); program.addCommand(createEvidenceCommand()); program.addCommand(createDemoCommand()); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 6e1f7e3..ccf1e3f 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -163,8 +163,9 @@ describe('CLI Commands', () => { }); describe('v2 command surface', () => { - it('exposes registry, dht, evidence, attest, demo, simulate, and invoke commands', async () => { + it('exposes registry, agents, dht, evidence, attest, demo, simulate, and invoke commands', async () => { const { createRegistryCommand } = await import('../src/commands/registry.js'); + const { createAgentsCommand, createRegisterCommand } = await import('../src/commands/agents.js'); const { createDhtCommand } = await import('../src/commands/dht.js'); const { createEvidenceCommand } = await import('../src/commands/evidence.js'); const { createAttestCommand } = await import('../src/commands/attest.js'); @@ -173,6 +174,8 @@ describe('CLI Commands', () => { const { createInvokeCommand } = await import('../src/commands/invoke.js'); expect(createRegistryCommand().name()).toBe('registry'); + expect(createRegisterCommand().name()).toBe('register'); + expect(createAgentsCommand().name()).toBe('agents'); expect(createDhtCommand().name()).toBe('dht'); expect(createEvidenceCommand().name()).toBe('evidence'); expect(createAttestCommand().name()).toBe('attest'); @@ -1097,6 +1100,41 @@ describe('CLI Commands', () => { ); }); + it('register and agents commands should manage local discovery candidates', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + registered: true, + agentId: 'did:fides:agent', + agents: [{ agentId: 'did:fides:agent' }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createRegisterCommand, createAgentsCommand } = await import('../src/commands/agents.js'); + const register = createRegisterCommand(); + const agents = createAgentsCommand(); + + await register.parseAsync(['card_1', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await agents.parseAsync(['list', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await agents.parseAsync(['inspect', 'did:fides:agent', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/agents/register', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ agentCardId: 'card_1' }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith(2, 'http://agentd.test/agents', expect.objectContaining({ method: 'GET' })); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'http://agentd.test/agents/did%3Afides%3Aagent', + expect.objectContaining({ method: 'GET' }) + ); + }); + it('incident report should call agentd incidents', async () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ recorded: true }), { status: 201, From c16facd65c918b70c64cc325dfb348a38327a605 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:55:47 +0300 Subject: [PATCH 110/282] feat(cli): wire root agent card commands --- docs/cli-reference.md | 9 +++++ packages/cli/src/commands/card.ts | 56 +++++++++++++++++++++++++--- packages/cli/test/commands.test.ts | 59 ++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f3ddcbb..2937829 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -42,6 +42,10 @@ agentd identity list agentd identity show did:fides:... agentd identity domain challenge example.com did:fides:... agentd identity domain verify example.com did:fides:... +agentd card create --did did:fides:... --name "Invoice Agent" --capabilities '[{"id":"invoice.reconcile"}]' --agentd-url http://localhost:7345 +agentd card sign did:fides:... +agentd card verify did:fides:... --agentd-url http://localhost:7345 +agentd card inspect did:fides:... agentd register card_... agentd agents list agentd agents inspect did:fides:... @@ -93,6 +97,11 @@ Local identity files are stored under `~/.fides/identities` by default. Set `identity show` and `identity list` do not print private keys; private keys stay inside the local identity file. +`card create --agentd-url`, `card sign`, `card inspect`, and +`card verify --agentd-url` use the root v2 local agentd AgentCard endpoints. +Without `--agentd-url`, `card create` and `card verify` keep their legacy local +validation and well-known lookup behavior. + `register` and `agents list/inspect` use the root v2 local agentd registration endpoints. Registration records an AgentCard as a discovery candidate only; it does not grant authority to invoke capabilities. diff --git a/packages/cli/src/commands/card.ts b/packages/cli/src/commands/card.ts index 1020165..cdff01c 100644 --- a/packages/cli/src/commands/card.ts +++ b/packages/cli/src/commands/card.ts @@ -6,6 +6,7 @@ import { WellKnownDiscoveryProvider } from '@fides/discovery' import { readFileSync } from 'node:fs' import { loadConfig } from '../utils/config.js' import { error, formatTable, info, success } from '../utils/output.js' +import { getJson, postJson, printResult } from './authority-utils.js' export function createCardCommand(): Command { const cmd = new Command('card') @@ -16,14 +17,23 @@ export function createCardCommand(): Command { .requiredOption('--did ', 'Agent DID') .option('--name ', 'Agent name') .option('--capabilities ', 'Capabilities JSON array') - .action((options) => { - const identity = createIdentity(options.did, 'agent', { name: options.name || 'Unknown' }) + .option('--agentd-url ', 'Create the AgentCard through local agentd') + .option('--json', 'Print JSON only') + .action(async (options) => { + const capabilities = parseCapabilities(options.capabilities) - let capabilities: CapabilityDescriptor[] = [] - if (options.capabilities) { - capabilities = JSON.parse(options.capabilities) + if (options.agentdUrl) { + const result = await postJson(`${baseUrl(options.agentdUrl)}/agent-cards`, { + agentId: options.did, + ...(options.name && { name: options.name }), + capabilities, + }) + printResult('AgentCard created:', result, options) + return } + const identity = createIdentity(options.did, 'agent', { name: options.name || 'Unknown' }) + const card: AgentCard = { id: options.did, identity, @@ -50,6 +60,26 @@ export function createCardCommand(): Command { } }) + cmd.command('sign') + .description('Sign a local agentd AgentCard') + .argument('', 'AgentCard ID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (agentCardId, options) => { + const result = await postJson(`${baseUrl(options.agentdUrl)}/agent-cards/${encodeURIComponent(agentCardId)}/sign`, {}) + printResult('AgentCard signed:', result, options) + }) + + cmd.command('inspect') + .description('Inspect a local agentd AgentCard') + .argument('', 'AgentCard ID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (agentCardId, options) => { + const result = await getJson(`${baseUrl(options.agentdUrl)}/agent-cards/${encodeURIComponent(agentCardId)}`) + printResult('AgentCard:', result, options) + }) + cmd.command('publish') .description('Publish an AgentCard JSON file to the hosted registry') .argument('', 'AgentCard JSON file path') @@ -188,8 +218,16 @@ export function createCardCommand(): Command { .description('Verify an AgentCard') .argument('', 'AgentCard JSON file path or DID to lookup') .option('--discovery-url ', 'Discovery service URL') + .option('--agentd-url ', 'Verify a local agentd AgentCard by ID') + .option('--json', 'Print JSON only') .action(async (source, options) => { try { + if (options.agentdUrl) { + const result = await postJson(`${baseUrl(options.agentdUrl)}/agent-cards/${encodeURIComponent(source)}/verify`, {}) + printResult('AgentCard verification:', result, options) + return + } + let card: AgentCard if (source.endsWith('.json')) { @@ -232,6 +270,14 @@ export function createCardCommand(): Command { return cmd } +function parseCapabilities(value?: string): CapabilityDescriptor[] { + return value ? JSON.parse(value) : [] +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} + function createRegistryClient(options: { registryUrl?: string; apiKey?: string }): RegistryClient { const config = loadConfig() return new RegistryClient({ diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index ccf1e3f..fa81887 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -332,6 +332,65 @@ describe('CLI Commands', () => { name: 'Agent', }, null, 2)); }); + + it('uses the root AgentCard API for local create, sign, inspect, and verify', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + card: { id: 'did:fides:agent' }, + signed: { payload: { id: 'did:fides:agent' } }, + valid: true, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createCardCommand } = await import('../src/commands/card.js'); + const cmd = createCardCommand(); + + await cmd.parseAsync([ + 'create', + '--did', + 'did:fides:agent', + '--name', + 'Invoice Agent', + '--capabilities', + '[{"id":"invoice.reconcile"}]', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync(['sign', 'did:fides:agent', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['inspect', 'did:fides:agent', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['verify', 'did:fides:agent', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/agent-cards', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + agentId: 'did:fides:agent', + name: 'Invoice Agent', + capabilities: [{ id: 'invoice.reconcile' }], + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'http://agentd.test/agent-cards/did%3Afides%3Aagent/sign', + expect.objectContaining({ method: 'POST' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'http://agentd.test/agent-cards/did%3Afides%3Aagent', + expect.objectContaining({ method: 'GET' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + 'http://agentd.test/agent-cards/did%3Afides%3Aagent/verify', + expect.objectContaining({ method: 'POST' }) + ); + }); }); describe('sign command', () => { From e20fda0fbe2e2b8b70c221793018d084d40e6025 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:57:17 +0300 Subject: [PATCH 111/282] feat(cli): add approval commands --- docs/cli-reference.md | 9 +++ packages/cli/src/commands/approval.ts | 92 +++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/test/commands.test.ts | 96 ++++++++++++++++++++++++++- 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/approval.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2937829..b27bf7b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -17,6 +17,7 @@ Current implementation anchors: - `discover` - `trust` - `policy` +- `approval` - `session` - `invoke` - `authorize` @@ -68,6 +69,10 @@ agentd dht find --capability invoice.reconcile agentd invoke did:fides:... --capability invoice.reconcile --input invoice.json --requested-scopes invoice:read agentd invoke --session-id sess_... --input invoice.json agentd invoke --dry-run did:fides:... --capability payments.prepare --input payment.json +agentd approval request --agent did:fides:... --capability payments.prepare --requested-scopes payments:prepare --risk-level high +agentd approval list +agentd approval approve appr_... --reason "human approved" +agentd approval deny appr_... --reason "too risky" agentd session request did:fides:... --capability invoice.reconcile --requested-scopes invoice:read agentd session verify sess_... agentd attest runtime --agent did:fides:... --code-hash sha256:... --runtime-hash sha256:... --policy-hash sha256:... @@ -129,6 +134,10 @@ Input defaults to `{}` and can be supplied with `--input` or `--input-json`. Use `--dry-run` to request dry-run execution; discovery is never treated as authority by this command. +`approval request/list/approve/deny` use root v2 approval endpoints. Approval +records human authorization intent and evidence, but does not grant authority +without a policy evaluation and scoped `SessionGrant`. + `session request`, `session show`, and `session verify` use the root v2 local agentd session endpoints. The older `session create` and `session revoke` commands remain available for the legacy signed `DelegationToken` `/v1` diff --git a/packages/cli/src/commands/approval.ts b/packages/cli/src/commands/approval.ts new file mode 100644 index 0000000..c60a405 --- /dev/null +++ b/packages/cli/src/commands/approval.ts @@ -0,0 +1,92 @@ +import { Command } from 'commander' +import { getJson, parseJsonObject, parseList, postJson, printResult } from './authority-utils.js' + +export function createApprovalCommand(): Command { + const cmd = new Command('approval') + .description('Approval requests and decisions') + + cmd.command('request') + .description('Create a root v2 approval request') + .requiredOption('--agent ', 'Target agent DID') + .requiredOption('--capability ', 'Capability ID') + .option('--requester-agent ', 'Requester agent DID') + .option('--principal ', 'Principal DID') + .option('--requested-scopes ', 'Comma-separated requested scopes') + .option('--risk-level ', 'Risk level: low, medium, high, critical') + .option('--policy-decision-hash ', 'Policy decision hash') + .option('--evidence-refs ', 'Comma-separated evidence event IDs') + .option('--expires-at ', 'Expiration timestamp') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + const result = await postJson(`${baseUrl(options.agentdUrl)}/approvals`, { + targetAgentId: options.agent, + capability: options.capability, + ...(options.requesterAgent && { requesterAgentId: options.requesterAgent }), + ...(options.principal && { principalId: options.principal }), + requestedScopes: parseList(options.requestedScopes), + ...(options.riskLevel && { riskLevel: options.riskLevel }), + ...(options.policyDecisionHash && { policyDecisionHash: options.policyDecisionHash }), + evidenceRefs: parseList(options.evidenceRefs), + ...(options.expiresAt && { expiresAt: options.expiresAt }), + }) + printResult('Approval requested:', result, options) + }) + + cmd.command('list') + .description('List root v2 approval requests') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + const result = await getJson(`${baseUrl(options.agentdUrl)}/approvals`) + printResult('Approvals:', result, options) + }) + + cmd.command('approve') + .description('Approve a root v2 approval request') + .argument('', 'Approval request ID') + .option('--approver ', 'Approver DID') + .option('--reason ', 'Approval reason') + .option('--constraints ', 'Decision constraints JSON object') + .option('--evidence-refs ', 'Comma-separated evidence event IDs') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (approvalId, options) => { + const result = await postJson(`${baseUrl(options.agentdUrl)}/approvals/${encodeURIComponent(approvalId)}/approve`, decisionBody(options)) + printResult('Approval approved:', result, options) + }) + + cmd.command('deny') + .description('Deny a root v2 approval request') + .argument('', 'Approval request ID') + .option('--approver ', 'Approver DID') + .option('--reason ', 'Denial reason') + .option('--constraints ', 'Decision constraints JSON object') + .option('--evidence-refs ', 'Comma-separated evidence event IDs') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (approvalId, options) => { + const result = await postJson(`${baseUrl(options.agentdUrl)}/approvals/${encodeURIComponent(approvalId)}/deny`, decisionBody(options)) + printResult('Approval denied:', result, options) + }) + + return cmd +} + +function decisionBody(options: { + approver?: string + reason?: string + constraints?: string + evidenceRefs?: string +}): Record { + return { + ...(options.approver && { approverId: options.approver }), + ...(options.reason && { reason: options.reason }), + constraints: parseJsonObject(options.constraints), + evidenceRefs: parseList(options.evidenceRefs), + } +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d024781..235f01e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,6 +9,7 @@ import { createDiscoverCommand } from './commands/discover.js'; import { createStatusCommand } from './commands/status.js'; import { createCardCommand } from './commands/card.js'; import { createPolicyCommand } from './commands/policy.js'; +import { createApprovalCommand } from './commands/approval.js'; import { createRuntimeCommand } from './commands/runtime.js'; import { createKillswitchCommand } from './commands/killswitch.js'; import { createDaemonCommand } from './commands/daemon.js'; @@ -47,6 +48,7 @@ program.addCommand(createDiscoverCommand()); program.addCommand(createStatusCommand()); program.addCommand(createCardCommand()); program.addCommand(createPolicyCommand()); +program.addCommand(createApprovalCommand()); program.addCommand(createRuntimeCommand()); program.addCommand(createKillswitchCommand()); program.addCommand(createDaemonCommand()); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index fa81887..26ec420 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -163,9 +163,10 @@ describe('CLI Commands', () => { }); describe('v2 command surface', () => { - it('exposes registry, agents, dht, evidence, attest, demo, simulate, and invoke commands', async () => { + it('exposes registry, agents, approval, dht, evidence, attest, demo, simulate, and invoke commands', async () => { const { createRegistryCommand } = await import('../src/commands/registry.js'); const { createAgentsCommand, createRegisterCommand } = await import('../src/commands/agents.js'); + const { createApprovalCommand } = await import('../src/commands/approval.js'); const { createDhtCommand } = await import('../src/commands/dht.js'); const { createEvidenceCommand } = await import('../src/commands/evidence.js'); const { createAttestCommand } = await import('../src/commands/attest.js'); @@ -176,6 +177,7 @@ describe('CLI Commands', () => { expect(createRegistryCommand().name()).toBe('registry'); expect(createRegisterCommand().name()).toBe('register'); expect(createAgentsCommand().name()).toBe('agents'); + expect(createApprovalCommand().name()).toBe('approval'); expect(createDhtCommand().name()).toBe('dht'); expect(createEvidenceCommand().name()).toBe('evidence'); expect(createAttestCommand().name()).toBe('attest'); @@ -1194,6 +1196,98 @@ describe('CLI Commands', () => { ); }); + it('approval commands should create and decide root v2 approvals', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + approval: { id: 'appr_1' }, + decisions: [], + authorityGranted: false, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createApprovalCommand } = await import('../src/commands/approval.js'); + const cmd = createApprovalCommand(); + + await cmd.parseAsync([ + 'request', + '--agent', + 'did:fides:target', + '--capability', + 'payments.prepare', + '--requester-agent', + 'did:fides:requester', + '--principal', + 'did:fides:principal', + '--requested-scopes', + 'payments:prepare,evidence:write', + '--risk-level', + 'high', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync(['list', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync([ + 'approve', + 'appr_1', + '--approver', + 'did:fides:approver', + '--reason', + 'human approved', + '--constraints', + '{"dryRunOnly":true}', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync(['deny', 'appr_2', '--reason', 'too risky', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/approvals', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + targetAgentId: 'did:fides:target', + capability: 'payments.prepare', + requesterAgentId: 'did:fides:requester', + principalId: 'did:fides:principal', + requestedScopes: ['payments:prepare', 'evidence:write'], + riskLevel: 'high', + evidenceRefs: [], + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith(2, 'http://agentd.test/approvals', expect.objectContaining({ method: 'GET' })); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'http://agentd.test/approvals/appr_1/approve', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + approverId: 'did:fides:approver', + reason: 'human approved', + constraints: { dryRunOnly: true }, + evidenceRefs: [], + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + 'http://agentd.test/approvals/appr_2/deny', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + reason: 'too risky', + constraints: {}, + evidenceRefs: [], + }), + }) + ); + }); + it('incident report should call agentd incidents', async () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ recorded: true }), { status: 201, From 9785be4b163d7db68e99647c90238011a8583116 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 12:59:21 +0300 Subject: [PATCH 112/282] feat(cli): wire root delegation command --- docs/cli-reference.md | 6 ++++ packages/cli/src/commands/delegate.ts | 19 ++++++++++- packages/cli/test/commands.test.ts | 45 +++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b27bf7b..cf333a6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -73,6 +73,7 @@ agentd approval request --agent did:fides:... --capability payments.prepare --re agentd approval list agentd approval approve appr_... --reason "human approved" agentd approval deny appr_... --reason "too risky" +agentd delegate create --delegator did:fides:principal --delegatee did:fides:agent --capabilities invoice.reconcile --agentd-url http://localhost:7345 agentd session request did:fides:... --capability invoice.reconcile --requested-scopes invoice:read agentd session verify sess_... agentd attest runtime --agent did:fides:... --code-hash sha256:... --runtime-hash sha256:... --policy-hash sha256:... @@ -138,6 +139,11 @@ authority by this command. records human authorization intent and evidence, but does not grant authority without a policy evaluation and scoped `SessionGrant`. +`delegate create --agentd-url` records a root v2 delegation with local agentd. +Without `--agentd-url`, `delegate create` keeps its legacy local +`DelegationToken` generation behavior. Delegation still does not grant +invocation authority until policy produces a scoped `SessionGrant`. + `session request`, `session show`, and `session verify` use the root v2 local agentd session endpoints. The older `session create` and `session revoke` commands remain available for the legacy signed `DelegationToken` `/v1` diff --git a/packages/cli/src/commands/delegate.ts b/packages/cli/src/commands/delegate.ts index dddb03e..80912c0 100644 --- a/packages/cli/src/commands/delegate.ts +++ b/packages/cli/src/commands/delegate.ts @@ -1,6 +1,6 @@ import { Command } from 'commander' import { createDelegationToken } from '@fides/core' -import { parseJsonObject, parseList, printResult } from './authority-utils.js' +import { parseJsonObject, parseList, postJson, printResult } from './authority-utils.js' export function createDelegateCommand(): Command { const cmd = new Command('delegate') @@ -20,6 +20,7 @@ export function createDelegateCommand(): Command { .option('--forbidden-contexts ', 'Comma-separated forbidden contexts') .option('--constraints-json ', 'Additional delegation constraints as JSON object') .option('--signature ', 'Externally produced signature for the token') + .option('--agentd-url ', 'Create the delegation through local agentd') .option('--json', 'Print JSON only') .action(async (options) => { try { @@ -32,6 +33,19 @@ export function createDelegateCommand(): Command { ...(options.forbiddenContexts && { forbiddenContexts: parseList(options.forbiddenContexts) }), } + if (options.agentdUrl) { + const result = await postJson(`${baseUrl(options.agentdUrl)}/delegations`, { + delegator: options.delegator, + delegatee: options.delegatee, + capabilities: parseList(options.capabilities), + constraints, + expiresAt, + audience: parseList(options.audience), + }) + printResult('Delegation recorded:', result, options) + return + } + const token = createDelegationToken({ delegator: options.delegator, delegatee: options.delegatee, @@ -54,3 +68,6 @@ export function createDelegateCommand(): Command { return cmd } +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 26ec420..f9c3d36 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -894,6 +894,51 @@ describe('CLI Commands', () => { expect(output.constraints.maxActions).toBe(3); }); + it('delegate create can record a root v2 delegation through agentd', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + token: { id: 'del_1' }, + authorityGranted: false, + }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createDelegateCommand } = await import('../src/commands/delegate.js'); + const cmd = createDelegateCommand(); + + await cmd.parseAsync([ + 'create', + '--delegator', + 'did:fides:principal', + '--delegatee', + 'did:fides:agent', + '--capabilities', + 'invoice.reconcile,payments.prepare', + '--max-actions', + '2', + '--audience', + 'agentd,invoice-agent', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://agentd.test/delegations', + expect.objectContaining({ method: 'POST' }) + ); + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(init.body as string)).toMatchObject({ + delegator: 'did:fides:principal', + delegatee: 'did:fides:agent', + capabilities: ['invoice.reconcile', 'payments.prepare'], + constraints: { maxActions: 2 }, + expiresAt: expect.any(String), + audience: ['agentd', 'invoice-agent'], + }); + }); + it('session create should call agentd with a DelegationToken', async () => { process.env.FIDES_API_KEY = 'cli-api-key' const mockFetch = vi.fn(async () => new Response(JSON.stringify({ From fd9d3f6e4114f0bd6878d87e9d369652c7473c0d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:01:45 +0300 Subject: [PATCH 113/282] feat(cli): add trust and reputation root commands --- docs/cli-reference.md | 9 +++ packages/cli/src/commands/reputation.ts | 51 ++++++++++++++++ packages/cli/src/commands/trust.ts | 21 +++++++ packages/cli/src/index.ts | 2 + packages/cli/test/commands.test.ts | 78 ++++++++++++++++++++++++- 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/reputation.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index cf333a6..e39b36a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -69,6 +69,9 @@ agentd dht find --capability invoice.reconcile agentd invoke did:fides:... --capability invoice.reconcile --input invoice.json --requested-scopes invoice:read agentd invoke --session-id sess_... --input invoice.json agentd invoke --dry-run did:fides:... --capability payments.prepare --input payment.json +agentd trust did:fides:... --capability invoice.reconcile --agentd-url http://localhost:7345 +agentd reputation update --agent did:fides:... --capability invoice.reconcile --successful-invocations 5 +agentd reputation get did:fides:... agentd approval request --agent did:fides:... --capability payments.prepare --requested-scopes payments:prepare --risk-level high agentd approval list agentd approval approve appr_... --reason "human approved" @@ -135,6 +138,12 @@ Input defaults to `{}` and can be supplied with `--input` or `--input-json`. Use `--dry-run` to request dry-run execution; discovery is never treated as authority by this command. +`trust --capability --agentd-url` evaluates root v2 +capability-specific trust. Without `--agentd-url`, `trust` keeps the legacy +trust-attestation behavior. `reputation update/get` manages root v2 +capability-specific reputation records. Trust and reputation remain signals; +policy is the authority. + `approval request/list/approve/deny` use root v2 approval endpoints. Approval records human authorization intent and evidence, but does not grant authority without a policy evaluation and scoped `SessionGrant`. diff --git a/packages/cli/src/commands/reputation.ts b/packages/cli/src/commands/reputation.ts new file mode 100644 index 0000000..3fa0130 --- /dev/null +++ b/packages/cli/src/commands/reputation.ts @@ -0,0 +1,51 @@ +import { Command } from 'commander' +import { getJson, postJson, printResult } from './authority-utils.js' + +export function createReputationCommand(): Command { + const cmd = new Command('reputation') + .description('Capability-specific reputation records') + + cmd.command('update') + .description('Update root v2 capability-specific reputation') + .requiredOption('--agent ', 'Agent DID') + .requiredOption('--capability ', 'Capability ID') + .option('--publisher ', 'Publisher DID') + .option('--principal ', 'Principal DID') + .option('--successful-invocations ', 'Successful invocation count') + .option('--failed-invocations ', 'Failed invocation count') + .option('--incident-count ', 'Incident count') + .option('--publisher-weight ', 'Publisher weight') + .option('--context-boundary-mismatch', 'Apply context boundary mismatch penalty') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + const result = await postJson(`${baseUrl(options.agentdUrl)}/reputation/update`, { + agentId: options.agent, + capability: options.capability, + ...(options.publisher && { publisherId: options.publisher }), + ...(options.principal && { principalId: options.principal }), + ...(options.successfulInvocations && { successfulInvocations: Number(options.successfulInvocations) }), + ...(options.failedInvocations && { failedInvocations: Number(options.failedInvocations) }), + ...(options.incidentCount && { incidentCount: Number(options.incidentCount) }), + ...(options.publisherWeight && { publisherWeight: Number(options.publisherWeight) }), + ...(options.contextBoundaryMismatch && { contextBoundaryMismatch: true }), + }) + printResult('Reputation updated:', result, options) + }) + + cmd.command('get') + .description('Get root v2 reputation records for an agent') + .argument('', 'Agent DID') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (agentId, options) => { + const result = await getJson(`${baseUrl(options.agentdUrl)}/reputation/${encodeURIComponent(agentId)}`) + printResult('Reputation records:', result, options) + }) + + return cmd +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/commands/trust.ts b/packages/cli/src/commands/trust.ts index 4098869..116105e 100644 --- a/packages/cli/src/commands/trust.ts +++ b/packages/cli/src/commands/trust.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { createAttestation, TrustClient, FileKeyStore, TrustLevel } from '@fides/sdk'; import { loadConfig } from '../utils/config.js'; import { success, error, info } from '../utils/output.js'; +import { getJson, postJson, printResult } from './authority-utils.js'; export function createTrustCommand(): Command { const cmd = new Command('trust'); @@ -10,8 +11,24 @@ export function createTrustCommand(): Command { .description('Create a trust attestation for another agent') .argument('', 'DID of the agent to trust') .option('--level ', 'Trust level: none, low, medium, high, absolute, or 0-100', 'medium') + .option('--capability ', 'Evaluate root v2 trust for a capability') + .option('--agentd-url ', 'Evaluate trust through local agentd') + .option('--json', 'Print JSON only') .action(async (agentDid, options) => { try { + if (options.agentdUrl) { + if (options.capability) { + const result = await postJson(`${baseUrl(options.agentdUrl)}/trust/evaluate`, { + agentId: agentDid, + capability: options.capability, + }) + printResult('Trust result:', result, options) + return + } + const result = await getJson(`${baseUrl(options.agentdUrl)}/trust/${encodeURIComponent(agentDid)}`) + printResult('Trust results:', result, options) + return + } await createTrust(agentDid, options); } catch (err) { error(`Failed to create trust attestation: ${err instanceof Error ? err.message : String(err)}`); @@ -93,3 +110,7 @@ async function createTrust(agentDid: string, options: { level: string }): Promis info(`Signature: ${attestation.signature.substring(0, 32)}...`); console.log(''); } + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 235f01e..ab02123 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { createInitCommand } from './commands/init.js'; import { createSignCommand } from './commands/sign.js'; import { createVerifyCommand } from './commands/verify.js'; import { createTrustCommand } from './commands/trust.js'; +import { createReputationCommand } from './commands/reputation.js'; import { createDiscoverCommand } from './commands/discover.js'; import { createStatusCommand } from './commands/status.js'; import { createCardCommand } from './commands/card.js'; @@ -44,6 +45,7 @@ program.addCommand(createInitCommand()); program.addCommand(createSignCommand()); program.addCommand(createVerifyCommand()); program.addCommand(createTrustCommand()); +program.addCommand(createReputationCommand()); program.addCommand(createDiscoverCommand()); program.addCommand(createStatusCommand()); program.addCommand(createCardCommand()); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index f9c3d36..b63d5ba 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -163,10 +163,11 @@ describe('CLI Commands', () => { }); describe('v2 command surface', () => { - it('exposes registry, agents, approval, dht, evidence, attest, demo, simulate, and invoke commands', async () => { + it('exposes registry, agents, approval, reputation, dht, evidence, attest, demo, simulate, and invoke commands', async () => { const { createRegistryCommand } = await import('../src/commands/registry.js'); const { createAgentsCommand, createRegisterCommand } = await import('../src/commands/agents.js'); const { createApprovalCommand } = await import('../src/commands/approval.js'); + const { createReputationCommand } = await import('../src/commands/reputation.js'); const { createDhtCommand } = await import('../src/commands/dht.js'); const { createEvidenceCommand } = await import('../src/commands/evidence.js'); const { createAttestCommand } = await import('../src/commands/attest.js'); @@ -178,6 +179,7 @@ describe('CLI Commands', () => { expect(createRegisterCommand().name()).toBe('register'); expect(createAgentsCommand().name()).toBe('agents'); expect(createApprovalCommand().name()).toBe('approval'); + expect(createReputationCommand().name()).toBe('reputation'); expect(createDhtCommand().name()).toBe('dht'); expect(createEvidenceCommand().name()).toBe('evidence'); expect(createAttestCommand().name()).toBe('attest'); @@ -1333,6 +1335,80 @@ describe('CLI Commands', () => { ); }); + it('trust and reputation commands should use root v2 evaluation APIs', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + trust: { agent_id: 'did:fides:agent', capability: 'invoice.reconcile' }, + reputation: { agent_id: 'did:fides:agent', capability: 'invoice.reconcile' }, + reputations: [], + authorityGranted: false, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createTrustCommand } = await import('../src/commands/trust.js'); + const { createReputationCommand } = await import('../src/commands/reputation.js'); + const trustEvaluate = createTrustCommand(); + const trustGet = createTrustCommand(); + const reputation = createReputationCommand(); + + await trustEvaluate.parseAsync([ + 'did:fides:agent', + '--capability', + 'invoice.reconcile', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await trustGet.parseAsync(['did:fides:agent', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await reputation.parseAsync([ + 'update', + '--agent', + 'did:fides:agent', + '--capability', + 'invoice.reconcile', + '--successful-invocations', + '5', + '--failed-invocations', + '1', + '--incident-count', + '0', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await reputation.parseAsync(['get', 'did:fides:agent', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/trust/evaluate', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + agentId: 'did:fides:agent', + capability: 'invoice.reconcile', + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith(2, 'http://agentd.test/trust/did%3Afides%3Aagent', expect.objectContaining({ method: 'GET' })); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'http://agentd.test/reputation/update', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + agentId: 'did:fides:agent', + capability: 'invoice.reconcile', + successfulInvocations: 5, + failedInvocations: 1, + incidentCount: 0, + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith(4, 'http://agentd.test/reputation/did%3Afides%3Aagent', expect.objectContaining({ method: 'GET' })); + }); + it('incident report should call agentd incidents', async () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ recorded: true }), { status: 201, From 4b0338a960787c50fcdd70b8f592ee2c996a11e1 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:03:31 +0300 Subject: [PATCH 114/282] feat(cli): wire root policy evaluation --- docs/cli-reference.md | 6 ++++ packages/cli/src/commands/policy.ts | 48 ++++++++++++++++++++++++++-- packages/cli/test/commands.test.ts | 49 +++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e39b36a..3053d68 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -72,6 +72,7 @@ agentd invoke --dry-run did:fides:... --capability payments.prepare --input paym agentd trust did:fides:... --capability invoice.reconcile --agentd-url http://localhost:7345 agentd reputation update --agent did:fides:... --capability invoice.reconcile --successful-invocations 5 agentd reputation get did:fides:... +agentd policy evaluate --agent did:fides:... --capability invoice.reconcile --requested-scopes read:invoices --agentd-url http://localhost:7345 agentd approval request --agent did:fides:... --capability payments.prepare --requested-scopes payments:prepare --risk-level high agentd approval list agentd approval approve appr_... --reason "human approved" @@ -144,6 +145,11 @@ trust-attestation behavior. `reputation update/get` manages root v2 capability-specific reputation records. Trust and reputation remain signals; policy is the authority. +`policy evaluate --agentd-url` evaluates root v2 policy-before-execution through +local agentd and returns a structured decision, trust context, required controls, +and whether a `SessionGrant` is still required. Without `--agentd-url`, +`policy evaluate` keeps its legacy local bundle evaluator behavior. + `approval request/list/approve/deny` use root v2 approval endpoints. Approval records human authorization intent and evidence, but does not grant authority without a policy evaluation and scoped `SessionGrant`. diff --git a/packages/cli/src/commands/policy.ts b/packages/cli/src/commands/policy.ts index ab6d48d..ded2087 100644 --- a/packages/cli/src/commands/policy.ts +++ b/packages/cli/src/commands/policy.ts @@ -1,6 +1,7 @@ import { Command } from 'commander' import { evaluatePolicy, type PolicyBundle } from '@fides/policy' import { readFileSync } from 'node:fs' +import { parseList, postJson, printResult } from './authority-utils.js' export function createPolicyCommand(): Command { const cmd = new Command('policy') @@ -8,10 +9,47 @@ export function createPolicyCommand(): Command { cmd.command('evaluate') .description('Evaluate a policy against context') - .requiredOption('--bundle ', 'Policy bundle JSON file') - .requiredOption('--context ', 'JSON context string or file path') - .action((options) => { + .option('--bundle ', 'Policy bundle JSON file') + .option('--context ', 'JSON context string or file path') + .option('--agent ', 'Target agent DID for root v2 policy evaluation') + .option('--capability ', 'Capability ID for root v2 policy evaluation') + .option('--principal ', 'Principal DID') + .option('--requester-agent ', 'Requester agent DID') + .option('--requested-scopes ', 'Comma-separated requested scopes') + .option('--runtime-attestation-valid', 'Mark runtime attestation as valid') + .option('--revocation-active', 'Mark revocation as active') + .option('--kill-switch-active', 'Mark kill switch as active') + .option('--incidents-active', 'Mark incidents as active') + .option('--approval-granted', 'Mark human approval as granted') + .option('--evidence-refs ', 'Comma-separated evidence event IDs') + .option('--agentd-url ', 'Evaluate policy through local agentd') + .option('--json', 'Print JSON only') + .action(async (options) => { try { + if (options.agentdUrl) { + if (!options.agent || !options.capability) { + throw new Error('--agent and --capability are required with --agentd-url') + } + const result = await postJson(`${baseUrl(options.agentdUrl)}/policy/evaluate`, { + agentId: options.agent, + capability: options.capability, + ...(options.principal && { principalId: options.principal }), + ...(options.requesterAgent && { requesterAgentId: options.requesterAgent }), + requestedScopes: parseList(options.requestedScopes), + ...(options.runtimeAttestationValid && { runtimeAttestationValid: true }), + ...(options.revocationActive && { revocationActive: true }), + ...(options.killSwitchActive && { killSwitchActive: true }), + ...(options.incidentsActive && { incidentsActive: true }), + ...(options.approvalGranted && { approvalGranted: true }), + evidenceRefs: parseList(options.evidenceRefs), + }) + printResult('Policy decision:', result, options) + return + } + + if (!options.bundle || !options.context) { + throw new Error('--bundle and --context are required without --agentd-url') + } const bundle: PolicyBundle = JSON.parse(readFileSync(options.bundle, 'utf-8')) let context: Record @@ -37,3 +75,7 @@ export function createPolicyCommand(): Command { return cmd } + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index b63d5ba..c37e444 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -1409,6 +1409,55 @@ describe('CLI Commands', () => { expect(mockFetch).toHaveBeenNthCalledWith(4, 'http://agentd.test/reputation/did%3Afides%3Aagent', expect.objectContaining({ method: 'GET' })); }); + it('policy evaluate can use the root v2 policy API', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + policy: { decision: 'allow' }, + requiresSessionGrant: true, + authorityGranted: false, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createPolicyCommand } = await import('../src/commands/policy.js'); + const cmd = createPolicyCommand(); + + await cmd.parseAsync([ + 'evaluate', + '--agent', + 'did:fides:agent', + '--capability', + 'invoice.reconcile', + '--principal', + 'did:fides:principal', + '--requester-agent', + 'did:fides:requester', + '--requested-scopes', + 'read:invoices,write:evidence', + '--approval-granted', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://agentd.test/policy/evaluate', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + agentId: 'did:fides:agent', + capability: 'invoice.reconcile', + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + requestedScopes: ['read:invoices', 'write:evidence'], + approvalGranted: true, + evidenceRefs: [], + }), + }) + ); + }); + it('incident report should call agentd incidents', async () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ recorded: true }), { status: 201, From 17fc3c22ca179aa2569a1bfa761a98ca90ee39e8 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:05:05 +0300 Subject: [PATCH 115/282] test(agentd): align demo endpoint with full demo contract --- services/agentd/src/index.ts | 3 +++ services/agentd/test/routes.test.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 4646d1a..baa72f6 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -175,6 +175,9 @@ const fullDemoSteps = [ 'publish_invoice_agent_to_registry', 'publish_calendar_agent_to_relay', 'publish_payment_pointer_to_dht', + 'verify_signed_registry_index_record', + 'verify_signed_relay_agent_card_reference', + 'verify_signed_dht_pointer_record', 'discover_calendar_locally', 'discover_invoice_through_registry', 'discover_payment_through_dht', diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 8c0f38f..f119636 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -56,6 +56,7 @@ import { } from '@fides/core' import * as ed from '@noble/ed25519' import { bytesToHex } from '@noble/hashes/utils' +import { fullDemoSteps as fullDemoContractSteps } from '../../../examples/full-demo/run.js' const mockFetch = fetch as ReturnType @@ -1851,6 +1852,7 @@ describe('Agentd Service Routes', () => { expect(demo.status).toBe(200) const demoData = await demo.json() expect(demoData.status).toBe('executed') + expect(demoData.steps).toEqual(fullDemoContractSteps) expect(demoData.steps).toContain('discover_payment_through_dht') expect(demoData.steps).toContain('verify_evidence_hash_chain') expect(demoData.authority).toMatchObject({ From 14a9bc8491b57e9dd0db2e3161a46d49a8d0f85c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:08:07 +0300 Subject: [PATCH 116/282] feat(evidence): align events with signed object envelope --- docs/protocol/evidence-ledger.md | 12 +++++++ packages/evidence/src/index.ts | 42 ++++++++++++++++++++----- packages/evidence/test/evidence.test.ts | 7 +++++ 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/docs/protocol/evidence-ledger.md b/docs/protocol/evidence-ledger.md index 8f4889d..127be71 100644 --- a/docs/protocol/evidence-ledger.md +++ b/docs/protocol/evidence-ledger.md @@ -9,6 +9,18 @@ Current implementation anchors: ## Event Classes +`EvidenceEventV2` is also aligned with the shared FIDES signed object envelope: + +- `schema_version` +- `id` as an alias for `event_id` +- `issuer` as an alias for the event `actor` +- `issued_at` as an alias for `timestamp` +- `payload_hash` +- `signature` + +The event still keeps evidence-native fields such as `prev_event_hash` and +`event_hash` for hash-chain verification. + The event taxonomy includes agent registration, discovery, trust computation, policy evaluation, approval, session, invocation, attestation, revocation, incident, and kill switch events. Current root `agentd` mutations append hash-only lifecycle evidence for: diff --git a/packages/evidence/src/index.ts b/packages/evidence/src/index.ts index 1da1344..8a48fe5 100644 --- a/packages/evidence/src/index.ts +++ b/packages/evidence/src/index.ts @@ -71,7 +71,9 @@ export type EvidencePrivacyMode = 'public' | 'private' | 'redacted' | 'hash_only export interface EvidenceEventV2 { schema_version: 'fides.evidence_event.v1' + id: string event_id: string + issuer: string type: EvidenceEventType actor: string subject?: string @@ -83,8 +85,10 @@ export interface EvidenceEventV2 { decision?: string risk_level?: 'low' | 'medium' | 'high' | 'critical' privacy_mode: EvidencePrivacyMode + issued_at: string timestamp: string prev_event_hash: string + payload_hash: string event_hash: string signature: string metadata?: Record @@ -128,13 +132,18 @@ export function createEvidenceEventV2( input: EvidenceEventV2Input, previousEventHash = '0' ): EvidenceEventV2 { - const eventWithoutHash: Omit = { + const eventId = input.event_id ?? crypto.randomUUID() + const timestamp = input.timestamp ?? new Date().toISOString() + const eventPayload: Omit = { schema_version: 'fides.evidence_event.v1', - event_id: input.event_id ?? crypto.randomUUID(), + id: eventId, + event_id: eventId, + issuer: input.actor, type: input.type, actor: input.actor, privacy_mode: input.privacy_mode ?? defaultPrivacyMode(input), - timestamp: input.timestamp ?? new Date().toISOString(), + issued_at: timestamp, + timestamp, prev_event_hash: previousEventHash, ...(input.subject !== undefined && { subject: input.subject }), ...(input.principal !== undefined && { principal: input.principal }), @@ -152,6 +161,10 @@ export function createEvidenceEventV2( ...(input.risk_level !== undefined && { risk_level: input.risk_level }), ...(input.metadata !== undefined && { metadata: input.metadata }), } + const eventWithoutHash: Omit = { + ...eventPayload, + payload_hash: hashEvidenceValue(eventPayload), + } const event_hash = hashEvidenceValue(eventWithoutHash) return { ...eventWithoutHash, @@ -181,8 +194,13 @@ export async function verifyEvidenceEventV2( verificationMethod = event.actor ): Promise { if (!event.signature) return false - const { event_hash, signature, ...withoutHashAndSignature } = event - if (hashEvidenceValue(withoutHashAndSignature) !== event_hash) return false + if (event.id !== event.event_id) return false + if (event.issuer !== event.actor) return false + if (event.issued_at !== event.timestamp) return false + const { event_hash, signature, payload_hash, ...withoutHashAndSignature } = event + if (hashEvidenceValue(withoutHashAndSignature) !== payload_hash) return false + const withPayloadHash = { ...withoutHashAndSignature, payload_hash } + if (hashEvidenceValue(withPayloadHash) !== event_hash) return false return verifyObject({ payload: { ...event, signature: '' }, proof: { @@ -196,6 +214,17 @@ export async function verifyEvidenceEventV2( }) } +export function verifyUnsignedEvidenceEventV2(event: EvidenceEventV2): boolean { + if (event.id !== event.event_id) return false + if (event.issuer !== event.actor) return false + if (event.issued_at !== event.timestamp) return false + const { event_hash, signature: _signature, payload_hash, ...withoutHashAndSignature } = event + if (hashEvidenceValue(withoutHashAndSignature) !== payload_hash) return false + const withPayloadHash = { ...withoutHashAndSignature, payload_hash } + if (hashEvidenceValue(withPayloadHash) !== event_hash) return false + return true +} + export function appendEvidenceEventV2( events: EvidenceEventV2[], event: EvidenceEventV2 @@ -212,8 +241,7 @@ export function verifyEvidenceEventsV2(events: EvidenceEventV2[]): boolean { const event = events[index] const expectedPrevious = index === 0 ? '0' : events[index - 1].event_hash if (event.prev_event_hash !== expectedPrevious) return false - const { event_hash, signature: _signature, ...withoutHashAndSignature } = event - if (hashEvidenceValue(withoutHashAndSignature) !== event_hash) return false + if (!verifyUnsignedEvidenceEventV2(event)) return false } return true } diff --git a/packages/evidence/test/evidence.test.ts b/packages/evidence/test/evidence.test.ts index e92214e..3775aeb 100644 --- a/packages/evidence/test/evidence.test.ts +++ b/packages/evidence/test/evidence.test.ts @@ -151,6 +151,11 @@ describe('Evidence Ledger', () => { }) expect(event.schema_version).toBe('fides.evidence_event.v1') + expect(event.id).toBe(event.event_id) + expect(event.issuer).toBe('did:fides:agent') + expect(event.issued_at).toBe(event.timestamp) + expect(event.payload_hash).toMatch(/^sha256:/) + expect(event.event_hash).toMatch(/^sha256:/) expect(event.privacy_mode).toBe('hash_only') expect(event.input_hash).toBe(hashEvidenceValue({ invoiceId: 'inv_123', secret: 'hidden' })) expect(event.policy_hash).toMatch(/^sha256:/) @@ -173,6 +178,8 @@ describe('Evidence Ledger', () => { expect(await verifyEvidenceEventV2(signed)).toBe(true) expect(await verifyEvidenceEventV2({ ...signed, metadata: { score: 0.1 } })).toBe(false) + expect(await verifyEvidenceEventV2({ ...signed, issuer: 'did:fides:other' })).toBe(false) + expect(await verifyEvidenceEventV2({ ...signed, payload_hash: 'sha256:tampered' })).toBe(false) }) it('verifies v2 hash chains and detects broken links', () => { From 857d7a8db5e36cfb5348a08344ecaa404b9122a2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:12:35 +0300 Subject: [PATCH 117/282] fix(evidence): normalize legacy event envelope fields --- docs/protocol/evidence-ledger.md | 8 ++ packages/evidence/src/index.ts | 136 ++++++++++++++++++++---- packages/evidence/test/evidence.test.ts | 46 ++++++++ services/agentd/src/index.ts | 3 +- 4 files changed, 170 insertions(+), 23 deletions(-) diff --git a/docs/protocol/evidence-ledger.md b/docs/protocol/evidence-ledger.md index 127be71..11bbc6b 100644 --- a/docs/protocol/evidence-ledger.md +++ b/docs/protocol/evidence-ledger.md @@ -21,6 +21,14 @@ Current implementation anchors: The event still keeps evidence-native fields such as `prev_event_hash` and `event_hash` for hash-chain verification. +Local daemon state can contain older V2 evidence events created before the +shared envelope fields existed. On load, `agentd` normalizes those legacy +events into the current envelope, recomputes the local chain links, and +preserves previous `event_hash` and `prev_event_hash` values in event metadata +as `legacy_event_hash` and `legacy_prev_event_hash`. This is a local migration +path for unanchored daemon state; externally anchored evidence exports should be +verified against the format and root that were originally exported. + The event taxonomy includes agent registration, discovery, trust computation, policy evaluation, approval, session, invocation, attestation, revocation, incident, and kill switch events. Current root `agentd` mutations append hash-only lifecycle evidence for: diff --git a/packages/evidence/src/index.ts b/packages/evidence/src/index.ts index 8a48fe5..04bf004 100644 --- a/packages/evidence/src/index.ts +++ b/packages/evidence/src/index.ts @@ -44,28 +44,31 @@ export interface MerkleProof { steps: MerkleProofStep[] } -export type EvidenceEventType = - | 'agent.registered' - | 'agent.updated' - | 'agent.revoked' - | 'discovery.performed' - | 'trust.computed' - | 'policy.evaluated' - | 'approval.requested' - | 'approval.granted' - | 'approval.denied' - | 'session.requested' - | 'session.granted' - | 'session.denied' - | 'capability.invoked' - | 'capability.completed' - | 'capability.failed' - | 'attestation.issued' - | 'attestation.verified' - | 'attestation.failed' - | 'revocation.recorded' - | 'incident.reported' - | 'kill_switch.triggered' +export const EVIDENCE_EVENT_TYPES = [ + 'agent.registered', + 'agent.updated', + 'agent.revoked', + 'discovery.performed', + 'trust.computed', + 'policy.evaluated', + 'approval.requested', + 'approval.granted', + 'approval.denied', + 'session.requested', + 'session.granted', + 'session.denied', + 'capability.invoked', + 'capability.completed', + 'capability.failed', + 'attestation.issued', + 'attestation.verified', + 'attestation.failed', + 'revocation.recorded', + 'incident.reported', + 'kill_switch.triggered', +] as const + +export type EvidenceEventType = typeof EVIDENCE_EVENT_TYPES[number] export type EvidencePrivacyMode = 'public' | 'private' | 'redacted' | 'hash_only' @@ -128,6 +131,28 @@ export function hashEvidenceValue(value: unknown): string { return `sha256:${bytesToHex(sha256(new TextEncoder().encode(canonicalJson(value))))}` } +function isEvidenceEventType(value: unknown): value is EvidenceEventType { + return typeof value === 'string' && EVIDENCE_EVENT_TYPES.includes(value as EvidenceEventType) +} + +function isEvidencePrivacyMode(value: unknown): value is EvidencePrivacyMode { + return value === 'public' || value === 'private' || value === 'redacted' || value === 'hash_only' +} + +function isRiskLevel(value: unknown): value is EvidenceEventV2['risk_level'] { + return value === 'low' || value === 'medium' || value === 'high' || value === 'critical' +} + +function optionalString(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' ? value : undefined +} + +function optionalMetadata(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined + return value as Record +} + export function createEvidenceEventV2( input: EvidenceEventV2Input, previousEventHash = '0' @@ -173,6 +198,73 @@ export function createEvidenceEventV2( } } +export function normalizeEvidenceEventV2( + input: EvidenceEventV2 | Record, + previousEventHash?: string +): EvidenceEventV2 { + const event = input as Record + const eventId = optionalString(event, 'event_id') ?? optionalString(event, 'id') ?? crypto.randomUUID() + const actor = optionalString(event, 'actor') ?? optionalString(event, 'issuer') ?? 'did:fides:unknown' + const timestamp = optionalString(event, 'timestamp') ?? optionalString(event, 'issued_at') ?? new Date().toISOString() + const legacyEventHash = optionalString(event, 'event_hash') + const legacyPrevEventHash = optionalString(event, 'prev_event_hash') + const hasEnvelope = + typeof event.id === 'string' && + typeof event.issuer === 'string' && + typeof event.issued_at === 'string' && + typeof event.payload_hash === 'string' + const existingMetadata = optionalMetadata(event.metadata) + const metadata = hasEnvelope + ? existingMetadata + : { + ...(existingMetadata ?? {}), + migrated_from_legacy_evidence_event: true, + ...(legacyEventHash !== undefined && { legacy_event_hash: legacyEventHash }), + ...(legacyPrevEventHash !== undefined && { legacy_prev_event_hash: legacyPrevEventHash }), + } + + const eventPayload: Omit = { + schema_version: 'fides.evidence_event.v1', + id: eventId, + event_id: eventId, + issuer: actor, + type: isEvidenceEventType(event.type) ? event.type : 'capability.failed', + actor, + privacy_mode: isEvidencePrivacyMode(event.privacy_mode) ? event.privacy_mode : 'hash_only', + issued_at: timestamp, + timestamp, + prev_event_hash: previousEventHash ?? legacyPrevEventHash ?? '0', + ...(optionalString(event, 'subject') !== undefined && { subject: optionalString(event, 'subject') }), + ...(optionalString(event, 'principal') !== undefined && { principal: optionalString(event, 'principal') }), + ...(optionalString(event, 'capability') !== undefined && { capability: optionalString(event, 'capability') }), + ...(optionalString(event, 'input_hash') !== undefined && { input_hash: optionalString(event, 'input_hash') }), + ...(optionalString(event, 'output_hash') !== undefined && { output_hash: optionalString(event, 'output_hash') }), + ...(optionalString(event, 'policy_hash') !== undefined && { policy_hash: optionalString(event, 'policy_hash') }), + ...(optionalString(event, 'decision') !== undefined && { decision: optionalString(event, 'decision') }), + ...(isRiskLevel(event.risk_level) && { risk_level: event.risk_level }), + ...(metadata !== undefined && { metadata }), + } + const eventWithoutHash: Omit = { + ...eventPayload, + payload_hash: hashEvidenceValue(eventPayload), + } + return { + ...eventWithoutHash, + event_hash: hashEvidenceValue(eventWithoutHash), + signature: optionalString(event, 'signature') ?? '', + } +} + +export function normalizeEvidenceEventsV2( + events: Array> +): EvidenceEventV2[] { + const normalized: EvidenceEventV2[] = [] + for (const event of events) { + normalized.push(normalizeEvidenceEventV2(event, normalized.at(-1)?.event_hash ?? '0')) + } + return normalized +} + export async function signEvidenceEventV2( event: EvidenceEventV2, privateKey: Uint8Array, diff --git a/packages/evidence/test/evidence.test.ts b/packages/evidence/test/evidence.test.ts index 3775aeb..e16fc90 100644 --- a/packages/evidence/test/evidence.test.ts +++ b/packages/evidence/test/evidence.test.ts @@ -8,6 +8,7 @@ import { createEvidenceEventV2, exportEvidenceEventsV2, hashEvidenceValue, + normalizeEvidenceEventsV2, redactEvidenceEventV2, redactEvent, signEvidenceEventV2, @@ -200,6 +201,51 @@ describe('Evidence Ledger', () => { expect(verifyEvidenceEventsV2([{ ...second, prev_event_hash: 'wrong' }])).toBe(false) }) + it('normalizes legacy v2 events into the signed object envelope', () => { + const legacyFirst = { + schema_version: 'fides.evidence_event.v1', + event_id: 'evt_legacy_1', + type: 'session.requested', + actor: 'did:fides:requester', + privacy_mode: 'hash_only', + timestamp: '2026-05-29T00:00:00.000Z', + prev_event_hash: '0', + event_hash: 'sha256:legacy-first', + signature: '', + } + const legacySecond = { + schema_version: 'fides.evidence_event.v1', + event_id: 'evt_legacy_2', + type: 'session.granted', + actor: 'did:fides:target', + privacy_mode: 'hash_only', + timestamp: '2026-05-29T00:00:01.000Z', + prev_event_hash: 'sha256:legacy-first', + event_hash: 'sha256:legacy-second', + signature: '', + } + + const normalized = normalizeEvidenceEventsV2([legacyFirst, legacySecond]) + + expect(normalized[0].id).toBe('evt_legacy_1') + expect(normalized[0].issuer).toBe('did:fides:requester') + expect(normalized[0].issued_at).toBe('2026-05-29T00:00:00.000Z') + expect(normalized[0].payload_hash).toMatch(/^sha256:/) + expect(normalized[0].event_hash).toMatch(/^sha256:/) + expect(normalized[0].metadata).toMatchObject({ + migrated_from_legacy_evidence_event: true, + legacy_event_hash: 'sha256:legacy-first', + legacy_prev_event_hash: '0', + }) + expect(normalized[1].prev_event_hash).toBe(normalized[0].event_hash) + expect(normalized[1].metadata).toMatchObject({ + migrated_from_legacy_evidence_event: true, + legacy_event_hash: 'sha256:legacy-second', + legacy_prev_event_hash: 'sha256:legacy-first', + }) + expect(verifyEvidenceEventsV2(normalized)).toBe(true) + }) + it('exports v2 events according to privacy mode without exposing metadata by default', () => { const event = createEvidenceEventV2({ type: 'capability.completed', diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index baa72f6..82b6a9b 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -16,6 +16,7 @@ import { appendEvidenceEventV2, createEvidenceChain, createEvidenceEventV2, + normalizeEvidenceEventsV2, verifyEvidenceChain, verifyEvidenceEventsV2, type EvidenceEventV2, @@ -275,7 +276,7 @@ function hydrateLocalState(snapshot: LocalDaemonStateSnapshot): void { for (const attestation of snapshot.runtimeAttestations as RuntimeAttestation[]) { if (attestation?.attestation_id) localRuntimeAttestations.set(attestation.attestation_id, attestation) } - localEvidenceEvents = snapshot.evidenceEvents as EvidenceEventV2[] + localEvidenceEvents = normalizeEvidenceEventsV2(snapshot.evidenceEvents as Array>) localSessionGrants.clear() for (const record of snapshot.sessionGrants as LocalSessionRecord[]) { if (record?.session?.session_id) localSessionGrants.set(record.session.session_id, record) From e68d9adebc786ed9527ee07fe1d2703cffac1234 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:13:55 +0300 Subject: [PATCH 118/282] fix(agentd): honor evidence export privacy options --- services/agentd/src/index.ts | 27 +++++++++++++++++++++++++-- services/agentd/test/routes.test.ts | 24 ++++++++++++++++++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 82b6a9b..bade1f4 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -16,6 +16,7 @@ import { appendEvidenceEventV2, createEvidenceChain, createEvidenceEventV2, + exportEvidenceEventsV2, normalizeEvidenceEventsV2, verifyEvidenceChain, verifyEvidenceEventsV2, @@ -2802,13 +2803,35 @@ app.post('/evidence/verify', (c) => { }) }) -app.post('/evidence/export', (c) => { +app.post('/evidence/export', async (c) => { + const body = await c.req.json().catch(() => ({})) + const privacyMode = body.privacy_mode ?? body.privacyMode + if ( + privacyMode !== undefined && + privacyMode !== 'public' && + privacyMode !== 'private' && + privacyMode !== 'redacted' && + privacyMode !== 'hash_only' + ) { + return c.json({ error: 'privacy_mode must be public, private, redacted, or hash_only' }, 400) + } + const includeMetadata = typeof body.include_metadata === 'boolean' + ? body.include_metadata + : typeof body.includeMetadata === 'boolean' + ? body.includeMetadata + : undefined + const events = exportEvidenceEventsV2(localEvidenceEvents, { + ...(privacyMode !== undefined && { privacy_mode: privacyMode }), + ...(includeMetadata !== undefined && { include_metadata: includeMetadata }), + }) return c.json({ format: 'json', exportedAt: new Date().toISOString(), valid: verifyEvidenceEventsV2(localEvidenceEvents), count: localEvidenceEvents.length, - events: localEvidenceEvents, + privacyMode: privacyMode ?? 'event_default', + includeMetadata: includeMetadata ?? null, + events, }) }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index f119636..be6fd52 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1917,6 +1917,7 @@ describe('Agentd Service Routes', () => { capability: 'invoice.reconcile', input: { invoiceId: 'inv_123', secret: 'do-not-store' }, decision: 'allow', + metadata: { rawPrompt: 'also-do-not-export' }, }), }) expect(appended.status).toBe(201) @@ -1943,14 +1944,29 @@ describe('Agentd Service Routes', () => { expect(verified.valid).toBe(true) expect(verified.count).toBeGreaterThanOrEqual(1) - const exported = await app.request('/evidence/export', { method: 'POST' }) + const exported = await app.request('/evidence/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ privacy_mode: 'private', include_metadata: false }), + }) expect(exported.status).toBe(200) const exportedData = await exported.json() expect(exportedData.format).toBe('json') expect(exportedData.valid).toBe(true) - expect(exportedData.events).toEqual(expect.arrayContaining([ - expect.objectContaining({ event_id: appendedData.event.event_id }), - ])) + expect(exportedData.privacyMode).toBe('private') + const exportedEvent = exportedData.events.find((event: any) => event.event_id === appendedData.event.event_id) + expect(exportedEvent).toBeDefined() + expect(exportedEvent.input_hash).toBeUndefined() + expect(exportedEvent.decision).toBeUndefined() + expect(exportedEvent.metadata).toBeUndefined() + expect(JSON.stringify(exportedData)).not.toContain('also-do-not-export') + + const invalidExport = await app.request('/evidence/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ privacy_mode: 'raw' }), + }) + expect(invalidExport.status).toBe(400) }) }) From ee956a2faa0ca735571e43599dee1f94adf00c61 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:17:36 +0300 Subject: [PATCH 119/282] feat(attest): add identity trust anchor commands --- docs/cli-reference.md | 15 ++- packages/cli/src/commands/attest.ts | 67 ++++++++++++++ packages/cli/test/commands.test.ts | 67 ++++++++++++++ services/agentd/src/index.ts | 139 ++++++++++++++++++++++++++++ services/agentd/test/routes.test.ts | 51 ++++++++++ 5 files changed, 335 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3053d68..f1e825c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -80,6 +80,11 @@ agentd approval deny appr_... --reason "too risky" agentd delegate create --delegator did:fides:principal --delegatee did:fides:agent --capabilities invoice.reconcile --agentd-url http://localhost:7345 agentd session request did:fides:... --capability invoice.reconcile --requested-scopes invoice:read agentd session verify sess_... +agentd attest github --identity did:fides:... --handle fides-dev +agentd attest email --identity did:fides:... --email dev@example.com +agentd attest domain --identity did:fides:... --domain example.com +agentd attest package --identity did:fides:... --registry npm --package @fides/example-agent +agentd attest wallet --identity did:fides:... --address 0x... agentd attest runtime --agent did:fides:... --code-hash sha256:... --runtime-hash sha256:... --policy-hash sha256:... agentd attest show att_... agentd attest verify att_... @@ -164,10 +169,12 @@ agentd session endpoints. The older `session create` and `session revoke` commands remain available for the legacy signed `DelegationToken` `/v1` authority path. -`attest runtime/show/verify` use the root v2 local agentd runtime attestation -endpoints. Issuing or verifying an attestation emits evidence and does not -grant authority by itself. The older `runtime attest` command remains a local -MockTEE helper for standalone runtime package checks. +`attest github/email/domain/package/wallet` add local mock identity trust +anchors to an existing identity and emit evidence. `attest runtime/show/verify` +use the root v2 local agentd runtime attestation endpoints. Issuing or +verifying an attestation emits evidence and does not grant authority by itself. +The older `runtime attest` command remains a local MockTEE helper for +standalone runtime package checks. `incident report/list/inspect/resolve` use the root v2 incident endpoints by default. Passing `--private-key-hex` keeps the legacy signed `/v1/incidents` diff --git a/packages/cli/src/commands/attest.ts b/packages/cli/src/commands/attest.ts index 077eeaf..b404ded 100644 --- a/packages/cli/src/commands/attest.ts +++ b/packages/cli/src/commands/attest.ts @@ -5,6 +5,61 @@ export function createAttestCommand(): Command { const cmd = new Command('attest') .description('Issue root v2 attestations through local agentd') + cmd.command('github') + .description('Add a local mock GitHub trust anchor to an identity') + .requiredOption('--identity ', 'Identity DID') + .requiredOption('--handle ', 'GitHub handle') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + await issueIdentityAttestation('github', { identity: options.identity, handle: options.handle }, options) + }) + + cmd.command('email') + .description('Add a local mock email trust anchor to an identity') + .requiredOption('--identity ', 'Identity DID') + .requiredOption('--email ', 'Email address') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + await issueIdentityAttestation('email', { identity: options.identity, email: options.email }, options) + }) + + cmd.command('domain') + .description('Add a local mock domain trust anchor to an identity') + .requiredOption('--identity ', 'Identity DID') + .requiredOption('--domain ', 'Domain name') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + await issueIdentityAttestation('domain', { identity: options.identity, domain: options.domain }, options) + }) + + cmd.command('package') + .description('Add a local mock package registry trust anchor to an identity') + .requiredOption('--identity ', 'Identity DID') + .requiredOption('--registry ', 'Package registry: npm or pypi') + .requiredOption('--package ', 'Package name') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + await issueIdentityAttestation('package', { + identity: options.identity, + registry: options.registry, + package: options.package, + }, options) + }) + + cmd.command('wallet') + .description('Add a local mock wallet trust anchor to an identity') + .requiredOption('--identity ', 'Identity DID') + .requiredOption('--address
', 'Wallet address') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (options) => { + await issueIdentityAttestation('wallet', { identity: options.identity, address: options.address }, options) + }) + cmd.command('runtime') .description('Issue a runtime attestation through agentd') .requiredOption('--agent ', 'Agent DID') @@ -49,6 +104,18 @@ export function createAttestCommand(): Command { return cmd } +async function issueIdentityAttestation( + type: string, + body: Record, + options: { agentdUrl: string; json?: boolean } +): Promise { + const result = await postJson(`${baseUrl(options.agentdUrl)}/attestations`, { + type, + ...body, + }) + printResult('Identity attestation issued:', result, options) +} + function baseUrl(url: string): string { return url.replace(/\/+$/, '') } diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index c37e444..cf13bd4 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -1177,6 +1177,73 @@ describe('CLI Commands', () => { ); }); + it('attest identity trust-anchor commands should call root v2 attestations', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + attestation: { id: 'att_identity_1' }, + evidenceRefs: ['evt_1'], + authorityGranted: false, + }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createAttestCommand } = await import('../src/commands/attest.js'); + const cmd = createAttestCommand(); + + await cmd.parseAsync(['github', '--identity', 'did:fides:publisher', '--handle', 'fides-dev', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['email', '--identity', 'did:fides:publisher', '--email', 'dev@example.com', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['domain', '--identity', 'did:fides:publisher', '--domain', 'example.com', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['package', '--identity', 'did:fides:publisher', '--registry', 'npm', '--package', '@fides/example-agent', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['wallet', '--identity', 'did:fides:publisher', '--address', '0xabc', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/attestations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ type: 'github', identity: 'did:fides:publisher', handle: 'fides-dev' }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'http://agentd.test/attestations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ type: 'email', identity: 'did:fides:publisher', email: 'dev@example.com' }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'http://agentd.test/attestations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ type: 'domain', identity: 'did:fides:publisher', domain: 'example.com' }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + 'http://agentd.test/attestations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + type: 'package', + identity: 'did:fides:publisher', + registry: 'npm', + package: '@fides/example-agent', + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 5, + 'http://agentd.test/attestations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ type: 'wallet', identity: 'did:fides:publisher', address: '0xabc' }), + }) + ); + }); + it('attest show and verify should inspect root v2 runtime attestations', async () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ attestation: { attestation_id: 'att_1' }, diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index bade1f4..b201ba7 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -81,6 +81,7 @@ import { type DHTPointerRecord, type IncidentRecord, type IncidentRecordV2, + type IdentityTrustAnchor, type KillSwitchRule, type DelegationToken, type PrincipalIdentity, @@ -94,6 +95,7 @@ import { type SignedRegistryIndexRecord, type SignedRegistryPeerRecord, type SignedAgentCard, + type TrustAnchorType, type TrustResult, type VersionNegotiationRecord, } from '@fides/core' @@ -2156,6 +2158,11 @@ app.post('/incidents/:id/resolve', async (c) => { app.post('/attestations', async (c) => { const body = await c.req.json().catch(() => ({})) + const identityAttestation = issueLocalIdentityAttestation(body) + if (identityAttestation) { + return c.json(identityAttestation.body, identityAttestation.status) + } + const agentId = typeof body.agentId === 'string' ? body.agentId : typeof body.agent_id === 'string' @@ -2259,6 +2266,138 @@ app.post('/attestations/:id/verify', async (c) => { return c.json({ id, valid, attestation, evidenceRefs: [event.event_id], authorityGranted: false }) }) +function issueLocalIdentityAttestation(body: Record): { body: Record; status: 201 | 400 | 404 } | null { + const identityId = typeof body.identity === 'string' + ? body.identity + : typeof body.identityId === 'string' + ? body.identityId + : typeof body.identity_id === 'string' + ? body.identity_id + : undefined + if (!identityId) return null + + const record = localIdentities.get(identityId) + if (!record) { + return { + status: 404, + body: { + error: 'identity not found', + identity: identityId, + authorityGranted: false, + }, + } + } + + const anchor = createLocalIdentityTrustAnchor(body) + if (!anchor) { + return { + status: 400, + body: { + error: 'attestation type and value are required', + identity: identityId, + authorityGranted: false, + }, + } + } + + record.identity = { + ...record.identity, + trustAnchors: [ + ...(record.identity.trustAnchors ?? []), + anchor, + ], + } as LocalIdentityRecord['identity'] + localIdentities.set(identityId, record) + + const event = appendRootEvidence({ + type: 'attestation.issued', + actor: identityId, + subject: identityId, + output: anchor, + decision: 'issued', + privacy_mode: 'hash_only', + metadata: { + trust_anchor_type: anchor.type, + trust_anchor_value: anchor.value, + mock: true, + }, + }) + + return { + status: 201, + body: { + attestation: { + id: `att_${crypto.randomUUID()}`, + schema_version: 'fides.identity_attestation.v1', + identity: identityId, + trust_anchor: anchor, + issued_at: anchor.verifiedAt, + mode: 'local_mock', + }, + identity: safeIdentityRecord(record), + evidenceRefs: [event.event_id], + authorityGranted: false, + }, + } +} + +function createLocalIdentityTrustAnchor(body: Record): IdentityTrustAnchor | null { + const rawType = typeof body.type === 'string' + ? body.type + : typeof body.provider === 'string' + ? body.provider + : undefined + const type = normalizeTrustAnchorType(rawType, body) + if (!type) return null + const value = identityTrustAnchorValue(type, body) + if (!value) return null + return { + type, + value, + verified: true, + verifiedAt: new Date().toISOString(), + } +} + +function normalizeTrustAnchorType(rawType: string | undefined, body: Record): TrustAnchorType | null { + if (rawType === 'package') { + const registry = typeof body.registry === 'string' ? body.registry.toLowerCase() : '' + if (registry === 'npm') return 'npm' + if (registry === 'pypi') return 'pypi' + return null + } + if ( + rawType === 'domain' || + rawType === 'github' || + rawType === 'email' || + rawType === 'npm' || + rawType === 'pypi' || + rawType === 'wallet' || + rawType === 'passkey' || + rawType === 'organization_invitation' || + rawType === 'runtime_attestation' || + rawType === 'build_attestation' || + rawType === 'peer_attestation' + ) { + return rawType + } + return null +} + +function identityTrustAnchorValue(type: TrustAnchorType, body: Record): string | null { + if (type === 'github') return stringField(body, 'handle') + if (type === 'email') return stringField(body, 'email') + if (type === 'domain') return stringField(body, 'domain') + if (type === 'wallet') return stringField(body, 'address') + if (type === 'npm' || type === 'pypi') return stringField(body, 'package') ?? stringField(body, 'name') + return stringField(body, 'value') +} + +function stringField(record: Record, key: string): string | null { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + // ─── FIDES v2 Local API Aliases ─────────────────────────────────── app.post('/dht/start', (c) => { return c.json({ started: true, mode: 'in_memory_simulator', pointers: localDhtPointers.length }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index be6fd52..dbd8b3c 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1476,6 +1476,57 @@ describe('Agentd Service Routes', () => { ])) }) + it('adds local mock identity trust anchors without granting authority', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'publisher', name: 'Anchor Publisher' }), + }) + const { identity } = await identityResponse.json() + + const attestation = await app.request('/attestations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity: identity.did, + type: 'github', + handle: 'fides-publisher', + }), + }) + + expect(attestation.status).toBe(201) + const data = await attestation.json() + expect(data.authorityGranted).toBe(false) + expect(data.attestation).toMatchObject({ + schema_version: 'fides.identity_attestation.v1', + identity: identity.did, + mode: 'local_mock', + trust_anchor: { + type: 'github', + value: 'fides-publisher', + verified: true, + }, + }) + expect(data.identity.identity.trustAnchors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'github', + value: 'fides-publisher', + verified: true, + }), + ])) + + const evidence = await app.request('/evidence') + const evidenceData = await evidence.json() + expect(evidenceData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event_id: data.evidenceRefs[0], + type: 'attestation.issued', + subject: identity.did, + privacy_mode: 'hash_only', + }), + ])) + }) + it('records failed attestation verification evidence for missing attestations', async () => { const verified = await app.request('/attestations/att_missing/verify', { method: 'POST' }) expect(verified.status).toBe(404) From e9d89ceffb5744bf3b26bef36dc228b546983f60 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:20:34 +0300 Subject: [PATCH 120/282] feat(sdk): add identity attestation helpers --- docs/sdk-reference.md | 32 +++++++++++++++--- packages/sdk/src/fides-client.ts | 30 ++++++++++++++++ packages/sdk/test/fides-client.test.ts | 47 ++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 26e034f..7bacad0 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -108,6 +108,27 @@ const attestation = await client.attestations.create({ policyHash: `sha256:${'c'.repeat(64)}`, }) await client.attestations.verify(attestation.attestation.attestation_id) +await client.attestations.github({ + identity: identity.identity.did, + handle: 'fides-dev', +}) +await client.attestations.email({ + identity: identity.identity.did, + email: 'dev@example.com', +}) +await client.attestations.domain({ + identity: identity.identity.did, + domain: 'example.com', +}) +await client.attestations.package({ + identity: identity.identity.did, + registry: 'npm', + package: '@fides/example-agent', +}) +await client.attestations.wallet({ + identity: identity.identity.did, + address: '0x...', +}) await client.registry.start() await client.registry.publish({ agentCardId: identity.identity.did }) await client.registry.search({ @@ -167,11 +188,12 @@ scoped SessionGrant. Session request and invocation helpers use the same root local daemon API. Approval and kill switch helpers expose local authority controls, with kill switch rules overriding normal policy while active. Revocation and incident helpers expose local governance records that feed root -session policy decisions. Runtime attestation helpers issue and verify local -MockTEE attestations that can satisfy high-risk session policy when passed as -an `attestationId`. Registry, relay, DHT, federation, and well-known helpers -expose the local mock discovery surfaces. They return candidate records or -pointers only; they do not convert discovery into authority. Discovery, +session policy decisions. Attestation helpers include local mock identity trust +anchors for GitHub, email, domain, package registry, and wallet claims, plus +runtime MockTEE attestations that can satisfy high-risk session policy when +passed as an `attestationId`. Registry, relay, DHT, federation, and well-known +helpers expose the local mock discovery surfaces. They return candidate records +or pointers only; they do not convert discovery into authority. Discovery, registry, relay, and federation helpers accept `supported_versions` and `required_versions` so callers can request protocol compatibility filtering. `dht.publish` can publish a signed local pointer without an AgentCard URL when diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 6f0afcf..effd006 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -108,6 +108,31 @@ export interface FidesEvidenceExportRequest { include_metadata?: boolean } +export interface FidesIdentityAttestationRequest { + identity: string +} + +export interface FidesGithubAttestationRequest extends FidesIdentityAttestationRequest { + handle: string +} + +export interface FidesEmailAttestationRequest extends FidesIdentityAttestationRequest { + email: string +} + +export interface FidesDomainAttestationRequest extends FidesIdentityAttestationRequest { + domain: string +} + +export interface FidesPackageAttestationRequest extends FidesIdentityAttestationRequest { + registry: 'npm' | 'pypi' + package: string +} + +export interface FidesWalletAttestationRequest extends FidesIdentityAttestationRequest { + address: string +} + export interface FidesInvocationResponse { authorityGranted: boolean session: SessionGrantV2 @@ -211,6 +236,11 @@ export class FidesClient { readonly attestations = { create: (body: Record) => this.post('/attestations', body), + github: (body: FidesGithubAttestationRequest) => this.post('/attestations', { type: 'github', ...body }), + email: (body: FidesEmailAttestationRequest) => this.post('/attestations', { type: 'email', ...body }), + domain: (body: FidesDomainAttestationRequest) => this.post('/attestations', { type: 'domain', ...body }), + package: (body: FidesPackageAttestationRequest) => this.post('/attestations', { type: 'package', ...body }), + wallet: (body: FidesWalletAttestationRequest) => this.post('/attestations', { type: 'wallet', ...body }), get: (attestationId: string) => this.get(`/attestations/${encodeURIComponent(attestationId)}`), verify: (attestationId: string) => this.post(`/attestations/${encodeURIComponent(attestationId)}/verify`, {}), } diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index d357bb5..bfc0818 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -311,6 +311,53 @@ describe('FidesClient', () => { } }) + it('adds identity trust-anchor attestations through promise helpers', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) + return new Response(JSON.stringify({ + attestation: { id: 'att_identity_1' }, + evidenceRefs: ['evt_1'], + authorityGranted: false, + }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + + await client.attestations.github({ identity: 'did:fides:publisher', handle: 'fides-dev' }) + await client.attestations.email({ identity: 'did:fides:publisher', email: 'dev@example.com' }) + await client.attestations.domain({ identity: 'did:fides:publisher', domain: 'example.com' }) + await client.attestations.package({ + identity: 'did:fides:publisher', + registry: 'npm', + package: '@fides/example-agent', + }) + await client.attestations.wallet({ identity: 'did:fides:publisher', address: '0xabc' }) + + expect(calls.map(call => call.url)).toEqual([ + 'http://localhost:7345/attestations', + 'http://localhost:7345/attestations', + 'http://localhost:7345/attestations', + 'http://localhost:7345/attestations', + 'http://localhost:7345/attestations', + ]) + expect(calls.map(call => JSON.parse(call.init?.body as string))).toEqual([ + { type: 'github', identity: 'did:fides:publisher', handle: 'fides-dev' }, + { type: 'email', identity: 'did:fides:publisher', email: 'dev@example.com' }, + { type: 'domain', identity: 'did:fides:publisher', domain: 'example.com' }, + { + type: 'package', + identity: 'did:fides:publisher', + registry: 'npm', + package: '@fides/example-agent', + }, + { type: 'wallet', identity: 'did:fides:publisher', address: '0xabc' }, + ]) + }) + it('creates and submits signed invocation requests from a session grant', async () => { const requester = await createAgentIdentity() const sessionGrant: SessionGrantV2 = { From e5404cf0dba4db73e45c62a4791c2aa1a6e1905a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:21:36 +0300 Subject: [PATCH 121/282] docs(api): document identity attestations --- docs/api-reference.md | 17 +++++++++++------ docs/api/agentd.yaml | 10 ++++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 5dc5430..652e76f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -230,9 +230,14 @@ resolved with `POST /incidents/:id/resolve`. Revocation and incident creation append `revocation.recorded` and `incident.reported` evidence events and return `evidenceRefs`. -`POST /attestations` issues a local FIDES v2 runtime attestation through the -MockTEE provider. `POST /attestations/:id/verify` verifies provider, expiry, -and hash shape. Root `POST /sessions` can consume a valid `attestationId` as -runtime attestation evidence for high-risk capability policy. Attestation -issuance and verification append `attestation.issued`, `attestation.verified`, -or `attestation.failed` evidence events and return `evidenceRefs`. +`POST /attestations` accepts two local attestation shapes. When the body +contains `identity`, it adds a local mock identity trust anchor for `github`, +`email`, `domain`, `package` (`npm` or `pypi`), or `wallet` and appends +`attestation.issued` evidence without granting authority. When the body +contains `agentId` plus `codeHash`, `runtimeHash`, and `policyHash`, it issues +a local FIDES v2 runtime attestation through the MockTEE provider. +`POST /attestations/:id/verify` verifies runtime provider, expiry, and hash +shape. Root `POST /sessions` can consume a valid `attestationId` as runtime +attestation evidence for high-risk capability policy. Attestation issuance and +verification append `attestation.issued`, `attestation.verified`, or +`attestation.failed` evidence events and return `evidenceRefs`. diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index f07e946..d8da74b 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -85,9 +85,15 @@ paths: /attestations: post: - operationId: createRuntimeAttestation + operationId: createLocalAttestation tags: [Attestation] - summary: Issue a local runtime attestation + summary: Issue a local identity or runtime attestation + description: | + Bodies with `identity` add local mock identity trust anchors for + GitHub, email, domain, package registry, or wallet claims. Bodies with + `agentId`, `codeHash`, `runtimeHash`, and `policyHash` issue a local + MockTEE runtime attestation. Attestation issuance records evidence and + does not grant authority by itself. security: - ApiKeyAuth: [] requestBody: From 7c895efd18050494d58f7e5f63da8743dc11b3b3 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:23:44 +0300 Subject: [PATCH 122/282] feat(agentd): normalize local agent cards --- docs/protocol/agent-card.md | 6 ++++ services/agentd/src/index.ts | 32 ++++++++++++++++-- services/agentd/test/routes.test.ts | 52 +++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/docs/protocol/agent-card.md b/docs/protocol/agent-card.md index f0bede7..288dff3 100644 --- a/docs/protocol/agent-card.md +++ b/docs/protocol/agent-card.md @@ -33,6 +33,12 @@ signing: - `transports` defaults from endpoint transport metadata - `protocolVersions` defaults to the current FIDES protocol version +The root local `agentd` AgentCard creation endpoint stores normalized cards, +not sparse request bodies. When available, it carries local publisher identity, +agent trust anchors, runtime attestations, revocation metadata, public keys, and +transport metadata into the stored card before signing. This keeps the unsigned +inspection endpoint and signed payload aligned. + ## Rule Discovery may return AgentCards, but invocation requires trust evaluation, policy evaluation, and a scoped session grant. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index b201ba7..8f35da0 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -54,6 +54,7 @@ import { isKillSwitchRuleActive, MockTEEProvider as CoreMockTEEProvider, negotiateProtocolVersion, + normalizeAgentCard, resolveIncidentRecordV2, signAgentCard, signDHTPointerRecord, @@ -863,7 +864,28 @@ app.post('/agent-cards', async (c) => { : [] const now = new Date().toISOString() - const card: AgentCard = { + const publisherId = typeof body.publisherId === 'string' + ? body.publisherId + : typeof body.publisher_id === 'string' + ? body.publisher_id + : undefined + const publisher = publisherId ? localIdentities.get(publisherId) : undefined + if (publisherId && (!publisher || publisher.type !== 'publisher')) { + return c.json({ error: 'publisher identity not found in local daemon', publisherId }, 404) + } + const runtimeAttestationIds: string[] = Array.isArray(body.runtimeAttestationIds) + ? body.runtimeAttestationIds.map(String) + : Array.isArray(body.runtime_attestation_ids) + ? body.runtime_attestation_ids.map(String) + : [] + const runtimeAttestations = runtimeAttestationIds + .map((id: string) => localRuntimeAttestations.get(id)) + .filter((attestation: RuntimeAttestation | undefined): attestation is RuntimeAttestation => Boolean(attestation)) + if (runtimeAttestationIds.length !== runtimeAttestations.length) { + return c.json({ error: 'one or more runtime attestations were not found', runtimeAttestationIds }, 404) + } + + const card = normalizeAgentCard({ schema_version: 'fides.agent_card.v1', id: did, agent_id: did, @@ -874,16 +896,22 @@ app.post('/agent-cards', async (c) => { ...(typeof body.name === 'string' && { name: body.name }), }, }, + ...(publisher?.identity && { publisher: publisher.identity as PublisherIdentity }), capabilities, endpoints: Array.isArray(body.endpoints) ? body.endpoints : [], + ...(Array.isArray(body.transports) && { transports: body.transports }), policies: Array.isArray(body.policies) ? body.policies : [{ requiresRuntimeAttestation: false, requiresApproval: false }], + ...(localIdentity.identity.trustAnchors?.length && { trustAnchors: localIdentity.identity.trustAnchors }), + ...(runtimeAttestations.length > 0 && { runtimeAttestations }), + ...(typeof body.revocationUrl === 'string' && { revocationUrl: body.revocationUrl }), + ...(typeof body.revocationRef === 'string' && { revocationRef: body.revocationRef }), protocolVersions: Array.isArray(body.protocolVersions) ? body.protocolVersions.map(String) : ['fides.v2.0'], createdAt: now, updatedAt: now, ...(typeof body.expiresAt === 'string' && { expiresAt: body.expiresAt }), - } + }) const validation = validateAgentCard(card) if (!validation.valid) { diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index dbd8b3c..1e5cda7 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -343,6 +343,32 @@ describe('Agentd Service Routes', () => { body: JSON.stringify({ type: 'agent', name: 'Invoice Agent' }), }) const { identity } = await identityResponse.json() + const publisherResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'publisher', name: 'Invoice Publisher' }), + }) + const { identity: publisher } = await publisherResponse.json() + await app.request('/attestations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity: identity.did, + type: 'github', + handle: 'invoice-agent', + }), + }) + const runtimeAttestation = await app.request('/attestations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: identity.did, + codeHash: `sha256:${'a'.repeat(64)}`, + runtimeHash: `sha256:${'b'.repeat(64)}`, + policyHash: `sha256:${'c'.repeat(64)}`, + }), + }) + const runtimeAttestationData = await runtimeAttestation.json() const created = await app.request('/agent-cards', { method: 'POST', @@ -350,19 +376,45 @@ describe('Agentd Service Routes', () => { body: JSON.stringify({ identity, name: 'Invoice Agent', + publisherId: publisher.did, capabilities: [{ id: 'invoice.reconcile', requiredScopes: ['invoice:read'] }], + endpoints: [{ + url: 'https://invoice.example.test/invoke', + protocol: 'https', + capabilities: ['invoice.reconcile'], + auth: 'delegation', + }], + runtimeAttestationIds: [runtimeAttestationData.attestation.attestation_id], + revocationUrl: 'https://invoice.example.test/revocations', }), }) expect(created.status).toBe(201) const createdData = await created.json() expect(createdData.card.id).toBe(identity.did) expect(createdData.card.capabilities[0].id).toBe('invoice.reconcile') + expect(createdData.card.publisher.did).toBe(publisher.did) + expect(createdData.card.publicKeys[0]).toEqual(expect.objectContaining({ + id: `${identity.did}#ed25519`, + type: 'Ed25519', + })) + expect(createdData.card.transports[0]).toMatchObject({ + protocol: 'https', + url: 'https://invoice.example.test/invoke', + auth: 'delegation', + }) + expect(createdData.card.trustAnchors).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'github', value: 'invoice-agent', verified: true }), + ])) + expect(createdData.card.runtimeAttestations[0].attestation_id).toBe(runtimeAttestationData.attestation.attestation_id) + expect(createdData.card.revocationUrl).toBe('https://invoice.example.test/revocations') expect(createdData.validation.valid).toBe(true) const signed = await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) expect(signed.status).toBe(200) const signedData = await signed.json() expect(signedData.signed.proof.type).toBe('Ed25519Signature2024') + expect(signedData.signed.payload.publicKeys[0].id).toBe(`${identity.did}#ed25519`) + expect(signedData.signed.payload.publisher.did).toBe(publisher.did) const verified = await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/verify`, { method: 'POST' }) expect(verified.status).toBe(200) From 3a9b23238e5f6a1d4f221526e392dfd8f00fe2c9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:25:50 +0300 Subject: [PATCH 123/282] fix(dht): reject unadvertised capability pointers --- docs/protocol/dht-discovery.md | 6 ++++-- packages/core/src/dht.ts | 3 +++ packages/core/test/dht.test.ts | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/protocol/dht-discovery.md b/docs/protocol/dht-discovery.md index 7f2ea64..5864c10 100644 --- a/docs/protocol/dht-discovery.md +++ b/docs/protocol/dht-discovery.md @@ -24,12 +24,14 @@ publishes are accepted only as local mock records and are marked unverified. 2. Query DHT for pointers. 3. Verify pointer signature and expiry. 4. Resolve AgentCard. -5. Verify AgentCard hash and signature. +5. Verify AgentCard hash, agent identity, advertised capability, and signature. 6. Continue to trust and policy. `/dht/find` and `/discover/dht` verify signed local pointer records before returning them. Expired, tampered, or AgentCard-hash-mismatched pointers are -reported as rejected pointers and do not become authority. +reported as rejected pointers and do not become authority. A pointer is also +rejected when it claims a capability that the resolved AgentCard does not +advertise. The package-level `DHTDiscoveryProvider` also rejects invalid pointers before returning candidates. Tampered pointer hashes, expired pointers, AgentCard hash diff --git a/packages/core/src/dht.ts b/packages/core/src/dht.ts index 9ffa344..07eaf68 100644 --- a/packages/core/src/dht.ts +++ b/packages/core/src/dht.ts @@ -92,6 +92,9 @@ export async function verifyDHTPointerRecord( if ((options.card.agent_id ?? options.card.identity.did) !== record.agent_id) { errors.push('DHT pointer agent_id does not match AgentCard') } + if (!options.card.capabilities.some(capability => capability.id === record.capability)) { + errors.push('DHT pointer capability is not advertised by AgentCard') + } } if (!record.signature) { diff --git a/packages/core/test/dht.test.ts b/packages/core/test/dht.test.ts index ee65b21..545120b 100644 --- a/packages/core/test/dht.test.ts +++ b/packages/core/test/dht.test.ts @@ -83,4 +83,22 @@ describe('DHT pointer records', () => { expect(result.valid).toBe(false) expect(result.errors).toContain('DHT pointer agent_card_hash mismatch') }) + + it('rejects pointers for capabilities not advertised by the AgentCard', async () => { + const { card, publisher } = await fixture() + const record = createDHTPointerRecord({ + capability: 'payments.execute', + agentId: card.identity.did, + agentCardUrl: 'https://agent.example/card.json', + agentCardHash: hashAgentCard(card), + publisherId: publisher.identity.did, + expiresAt: '2999-01-01T00:00:00.000Z', + }) + const signed = await signDHTPointerRecord(record, publisher.privateKey) + + const result = await verifyDHTPointerRecord(signed, { card }) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('DHT pointer capability is not advertised by AgentCard') + }) }) From 7a755f5adf6c62287885f5237c09470b4992f872 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:27:54 +0300 Subject: [PATCH 124/282] feat(invocation): validate requests against session grants --- docs/protocol/delegation-and-sessions.md | 8 ++++ packages/core/src/invocation.ts | 59 +++++++++++++++++++++++- packages/core/test/invocation.test.ts | 40 ++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index bba5206..1ffaec0 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -44,6 +44,14 @@ and dry-run mode before policy preflight. `InvocationRequest` includes `id`, optional schema hashes, `issued_at`, and `payload_hash`; the subject is the target agent id. +Core exposes `validateInvocationRequestAgainstSessionGrant` so SDKs, daemons, +and adapters can reject requests that mutate the signed request payload, swap +session ids, change requester/target/principal identity, request a different +capability, exceed granted scopes, use an expired grant, or target an audience +outside the grant. This keeps discovery, trust, and policy separate from actual +authority: invocation authority is the scoped `SessionGrant`, not the discovered +AgentCard or DHT/registry pointer. + The daemon validates the request body against the capability input schema before execution and validates generated outputs against the capability output schema before returning a successful result. The daemon then emits hash-only evidence diff --git a/packages/core/src/invocation.ts b/packages/core/src/invocation.ts index 3275be5..10814e9 100644 --- a/packages/core/src/invocation.ts +++ b/packages/core/src/invocation.ts @@ -1,7 +1,7 @@ import { signObject, verifyObject, type SignedObject } from './canonical-signer.js' import { hashProtocolPayload } from './protocol.js' import type { JSONSchema } from './capability.js' -import type { SessionGrantV2 } from './delegation.js' +import { isSessionGrantV2Expired, type SessionGrantV2 } from './delegation.js' export type InvocationStatus = | 'dry_run' @@ -89,6 +89,12 @@ export interface SchemaValidationResult { errors: string[] } +export interface InvocationGrantValidationInput { + request: InvocationRequest + sessionGrant: SessionGrantV2 + now?: Date +} + export function createInvocationRequest(input: InvocationRequestInput): InvocationRequest { const payload = { schema_version: 'fides.invocation.request.v1' as const, @@ -167,6 +173,57 @@ export function evaluateInvocationPreflight(input: InvocationPreflightInput): In } } +export function validateInvocationRequestAgainstSessionGrant( + input: InvocationGrantValidationInput +): SchemaValidationResult { + const { request, sessionGrant } = input + const errors: string[] = [] + const { payload_hash: _, ...requestPayload } = request + + if (request.schema_version !== 'fides.invocation.request.v1') { + errors.push('InvocationRequest.schema_version is invalid') + } + if (request.payload_hash !== hashProtocolPayload(requestPayload)) { + errors.push('InvocationRequest.payload_hash mismatch') + } + if (isSessionGrantV2Expired(sessionGrant, input.now)) { + errors.push('SessionGrant is expired') + } + if (request.session_id !== sessionGrant.session_id) { + errors.push('InvocationRequest.session_id does not match SessionGrant') + } + if (request.issuer !== sessionGrant.requester_agent_id) { + errors.push('InvocationRequest.issuer must match SessionGrant.requester_agent_id') + } + if (request.requester_agent_id !== sessionGrant.requester_agent_id) { + errors.push('InvocationRequest.requester_agent_id does not match SessionGrant') + } + if (request.target_agent_id !== sessionGrant.target_agent_id) { + errors.push('InvocationRequest.target_agent_id does not match SessionGrant') + } + if (request.subject !== sessionGrant.target_agent_id) { + errors.push('InvocationRequest.subject must match SessionGrant.target_agent_id') + } + if (request.principal_id !== sessionGrant.principal_id) { + errors.push('InvocationRequest.principal_id does not match SessionGrant') + } + if (request.capability !== sessionGrant.capability) { + errors.push('InvocationRequest.capability does not match SessionGrant') + } + if (!sessionGrant.audience.includes(request.target_agent_id)) { + errors.push('SessionGrant audience does not include InvocationRequest.target_agent_id') + } + + const grantedScopes = new Set(sessionGrant.scopes) + for (const scope of request.scopes) { + if (!grantedScopes.has(scope)) { + errors.push(`InvocationRequest.scope ${scope} is not granted by SessionGrant`) + } + } + + return { valid: errors.length === 0, errors } +} + export function validateJsonSchemaValue(schema: JSONSchema | undefined, value: unknown): SchemaValidationResult { if (!schema) return { valid: true, errors: [] } const errors: string[] = [] diff --git a/packages/core/test/invocation.test.ts b/packages/core/test/invocation.test.ts index 13481c0..c2d37e9 100644 --- a/packages/core/test/invocation.test.ts +++ b/packages/core/test/invocation.test.ts @@ -7,6 +7,7 @@ import { evaluateInvocationPreflight, signInvocationRequest, signInvocationResult, + validateInvocationRequestAgainstSessionGrant, validateJsonSchemaValue, verifySignedInvocationRequest, verifySignedInvocationResult, @@ -71,6 +72,45 @@ describe('invocation protocol objects', () => { expect(pending.status).toBe('approval_required') }) + it('validates invocation requests against scoped SessionGrants', async () => { + const grant = await signedGrant() + const request = createInvocationRequest({ + issuer: 'did:fides:requester', + sessionGrant: grant.payload, + input: { invoiceId: 'inv_123' }, + }) + + expect(validateInvocationRequestAgainstSessionGrant({ + request, + sessionGrant: grant.payload, + })).toEqual({ valid: true, errors: [] }) + }) + + it('rejects invocation requests that exceed or mutate the SessionGrant', async () => { + const grant = await signedGrant() + const request = createInvocationRequest({ + issuer: 'did:fides:requester', + sessionGrant: grant.payload, + input: { invoiceId: 'inv_123' }, + }) + + const result = validateInvocationRequestAgainstSessionGrant({ + request: { + ...request, + capability: 'payments.execute', + scopes: ['invoice:read', 'payments:execute'], + }, + sessionGrant: grant.payload, + }) + + expect(result.valid).toBe(false) + expect(result.errors).toEqual(expect.arrayContaining([ + 'InvocationRequest.payload_hash mismatch', + 'InvocationRequest.capability does not match SessionGrant', + 'InvocationRequest.scope payments:execute is not granted by SessionGrant', + ])) + }) + it('creates and verifies signed invocation results', async () => { const target = await createIdentityKeyPair() const result = createInvocationResult({ From 8ea26e80c5f30c0cfdde6602176590981e95eba7 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:29:43 +0300 Subject: [PATCH 125/282] feat(agentd): enforce invocation grant validation --- docs/protocol/delegation-and-sessions.md | 5 ++ services/agentd/src/index.ts | 17 +++--- services/agentd/test/routes.test.ts | 75 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index 1ffaec0..6aae622 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -52,6 +52,11 @@ outside the grant. This keeps discovery, trust, and policy separate from actual authority: invocation authority is the scoped `SessionGrant`, not the discovered AgentCard or DHT/registry pointer. +The root `/invoke` daemon endpoint applies this validator to caller-supplied +signed invocation requests before policy preflight. The signed request must also +match the submitted input hash and dry-run mode, so a valid requester signature +cannot widen scopes or replay authority over different invocation input. + The daemon validates the request body against the capability input schema before execution and validates generated outputs against the capability output schema before returning a successful result. The daemon then emits hash-only evidence diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 8f35da0..90a64f5 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -71,6 +71,7 @@ import { verifyDelegationTokenSignature, verifyDomainDid, evaluateInvocationPreflight, + validateInvocationRequestAgainstSessionGrant, validateJsonSchemaValue, verifyIncidentRecord, verifyRevocationRecord, @@ -1636,20 +1637,20 @@ app.post('/invoke', async (c) => { const signedPayload = candidateSignedRequest.payload const expectedInputHash = hashProtocolPayload(body.input ?? {}) const expectedDryRun = typeof body.dryRun === 'boolean' ? body.dryRun : false - const payloadMatchesSession = signedPayload.session_id === record.session.session_id && - signedPayload.requester_agent_id === record.session.requester_agent_id && - signedPayload.target_agent_id === record.session.target_agent_id && - signedPayload.principal_id === record.session.principal_id && - signedPayload.capability === record.session.capability && - signedPayload.input_hash === expectedInputHash && + const grantValidation = validateInvocationRequestAgainstSessionGrant({ + request: signedPayload, + sessionGrant: record.session, + }) + const payloadMatchesInput = signedPayload.input_hash === expectedInputHash && signedPayload.dry_run === expectedDryRun - if (!signedRequestVerified || !payloadMatchesSession) { + if (!signedRequestVerified || !grantValidation.valid || !payloadMatchesInput) { return c.json({ error: createErrorEnvelope('IDENTITY_INVALID_SIGNATURE', { message: 'Signed invocation request failed verification or does not match the session and input', details: { signedRequestVerified, - payloadMatchesSession, + grantValidation, + payloadMatchesInput, sessionId, request_id: signedPayload.id, }, diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 1e5cda7..8e231c7 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -49,6 +49,7 @@ import { createIncidentRecord, createInvocationRequest, createRevocationRecord, + hashProtocolPayload, signDelegationToken, signIncidentRecord, signInvocationRequest, @@ -985,6 +986,80 @@ describe('Agentd Service Routes', () => { }) }) + it('rejects signed invocation requests that exceed SessionGrant scopes', async () => { + const requester = await createIdentityKeyPair() + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Scoped Invoice Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ + id: 'invoice.scoped_reconcile', + riskLevel: 'medium', + requiredScopes: ['invoice:read'], + }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const session = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: requester.did, + agentId: identity.did, + capability: 'invoice.scoped_reconcile', + requestedScopes: ['invoice:read'], + }), + }) + expect(session.status).toBe(201) + const sessionData = await session.json() + const input = { invoiceId: 'inv_scoped' } + const request = createInvocationRequest({ + issuer: requester.did, + sessionGrant: sessionData.session, + input, + }) + const elevatedPayload = { + ...request, + scopes: ['invoice:read', 'payments:execute'], + } + const { payload_hash: _oldPayloadHash, ...payloadForHash } = elevatedPayload + const signedRequest = await signInvocationRequest({ + ...elevatedPayload, + payload_hash: hashProtocolPayload(payloadForHash), + }, requester.privateKey, requester.did) + + const rejected = await app.request('/invoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionData.session.session_id, + input, + signedRequest, + }), + }) + expect(rejected.status).toBe(401) + const rejectedData = await rejected.json() + expect(rejectedData.authorityGranted).toBe(false) + expect(rejectedData.error.code).toBe('IDENTITY_INVALID_SIGNATURE') + expect(rejectedData.error.details.grantValidation.errors).toContain( + 'InvocationRequest.scope payments:execute is not granted by SessionGrant', + ) + }) + it('rejects invocation inputs and outputs that do not satisfy capability schemas', async () => { const identityResponse = await app.request('/identities', { method: 'POST', From 46511ca64b1fc1b640c454d8cb5ff5227b9ad59b Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:31:26 +0300 Subject: [PATCH 126/282] fix(invocation): require issuer-bound proofs --- docs/protocol/delegation-and-sessions.md | 4 +- packages/core/src/invocation.ts | 8 +++ packages/core/test/invocation.test.ts | 20 +++++++ services/agentd/src/index.ts | 4 +- services/agentd/test/routes.test.ts | 66 ++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 3 deletions(-) diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index 6aae622..7141d09 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -55,7 +55,9 @@ AgentCard or DHT/registry pointer. The root `/invoke` daemon endpoint applies this validator to caller-supplied signed invocation requests before policy preflight. The signed request must also match the submitted input hash and dry-run mode, so a valid requester signature -cannot widen scopes or replay authority over different invocation input. +cannot widen scopes or replay authority over different invocation input. The +proof verification method must match the request `issuer`; a signature from a +different DID over an otherwise valid requester payload is rejected. The daemon validates the request body against the capability input schema before execution and validates generated outputs against the capability output schema diff --git a/packages/core/src/invocation.ts b/packages/core/src/invocation.ts index 10814e9..618c0c8 100644 --- a/packages/core/src/invocation.ts +++ b/packages/core/src/invocation.ts @@ -312,6 +312,10 @@ export function verifySignedInvocationRequest(signed: SignedInvocationRequest): return verifyObject(signed) } +export async function verifySignedInvocationRequestIssuer(signed: SignedInvocationRequest): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedInvocationRequest(signed) +} + export function signInvocationResult( result: InvocationResult, privateKey: Uint8Array, @@ -323,3 +327,7 @@ export function signInvocationResult( export function verifySignedInvocationResult(signed: SignedInvocationResult): Promise { return verifyObject(signed) } + +export async function verifySignedInvocationResultIssuer(signed: SignedInvocationResult): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedInvocationResult(signed) +} diff --git a/packages/core/test/invocation.test.ts b/packages/core/test/invocation.test.ts index c2d37e9..41d4a12 100644 --- a/packages/core/test/invocation.test.ts +++ b/packages/core/test/invocation.test.ts @@ -9,7 +9,9 @@ import { signInvocationResult, validateInvocationRequestAgainstSessionGrant, validateJsonSchemaValue, + verifySignedInvocationRequestIssuer, verifySignedInvocationRequest, + verifySignedInvocationResultIssuer, verifySignedInvocationResult, } from '../src/invocation.js' @@ -49,6 +51,23 @@ describe('invocation protocol objects', () => { const signed = await signInvocationRequest(request, requester.privateKey, requester.did) expect(await verifySignedInvocationRequest(signed)).toBe(true) + expect(await verifySignedInvocationRequestIssuer(signed)).toBe(true) + }) + + it('rejects invocation request proofs whose verification method is not the issuer', async () => { + const requester = await createIdentityKeyPair() + const attacker = await createIdentityKeyPair() + const grant = await signedGrant() + const request = createInvocationRequest({ + issuer: requester.did, + sessionGrant: grant.payload, + input: { invoiceId: 'inv_123' }, + }) + + const signed = await signInvocationRequest(request, attacker.privateKey, attacker.did) + + expect(await verifySignedInvocationRequest(signed)).toBe(true) + expect(await verifySignedInvocationRequestIssuer(signed)).toBe(false) }) it('preflights denied and approval-required policy decisions without execution', async () => { @@ -127,6 +146,7 @@ describe('invocation protocol objects', () => { expect(result.payload_hash).toMatch(/^sha256:/) const signed = await signInvocationResult(result, target.privateKey, target.did) expect(await verifySignedInvocationResult(signed)).toBe(true) + expect(await verifySignedInvocationResultIssuer(signed)).toBe(true) }) it('validates invocation inputs and outputs against a JSON Schema subset', () => { diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 90a64f5..1e2087d 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -60,7 +60,7 @@ import { signDHTPointerRecord, signRegistryIndexRecord, signRegistryPeerRecord, - verifySignedInvocationRequest, + verifySignedInvocationRequestIssuer, signInvocationResult, validateAgentCard, verifyDHTPointerRecord, @@ -1633,7 +1633,7 @@ app.post('/invoke', async (c) => { const candidateSignedRequest = body.signedRequest signedRequest = candidateSignedRequest - signedRequestVerified = await verifySignedInvocationRequest(candidateSignedRequest) + signedRequestVerified = await verifySignedInvocationRequestIssuer(candidateSignedRequest) const signedPayload = candidateSignedRequest.payload const expectedInputHash = hashProtocolPayload(body.input ?? {}) const expectedDryRun = typeof body.dryRun === 'boolean' ? body.dryRun : false diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 8e231c7..b195344 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1060,6 +1060,72 @@ describe('Agentd Service Routes', () => { ) }) + it('rejects signed invocation requests whose proof is not bound to the issuer', async () => { + const requester = await createIdentityKeyPair() + const attacker = await createIdentityKeyPair() + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Issuer Bound Invoice Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ + id: 'invoice.issuer_bound_reconcile', + riskLevel: 'medium', + requiredScopes: ['invoice:read'], + }], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const session = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: requester.did, + agentId: identity.did, + capability: 'invoice.issuer_bound_reconcile', + requestedScopes: ['invoice:read'], + }), + }) + expect(session.status).toBe(201) + const sessionData = await session.json() + const input = { invoiceId: 'inv_issuer_bound' } + const request = createInvocationRequest({ + issuer: requester.did, + sessionGrant: sessionData.session, + input, + }) + const signedRequest = await signInvocationRequest(request, attacker.privateKey, attacker.did) + + const rejected = await app.request('/invoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionData.session.session_id, + input, + signedRequest, + }), + }) + expect(rejected.status).toBe(401) + const rejectedData = await rejected.json() + expect(rejectedData.authorityGranted).toBe(false) + expect(rejectedData.error.code).toBe('IDENTITY_INVALID_SIGNATURE') + expect(rejectedData.error.details.signedRequestVerified).toBe(false) + expect(rejectedData.error.details.grantValidation.valid).toBe(true) + }) + it('rejects invocation inputs and outputs that do not satisfy capability schemas', async () => { const identityResponse = await app.request('/identities', { method: 'POST', From b14e046d3d6ad18cefa4e1aa3b97fdc3c6c428ef Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 13:32:48 +0300 Subject: [PATCH 127/282] fix(delegation): add issuer-bound session grant verification --- docs/protocol/delegation-and-sessions.md | 7 ++++++ packages/core/src/delegation.ts | 4 ++++ packages/core/test/session-grant-v2.test.ts | 24 +++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index 7141d09..c94b9bf 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -34,6 +34,13 @@ binds to the same target as the session authority. Replay protection is required through nonce tracking. +Signed `SessionGrant` verification has two levels. `verifySignedSessionGrantV2` +checks the canonical Ed25519 proof. `verifySignedSessionGrantV2Issuer` also +requires `proof.verificationMethod` to equal the grant `issuer`, which is the +authority-safe check for session acceptance paths. A valid signature from a +different DID over an otherwise valid grant payload is not enough to establish +session authority. + ## Invocation Binding An invocation must bind to a scoped `SessionGrant`. The root local daemon can diff --git a/packages/core/src/delegation.ts b/packages/core/src/delegation.ts index a482857..d69661f 100644 --- a/packages/core/src/delegation.ts +++ b/packages/core/src/delegation.ts @@ -221,6 +221,10 @@ export function verifySignedSessionGrantV2(signed: SignedSessionGrantV2): Promis return verifyObject(signed) } +export async function verifySignedSessionGrantV2Issuer(signed: SignedSessionGrantV2): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedSessionGrantV2(signed) +} + export interface RevokedSession extends SessionGrant { revoked: boolean revokedAt: string diff --git a/packages/core/test/session-grant-v2.test.ts b/packages/core/test/session-grant-v2.test.ts index f5799b4..23235eb 100644 --- a/packages/core/test/session-grant-v2.test.ts +++ b/packages/core/test/session-grant-v2.test.ts @@ -6,6 +6,7 @@ import { signSessionGrantV2, validateSessionGrantV2, verifySignedSessionGrantV2, + verifySignedSessionGrantV2Issuer, } from '../src/delegation.js' describe('SessionGrant v2', () => { @@ -62,6 +63,29 @@ describe('SessionGrant v2', () => { const signed = await signSessionGrantV2(grant, issuer.privateKey, issuer.did) expect(await verifySignedSessionGrantV2(signed)).toBe(true) + expect(await verifySignedSessionGrantV2Issuer(signed)).toBe(true) + }) + + it('rejects session grant proofs whose verification method is not the issuer', async () => { + const issuer = await createIdentityKeyPair() + const attacker = await createIdentityKeyPair() + const grant = createSessionGrantV2({ + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:target', + principalId: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['invoice:read'], + constraints: {}, + policyHash: 'sha256:policy', + trustResultHash: 'sha256:trust', + issuer: issuer.did, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }) + + const signed = await signSessionGrantV2(grant, attacker.privateKey, attacker.did) + + expect(await verifySignedSessionGrantV2(signed)).toBe(true) + expect(await verifySignedSessionGrantV2Issuer(signed)).toBe(false) }) it('rejects grants whose shared object ids or subjects drift from session binding', async () => { From 29fa335f777c732f2d16e896f6baf8e5f9fbfc36 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:32:19 +0300 Subject: [PATCH 128/282] fix(approval): add issuer-bound authority verification --- docs/protocol/approvals.md | 7 ++++++ docs/protocol/kill-switch.md | 6 +++++ packages/core/src/approval.ts | 12 ++++++++++ packages/core/test/approval.test.ts | 37 ++++++++++++++++++++++++++--- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/protocol/approvals.md b/docs/protocol/approvals.md index eb468ff..c95c50d 100644 --- a/docs/protocol/approvals.md +++ b/docs/protocol/approvals.md @@ -21,6 +21,13 @@ constraints, evidence refs, timestamps, and canonical `payload_hash`. The decision issuer is the approver and the subject is the approval request id. Approval decisions are signed by the approver and may include constraints. +Approval verification has two levels. `verifySignedApprovalRequest` and +`verifySignedApprovalDecision` check canonical Ed25519 proofs. +`verifySignedApprovalRequestIssuer` and +`verifySignedApprovalDecisionIssuer` additionally require +`proof.verificationMethod` to equal the payload `issuer`, which is the +authority-safe check for approval workflows. + ## Evidence The local root daemon appends hash-only evidence for approval lifecycle diff --git a/docs/protocol/kill-switch.md b/docs/protocol/kill-switch.md index 07c033f..62089bb 100644 --- a/docs/protocol/kill-switch.md +++ b/docs/protocol/kill-switch.md @@ -20,6 +20,12 @@ Current implementation anchors: Kill switch checks should run before policy grants or invocation execution. +Signed kill switch verification has two levels. `verifySignedKillSwitchRule` +checks the canonical Ed25519 proof. `verifySignedKillSwitchRuleIssuer` +additionally requires `proof.verificationMethod` to equal the rule `issuer`. +Authority paths should use the issuer-bound verifier so a valid signature from +another DID cannot activate or disable emergency controls for the stated issuer. + ## Evidence The local root daemon appends a hash-only `kill_switch.triggered` event when a diff --git a/packages/core/src/approval.ts b/packages/core/src/approval.ts index 314c2c1..01658e3 100644 --- a/packages/core/src/approval.ts +++ b/packages/core/src/approval.ts @@ -167,6 +167,10 @@ export function verifySignedApprovalRequest(signed: SignedApprovalRequest): Prom return verifyObject(signed) } +export async function verifySignedApprovalRequestIssuer(signed: SignedApprovalRequest): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedApprovalRequest(signed) +} + export function signApprovalDecision( decision: ApprovalDecision, privateKey: Uint8Array, @@ -179,6 +183,10 @@ export function verifySignedApprovalDecision(signed: SignedApprovalDecision): Pr return verifyObject(signed) } +export async function verifySignedApprovalDecisionIssuer(signed: SignedApprovalDecision): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedApprovalDecision(signed) +} + export function signKillSwitchRule( rule: KillSwitchRule, privateKey: Uint8Array, @@ -190,3 +198,7 @@ export function signKillSwitchRule( export function verifySignedKillSwitchRule(signed: SignedKillSwitchRule): Promise { return verifyObject(signed) } + +export async function verifySignedKillSwitchRuleIssuer(signed: SignedKillSwitchRule): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedKillSwitchRule(signed) +} diff --git a/packages/core/test/approval.test.ts b/packages/core/test/approval.test.ts index f2f962f..8e6326e 100644 --- a/packages/core/test/approval.test.ts +++ b/packages/core/test/approval.test.ts @@ -9,15 +9,19 @@ import { signApprovalRequest, signKillSwitchRule, verifySignedApprovalDecision, + verifySignedApprovalDecisionIssuer, verifySignedApprovalRequest, + verifySignedApprovalRequestIssuer, verifySignedKillSwitchRule, + verifySignedKillSwitchRuleIssuer, } from '../src/approval.js' describe('approval and kill switch primitives', () => { it('creates and verifies signed approval requests and decisions', async () => { const approver = await createIdentityKeyPair() + const requester = await createIdentityKeyPair() const request = createApprovalRequest({ - requesterAgentId: 'did:fides:requester', + requesterAgentId: requester.did, targetAgentId: 'did:fides:target', principalId: 'did:fides:principal', capability: 'payments.prepare', @@ -27,11 +31,12 @@ describe('approval and kill switch primitives', () => { evidenceRefs: ['evt_1'], }) - const signedRequest = await signApprovalRequest(request, approver.privateKey, approver.did) - expect(request.issuer).toBe('did:fides:requester') + const signedRequest = await signApprovalRequest(request, requester.privateKey, requester.did) + expect(request.issuer).toBe(requester.did) expect(request.subject).toBe('did:fides:target') expect(request.payload_hash).toMatch(/^sha256:/) expect(await verifySignedApprovalRequest(signedRequest)).toBe(true) + expect(await verifySignedApprovalRequestIssuer(signedRequest)).toBe(true) const decision = createApprovalDecision({ approvalRequestId: request.id, @@ -46,6 +51,7 @@ describe('approval and kill switch primitives', () => { expect(decision.subject).toBe(request.id) expect(decision.payload_hash).toMatch(/^sha256:/) expect(await verifySignedApprovalDecision(signedDecision)).toBe(true) + expect(await verifySignedApprovalDecisionIssuer(signedDecision)).toBe(true) }) it('creates active kill switch rules that can target risky capability classes', async () => { @@ -62,5 +68,30 @@ describe('approval and kill switch primitives', () => { const signedRule = await signKillSwitchRule(rule, issuer.privateKey, issuer.did) expect(await verifySignedKillSwitchRule(signedRule)).toBe(true) + expect(await verifySignedKillSwitchRuleIssuer(signedRule)).toBe(true) + }) + + it('rejects authority proofs whose verification method is not the payload issuer', async () => { + const issuer = await createIdentityKeyPair() + const attacker = await createIdentityKeyPair() + const decision = createApprovalDecision({ + approvalRequestId: 'approval_1', + approverId: issuer.did, + decision: 'approved', + }) + const killSwitch = createKillSwitchRule({ + issuer: issuer.did, + targetType: 'capability', + target: 'payments.execute', + reason: 'Freeze payment execution', + }) + + const signedDecision = await signApprovalDecision(decision, attacker.privateKey, attacker.did) + const signedRule = await signKillSwitchRule(killSwitch, attacker.privateKey, attacker.did) + + expect(await verifySignedApprovalDecision(signedDecision)).toBe(true) + expect(await verifySignedApprovalDecisionIssuer(signedDecision)).toBe(false) + expect(await verifySignedKillSwitchRule(signedRule)).toBe(true) + expect(await verifySignedKillSwitchRuleIssuer(signedRule)).toBe(false) }) }) From cd2ccede801d077a6b860784bcd3f4ee2ece439c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:34:08 +0300 Subject: [PATCH 129/282] fix(revocation): add issuer-bound record verification --- docs/protocol/incidents.md | 6 ++++ docs/protocol/revocation.md | 6 ++++ packages/core/src/revocation.ts | 8 +++++ .../core/test/revocation-incident-v2.test.ts | 30 +++++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/docs/protocol/incidents.md b/docs/protocol/incidents.md index 44b543d..47749eb 100644 --- a/docs/protocol/incidents.md +++ b/docs/protocol/incidents.md @@ -24,6 +24,12 @@ Incidents carry severity, evidence refs, resolution status, trust penalty, and r `subject` is the affected agent id. Resolution changes recompute the payload hash so trust and policy can cite the exact incident state they evaluated. +Signed incident verification has two levels. `verifySignedIncidentRecordV2` +checks the canonical Ed25519 proof. `verifySignedIncidentRecordV2Issuer` +additionally requires `proof.verificationMethod` to equal the reporter/issuer. +Trust and policy ingestion paths should use the issuer-bound verifier before an +incident can affect trust, reputation, or authorization decisions. + ## Evidence The local root daemon appends a hash-only `incident.reported` event when an diff --git a/docs/protocol/revocation.md b/docs/protocol/revocation.md index a5e56eb..c2fed00 100644 --- a/docs/protocol/revocation.md +++ b/docs/protocol/revocation.md @@ -23,6 +23,12 @@ Revocation must be checked before trust, policy, session, and invocation flows c `subject`, timestamps, and `payload_hash`. The subject is the revoked target id, so policy and evidence can bind to the exact authority surface being disabled. +Signed revocation verification has two levels. `verifySignedRevocationRecordV2` +checks the canonical Ed25519 proof. `verifySignedRevocationRecordV2Issuer` +additionally requires `proof.verificationMethod` to equal the record `issuer`. +Authority paths should use the issuer-bound verifier so another DID cannot sign +an otherwise valid revocation payload on behalf of the stated issuer. + ## Evidence The local root daemon appends a hash-only `revocation.recorded` event when a diff --git a/packages/core/src/revocation.ts b/packages/core/src/revocation.ts index db64d41..51cba0f 100644 --- a/packages/core/src/revocation.ts +++ b/packages/core/src/revocation.ts @@ -217,6 +217,10 @@ export function verifySignedRevocationRecordV2(signed: SignedRevocationRecordV2) return verifyObject(signed) } +export async function verifySignedRevocationRecordV2Issuer(signed: SignedRevocationRecordV2): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedRevocationRecordV2(signed) +} + export function signIncidentRecordV2( record: IncidentRecordV2, privateKey: Uint8Array, @@ -229,6 +233,10 @@ export function verifySignedIncidentRecordV2(signed: SignedIncidentRecordV2): Pr return verifyObject(signed) } +export async function verifySignedIncidentRecordV2Issuer(signed: SignedIncidentRecordV2): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedIncidentRecordV2(signed) +} + /** * Create a revocation record (unsigned). */ diff --git a/packages/core/test/revocation-incident-v2.test.ts b/packages/core/test/revocation-incident-v2.test.ts index a39771c..954e987 100644 --- a/packages/core/test/revocation-incident-v2.test.ts +++ b/packages/core/test/revocation-incident-v2.test.ts @@ -7,7 +7,9 @@ import { signIncidentRecordV2, signRevocationRecordV2, verifySignedIncidentRecordV2, + verifySignedIncidentRecordV2Issuer, verifySignedRevocationRecordV2, + verifySignedRevocationRecordV2Issuer, } from '../src/revocation.js' describe('revocation and incident v2 records', () => { @@ -34,6 +36,7 @@ describe('revocation and incident v2 records', () => { const signed = await signRevocationRecordV2(record, issuer.privateKey, issuer.did) expect(await verifySignedRevocationRecordV2(signed)).toBe(true) + expect(await verifySignedRevocationRecordV2Issuer(signed)).toBe(true) }) it('creates and resolves signed incident records with trust impact metadata', async () => { @@ -63,10 +66,37 @@ describe('revocation and incident v2 records', () => { const signed = await signIncidentRecordV2(incident, reporter.privateKey, reporter.did) expect(await verifySignedIncidentRecordV2(signed)).toBe(true) + expect(await verifySignedIncidentRecordV2Issuer(signed)).toBe(true) const resolved = resolveIncidentRecordV2(incident, 'false_positive') expect(resolved.resolution_status).toBe('false_positive') expect(resolved.resolved_at).toBeDefined() expect(resolved.payload_hash).not.toBe(incident.payload_hash) }) + + it('rejects revocation and incident proofs whose verification method is not the issuer', async () => { + const issuer = await createIdentityKeyPair() + const attacker = await createIdentityKeyPair() + const revocation = createRevocationRecordV2({ + issuer: issuer.did, + targetType: 'agent', + targetId: 'did:fides:agent', + reason: 'Compromised agent identity', + }) + const incident = createIncidentRecordV2({ + reporter: issuer.did, + targetAgentId: 'did:fides:agent', + severity: 'critical', + category: 'unauthorized_action', + description: 'Agent attempted an unauthorized action.', + }) + + const signedRevocation = await signRevocationRecordV2(revocation, attacker.privateKey, attacker.did) + const signedIncident = await signIncidentRecordV2(incident, attacker.privateKey, attacker.did) + + expect(await verifySignedRevocationRecordV2(signedRevocation)).toBe(true) + expect(await verifySignedRevocationRecordV2Issuer(signedRevocation)).toBe(false) + expect(await verifySignedIncidentRecordV2(signedIncident)).toBe(true) + expect(await verifySignedIncidentRecordV2Issuer(signedIncident)).toBe(false) + }) }) From 941b65d2592d03db536c476517ef663a70e56a52 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:35:38 +0300 Subject: [PATCH 130/282] fix(registry): add issuer-bound record verification --- docs/protocol/registry-federation.md | 5 ++++ packages/core/src/registry.ts | 8 +++++++ packages/core/test/registry.test.ts | 35 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/protocol/registry-federation.md b/docs/protocol/registry-federation.md index 4b16b50..0a35ba3 100644 --- a/docs/protocol/registry-federation.md +++ b/docs/protocol/registry-federation.md @@ -32,10 +32,15 @@ record includes: Search and discovery verify signed local registry index records before returning them. A valid registry index record still does not grant invocation authority. +Authority-safe ingestion should use `verifySignedRegistryIndexRecordIssuer`, +which verifies both the canonical Ed25519 proof and that +`proof.verificationMethod` equals the record `issuer`. Federation peering records are adapter-ready and should not imply trust. Peers provide discovery and propagation surfaces; FIDES still verifies identity, signatures, revocations, incidents, trust, and policy. +Authority-safe peering ingestion should use `verifySignedRegistryPeerRecordIssuer` +for the same issuer-bound proof check. `LocalFederationDiscoveryProvider` is the local mock federation implementation. It accepts signed `RegistryPeerRecord` values plus peer discovery providers, diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 1f0562c..abce234 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -126,6 +126,10 @@ export function verifySignedRegistryIndexRecord(signed: SignedRegistryIndexRecor return verifyObject(signed) } +export async function verifySignedRegistryIndexRecordIssuer(signed: SignedRegistryIndexRecord): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedRegistryIndexRecord(signed) +} + export function signRegistryPeerRecord( record: RegistryPeerRecord, privateKey: Uint8Array, @@ -137,3 +141,7 @@ export function signRegistryPeerRecord( export function verifySignedRegistryPeerRecord(signed: SignedRegistryPeerRecord): Promise { return verifyObject(signed) } + +export async function verifySignedRegistryPeerRecordIssuer(signed: SignedRegistryPeerRecord): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedRegistryPeerRecord(signed) +} diff --git a/packages/core/test/registry.test.ts b/packages/core/test/registry.test.ts index 8b9f239..5fd4e87 100644 --- a/packages/core/test/registry.test.ts +++ b/packages/core/test/registry.test.ts @@ -8,7 +8,9 @@ import { signRegistryIndexRecord, signRegistryPeerRecord, verifySignedRegistryIndexRecord, + verifySignedRegistryIndexRecordIssuer, verifySignedRegistryPeerRecord, + verifySignedRegistryPeerRecordIssuer, } from '../src/registry.js' describe('registry and federation records', () => { @@ -36,6 +38,7 @@ describe('registry and federation records', () => { const signed = await signRegistryIndexRecord(record, issuer.privateKey, issuer.did) expect(await verifySignedRegistryIndexRecord(signed)).toBe(true) + expect(await verifySignedRegistryIndexRecordIssuer(signed)).toBe(true) }) it('creates and verifies signed federation peer records', async () => { @@ -59,6 +62,38 @@ describe('registry and federation records', () => { const signed = await signRegistryPeerRecord(record, issuer.privateKey, issuer.did) expect(await verifySignedRegistryPeerRecord(signed)).toBe(true) + expect(await verifySignedRegistryPeerRecordIssuer(signed)).toBe(true) + }) + + it('rejects registry proofs whose verification method is not the issuer', async () => { + const issuer = await createIdentityKeyPair() + const attacker = await createIdentityKeyPair() + const index = createRegistryIndexRecord({ + issuer: issuer.did, + mode: 'public', + agentCardId: 'card_123', + agentId: 'did:fides:agent', + capabilityIds: ['invoice.reconcile'], + agentCardHash: 'sha256:card', + registryUrl: 'https://registry.example', + supportedVersions: ['fides.v2.0'], + }) + const peer = createRegistryPeerRecord({ + issuer: issuer.did, + peerId: 'peer_1', + registryUrl: 'https://peer.example', + peeringMode: 'federated', + supportedVersions: ['fides.v2.0'], + capabilities: ['registry_search'], + }) + + const signedIndex = await signRegistryIndexRecord(index, attacker.privateKey, attacker.did) + const signedPeer = await signRegistryPeerRecord(peer, attacker.privateKey, attacker.did) + + expect(await verifySignedRegistryIndexRecord(signedIndex)).toBe(true) + expect(await verifySignedRegistryIndexRecordIssuer(signedIndex)).toBe(false) + expect(await verifySignedRegistryPeerRecord(signedPeer)).toBe(true) + expect(await verifySignedRegistryPeerRecordIssuer(signedPeer)).toBe(false) }) it('detects expired registry and federation records', async () => { From 95b52c653d0f0f0864a255a26bda4bf74c0c4359 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:37:32 +0300 Subject: [PATCH 131/282] fix(dht): bind pointer signatures to publisher --- docs/protocol/dht-discovery.md | 4 ++++ packages/core/src/dht.ts | 6 +++++- packages/core/test/dht.test.ts | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/protocol/dht-discovery.md b/docs/protocol/dht-discovery.md index 5864c10..8b4de4b 100644 --- a/docs/protocol/dht-discovery.md +++ b/docs/protocol/dht-discovery.md @@ -11,6 +11,10 @@ Current implementation anchors: ## Pointer Record DHT records point from capability hash to AgentCard location and hash. They include agent ID, publisher ID, expiry, sequence, and signature. +Pointer signature verification is issuer-bound: the verification method must +match `publisher_id`. A valid signature from any other DID over the pointer +payload is rejected, because DHT only provides pointers and must not let a third +party speak for the publisher named in the record. The local daemon can publish a signed DHT pointer from an already registered local AgentCard without the caller supplying a URL. In that case it uses a diff --git a/packages/core/src/dht.ts b/packages/core/src/dht.ts index 07eaf68..468b97b 100644 --- a/packages/core/src/dht.ts +++ b/packages/core/src/dht.ts @@ -100,12 +100,16 @@ export async function verifyDHTPointerRecord( if (!record.signature) { errors.push('DHT pointer signature is required') } else { + const verificationMethod = options.verificationMethod ?? record.publisher_id + if (verificationMethod !== record.publisher_id) { + errors.push('DHT pointer verificationMethod must match publisher_id') + } const signatureValid = await verifyObject({ payload: { ...record, signature: '' }, proof: { type: 'Ed25519Signature2024', created: record.expires_at, - verificationMethod: options.verificationMethod ?? record.publisher_id, + verificationMethod, proofPurpose: 'assertionMethod', canonicalizationAlgorithm: 'https://fides.dev/canonical-json/v1', proofValue: record.signature, diff --git a/packages/core/test/dht.test.ts b/packages/core/test/dht.test.ts index 545120b..3d70523 100644 --- a/packages/core/test/dht.test.ts +++ b/packages/core/test/dht.test.ts @@ -57,6 +57,20 @@ describe('DHT pointer records', () => { ])) }) + it('rejects pointer signatures whose verification method does not match publisher_id', async () => { + const { card, record } = await fixture() + const attacker = await createAgentIdentity() + const signed = await signDHTPointerRecord(record, attacker.privateKey, attacker.identity.did) + + const result = await verifyDHTPointerRecord(signed, { + card, + verificationMethod: attacker.identity.did, + }) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('DHT pointer verificationMethod must match publisher_id') + }) + it('rejects expired pointers', async () => { const { card, record } = await fixture() const result = await verifyDHTPointerRecord({ From cefac27d55f11e1ac8fc0565ce8943941115e0ae Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:39:23 +0300 Subject: [PATCH 132/282] fix(cards): add identity-bound agent card verification --- docs/protocol/agent-card.md | 6 ++++++ packages/core/src/agent-card.ts | 4 ++++ packages/core/test/agent-card.test.ts | 25 ++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/protocol/agent-card.md b/docs/protocol/agent-card.md index 288dff3..c6080c4 100644 --- a/docs/protocol/agent-card.md +++ b/docs/protocol/agent-card.md @@ -39,6 +39,12 @@ agent trust anchors, runtime attestations, revocation metadata, public keys, and transport metadata into the stored card before signing. This keeps the unsigned inspection endpoint and signed payload aligned. +AgentCard verification has two levels. `verifySignedAgentCard` validates the +card shape and canonical Ed25519 proof. `verifySignedAgentCardIdentity` also +requires `proof.verificationMethod` to equal `identity.did`, which is the +discovery-safe check for AgentCard ingestion. A valid signature from another DID +does not prove that the advertised agent identity published the card. + ## Rule Discovery may return AgentCards, but invocation requires trust evaluation, policy evaluation, and a scoped session grant. diff --git a/packages/core/src/agent-card.ts b/packages/core/src/agent-card.ts index 17a4c6a..dbb3aa3 100644 --- a/packages/core/src/agent-card.ts +++ b/packages/core/src/agent-card.ts @@ -108,6 +108,10 @@ export async function verifySignedAgentCard(card: SignedAgentCard): Promise { + return card.proof.verificationMethod === card.payload.identity.did && await verifySignedAgentCard(card) +} + /** * Validate that an AgentCard has all required fields and sensible values. */ diff --git a/packages/core/test/agent-card.test.ts b/packages/core/test/agent-card.test.ts index b3740e5..3b4aace 100644 --- a/packages/core/test/agent-card.test.ts +++ b/packages/core/test/agent-card.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest' -import { normalizeAgentCard, signAgentCard, validateAgentCard, verifySignedAgentCard } from '../src/agent-card.js' +import { + normalizeAgentCard, + signAgentCard, + validateAgentCard, + verifySignedAgentCard, + verifySignedAgentCardIdentity, +} from '../src/agent-card.js' import type { AgentCard } from '../src/agent-card.js' import { createAgentIdentity } from '../src/identity.js' @@ -133,9 +139,26 @@ describe('AgentCard', () => { auth: 'delegation', }) expect(await verifySignedAgentCard(signed)).toBe(true) + expect(await verifySignedAgentCardIdentity(signed)).toBe(true) signed.payload.capabilities[0].name = 'Tampered' expect(await verifySignedAgentCard(signed)).toBe(false) }) + + it('should reject AgentCard proofs whose verification method is not the agent identity', async () => { + const issued = await createAgentIdentity() + const attacker = await createAgentIdentity() + const card: AgentCard = { + ...validCard, + id: issued.identity.did, + identity: issued.identity, + expiresAt: '2999-01-01T00:00:00.000Z', + } + + const signed = await signAgentCard(card, attacker.privateKey, attacker.identity.did) + + expect(await verifySignedAgentCard(signed)).toBe(true) + expect(await verifySignedAgentCardIdentity(signed)).toBe(false) + }) }) }) From 85062362e77bd87d3ce2324df6d130d1b5b20335 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:41:02 +0300 Subject: [PATCH 133/282] fix(discovery): require identity-bound dht agent cards --- docs/protocol/dht-discovery.md | 4 ++++ packages/discovery/src/dht-provider.ts | 4 ++++ packages/discovery/test/dht-provider.test.ts | 11 +++++++++++ 3 files changed, 19 insertions(+) diff --git a/docs/protocol/dht-discovery.md b/docs/protocol/dht-discovery.md index 8b4de4b..f4503f5 100644 --- a/docs/protocol/dht-discovery.md +++ b/docs/protocol/dht-discovery.md @@ -43,4 +43,8 @@ mismatches, and locally revoked agent IDs are filtered out. A returned DHT candidate therefore only means "this signed pointer resolved to this AgentCard"; it still does not grant trust or authority. +`DHTDiscoveryProvider.register` accepts only identity-bound signed AgentCards. +The AgentCard proof verification method must match the advertised +`identity.did`, otherwise the card is not stored in the DHT simulator. + The in-memory DHT simulator is local mock infrastructure. A libp2p/Kademlia adapter should implement the same provider contract later. diff --git a/packages/discovery/src/dht-provider.ts b/packages/discovery/src/dht-provider.ts index 59f623d..639dbe3 100644 --- a/packages/discovery/src/dht-provider.ts +++ b/packages/discovery/src/dht-provider.ts @@ -2,6 +2,7 @@ import { cardSupportsCapability, createDiscoveryCandidate, hashCapability, + verifySignedAgentCardIdentity, verifyDHTPointerRecord, type AgentCard, type DHTPointerRecord, @@ -91,6 +92,9 @@ export class DHTDiscoveryProvider implements DiscoveryProvider { } async register(card: SignedAgentCard): Promise { + if (!await verifySignedAgentCardIdentity(card)) { + throw new Error('DHT registration requires an identity-bound signed AgentCard') + } const did = card.payload.id const agentCard = card.payload as AgentCard diff --git a/packages/discovery/test/dht-provider.test.ts b/packages/discovery/test/dht-provider.test.ts index 472ef66..c267db4 100644 --- a/packages/discovery/test/dht-provider.test.ts +++ b/packages/discovery/test/dht-provider.test.ts @@ -88,6 +88,17 @@ describe('DHTDiscoveryProvider', () => { expect(candidates).toEqual([]) }) + it('rejects AgentCards not signed by the advertised agent identity', async () => { + const { card } = await fixture() + const attacker = await createAgentIdentity() + const signedCard = await signAgentCard(card, attacker.privateKey, attacker.identity.did) + const provider = new DHTDiscoveryProvider() + + await expect(provider.register(signedCard)).rejects.toThrow( + 'DHT registration requires an identity-bound signed AgentCard', + ) + }) + it('rejects expired DHT pointers', async () => { const { signedCard, pointer } = await fixture() const provider = new DHTDiscoveryProvider() From d1bf398f35d074195536a5cd93fd89204723e672 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:42:53 +0300 Subject: [PATCH 134/282] fix(discovery): require identity-bound relay agent cards --- docs/protocol/relay-discovery.md | 6 +++ packages/discovery/src/relay-provider.ts | 5 ++- .../discovery/test/relay-provider.test.ts | 42 ++++++++++++------- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/docs/protocol/relay-discovery.md b/docs/protocol/relay-discovery.md index 5bc0119..6c9ed5a 100644 --- a/docs/protocol/relay-discovery.md +++ b/docs/protocol/relay-discovery.md @@ -27,6 +27,12 @@ AgentCards. When the card has been signed, the relay record includes: These fields let a caller resolve and verify the AgentCard after rendezvous. They do not make the relay a trust anchor. +`RelayDiscoveryProvider.register` accepts only identity-bound signed AgentCards. +The AgentCard proof verification method must match the advertised +`identity.did`; otherwise the provider refuses to publish the rendezvous +message. Relay can carry signed references, but it cannot make another DID speak +for the agent identity in the card. + ## Relay Must Not Provide - trust scores diff --git a/packages/discovery/src/relay-provider.ts b/packages/discovery/src/relay-provider.ts index 4a68878..8b22512 100644 --- a/packages/discovery/src/relay-provider.ts +++ b/packages/discovery/src/relay-provider.ts @@ -1,4 +1,4 @@ -import { validateAgentCard, type AgentCard, type SignedAgentCard } from '@fides/core' +import { validateAgentCard, verifySignedAgentCardIdentity, type AgentCard, type SignedAgentCard } from '@fides/core' import { DiscoveryProvider } from './provider.js' interface RelayMessage { @@ -50,6 +50,9 @@ export class RelayDiscoveryProvider implements DiscoveryProvider { } async register(card: SignedAgentCard): Promise { + if (!await verifySignedAgentCardIdentity(card)) { + throw new Error('Relay registration requires an identity-bound signed AgentCard') + } const did = card.payload.id const response = await fetch(`${this.baseUrl()}/v1/relay`, { method: 'POST', diff --git a/packages/discovery/test/relay-provider.test.ts b/packages/discovery/test/relay-provider.test.ts index 4a9b66d..7404476 100644 --- a/packages/discovery/test/relay-provider.test.ts +++ b/packages/discovery/test/relay-provider.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { RelayDiscoveryProvider } from '../src/relay-provider.js' -import type { AgentCard, SignedAgentCard } from '@fides/core' +import { createAgentIdentity, signAgentCard, type AgentCard } from '@fides/core' describe('RelayDiscoveryProvider', () => { const fetchMock = vi.fn() @@ -18,18 +18,6 @@ describe('RelayDiscoveryProvider', () => { createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', } - const signedCard: SignedAgentCard = { - payload: card, - proof: { - type: 'Ed25519Signature2024', - created: '2026-01-01T00:00:00.000Z', - verificationMethod: 'did:fides:relay-agent#key-1', - proofPurpose: 'assertionMethod', - canonicalizationAlgorithm: 'https://fides.dev/canonical-json/v1', - proofValue: 'aa', - }, - } - beforeEach(() => { vi.stubGlobal('fetch', fetchMock) fetchMock.mockReset() @@ -79,6 +67,13 @@ describe('RelayDiscoveryProvider', () => { }) it('registers signed agent cards through the relay service', async () => { + const agent = await createAgentIdentity() + const agentCard = { + ...card, + id: agent.identity.did, + identity: agent.identity, + } + const signedCard = await signAgentCard(agentCard, agent.privateKey, agent.identity.did) fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ accepted: true, relayId: 'relay-1' }), @@ -97,11 +92,11 @@ describe('RelayDiscoveryProvider', () => { expect.objectContaining({ method: 'POST', body: JSON.stringify({ - to: card.id, + to: agent.identity.did, from: 'did:fides:publisher', payload: { type: 'fides.agent_card', - card, + card: signedCard.payload, }, }), }) @@ -110,4 +105,21 @@ describe('RelayDiscoveryProvider', () => { expect((init.headers as Headers).get('Content-Type')).toBe('application/json') expect((init.headers as Headers).get('X-API-Key')).toBe('relay-key') }) + + it('rejects AgentCards not signed by the advertised agent identity', async () => { + const agent = await createAgentIdentity() + const attacker = await createAgentIdentity() + const agentCard = { + ...card, + id: agent.identity.did, + identity: agent.identity, + } + const signedCard = await signAgentCard(agentCard, attacker.privateKey, attacker.identity.did) + const provider = new RelayDiscoveryProvider({ relayUrl: 'http://relay.test' }) + + await expect(provider.register(signedCard)).rejects.toThrow( + 'Relay registration requires an identity-bound signed AgentCard', + ) + expect(fetchMock).not.toHaveBeenCalled() + }) }) From 17cedd17eb098e6a6c047bee8ba928fb27e1ffc0 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:45:23 +0300 Subject: [PATCH 135/282] fix(discovery): require identity-bound local registry cards --- docs/protocol/discovery.md | 7 +++ packages/discovery/src/local-provider.ts | 4 ++ packages/discovery/src/registry-provider.ts | 5 +- .../discovery/test/local-provider.test.ts | 62 +++++++++++++++++++ .../discovery/test/registry-provider.test.ts | 59 ++++++++++++++++++ 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 packages/discovery/test/local-provider.test.ts create mode 100644 packages/discovery/test/registry-provider.test.ts diff --git a/docs/protocol/discovery.md b/docs/protocol/discovery.md index 13fa702..dd97256 100644 --- a/docs/protocol/discovery.md +++ b/docs/protocol/discovery.md @@ -33,6 +33,13 @@ Current implementation anchors: FIDES discovery does not require every candidate to already expose an HTTP URL. An endpoint URL is transport metadata, not identity, trust, or authority. +All package-level discovery providers that accept `SignedAgentCard` registration +must require identity-bound AgentCard proofs. Local, registry, relay, and DHT +registration paths verify that the AgentCard proof verification method matches +the advertised `identity.did` before storing, publishing, or relaying the card. +Direct `registerCard` helpers remain local mock/test utilities and do not imply +the card is signed or trusted. + Current support: - Local discovery can resolve from daemon-held AgentCards without endpoint URLs. diff --git a/packages/discovery/src/local-provider.ts b/packages/discovery/src/local-provider.ts index 9b48b77..cb75c4f 100644 --- a/packages/discovery/src/local-provider.ts +++ b/packages/discovery/src/local-provider.ts @@ -1,6 +1,7 @@ import { cardSupportsCapability, createDiscoveryCandidate, + verifySignedAgentCardIdentity, type AgentCard, type DiscoveryCandidate, type DiscoveryQuery, @@ -51,6 +52,9 @@ export class LocalDiscoveryProvider implements DiscoveryProvider { } async register(card: SignedAgentCard): Promise { + if (!await verifySignedAgentCardIdentity(card)) { + throw new Error('Local registration requires an identity-bound signed AgentCard') + } const store = this.loadStore() const did = card.payload.id store.set(did, card.payload as AgentCard) diff --git a/packages/discovery/src/registry-provider.ts b/packages/discovery/src/registry-provider.ts index 4828a47..0fe2752 100644 --- a/packages/discovery/src/registry-provider.ts +++ b/packages/discovery/src/registry-provider.ts @@ -1,4 +1,4 @@ -import type { AgentCard, SignedAgentCard } from '@fides/core' +import { verifySignedAgentCardIdentity, type AgentCard, type SignedAgentCard } from '@fides/core' import { DiscoveryProvider } from './provider.js' /** @@ -25,6 +25,9 @@ export class RegistryDiscoveryProvider implements DiscoveryProvider { } async register(card: SignedAgentCard): Promise { + if (!await verifySignedAgentCardIdentity(card)) { + throw new Error('Registry registration requires an identity-bound signed AgentCard') + } const response = await fetch(`${this.options.baseUrl}/v1/cards`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/packages/discovery/test/local-provider.test.ts b/packages/discovery/test/local-provider.test.ts new file mode 100644 index 0000000..d1c93cc --- /dev/null +++ b/packages/discovery/test/local-provider.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createAgentIdentity, signAgentCard, type AgentCard } from '@fides/core' +import { LocalDiscoveryProvider } from '../src/local-provider.js' + +describe('LocalDiscoveryProvider', () => { + const tempDirs: string[] = [] + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + async function signedCard() { + const agent = await createAgentIdentity() + const card: AgentCard = { + id: agent.identity.did, + agent_id: agent.identity.did, + identity: agent.identity, + capabilities: [], + endpoints: [], + policies: [{ requiresRuntimeAttestation: false, requiresApproval: false }], + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + } + return { + agent, + card, + signed: await signAgentCard(card, agent.privateKey, agent.identity.did), + } + } + + function provider() { + const dir = mkdtempSync(join(tmpdir(), 'fides-local-provider-')) + tempDirs.push(dir) + return new LocalDiscoveryProvider({ storePath: join(dir, 'local-agents.json') }) + } + + it('registers identity-bound signed AgentCards in the local store', async () => { + const { card, signed } = await signedCard() + const local = provider() + + await expect(local.register(signed)).resolves.toBeUndefined() + + await expect(local.resolve(card.id)).resolves.toMatchObject({ id: card.id }) + }) + + it('rejects AgentCards not signed by the advertised agent identity', async () => { + const { card } = await signedCard() + const attacker = await createAgentIdentity() + const signed = await signAgentCard(card, attacker.privateKey, attacker.identity.did) + const local = provider() + + await expect(local.register(signed)).rejects.toThrow( + 'Local registration requires an identity-bound signed AgentCard', + ) + await expect(local.resolve(card.id)).resolves.toBeNull() + }) +}) diff --git a/packages/discovery/test/registry-provider.test.ts b/packages/discovery/test/registry-provider.test.ts new file mode 100644 index 0000000..cf7a692 --- /dev/null +++ b/packages/discovery/test/registry-provider.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentIdentity, signAgentCard, type AgentCard } from '@fides/core' +import { RegistryDiscoveryProvider } from '../src/registry-provider.js' + +describe('RegistryDiscoveryProvider', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + }) + + async function signedCard() { + const agent = await createAgentIdentity() + const card: AgentCard = { + id: agent.identity.did, + agent_id: agent.identity.did, + identity: agent.identity, + capabilities: [], + endpoints: [], + policies: [{ requiresRuntimeAttestation: false, requiresApproval: false }], + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + } + return { + agent, + card, + signed: await signAgentCard(card, agent.privateKey, agent.identity.did), + } + } + + it('publishes identity-bound signed AgentCards to the registry', async () => { + const { signed } = await signedCard() + fetchMock.mockResolvedValueOnce({ ok: true }) + const registry = new RegistryDiscoveryProvider({ baseUrl: 'https://registry.example' }) + + await expect(registry.register(signed)).resolves.toBeUndefined() + + expect(fetchMock).toHaveBeenCalledWith( + 'https://registry.example/v1/cards', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(signed), + }), + ) + }) + + it('rejects AgentCards not signed by the advertised agent identity before publishing', async () => { + const { card } = await signedCard() + const attacker = await createAgentIdentity() + const signed = await signAgentCard(card, attacker.privateKey, attacker.identity.did) + const registry = new RegistryDiscoveryProvider({ baseUrl: 'https://registry.example' }) + + await expect(registry.register(signed)).rejects.toThrow( + 'Registry registration requires an identity-bound signed AgentCard', + ) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) From 96d3f1ba94a39a63a3c590425dce18c181f38a43 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:50:33 +0300 Subject: [PATCH 136/282] fix(agentd): enforce identity-bound agent card verification --- docs/api-reference.md | 6 ++++- services/agentd/src/index.ts | 34 ++++++++++++++++++++--------- services/agentd/test/routes.test.ts | 5 ++++- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 652e76f..9f857fb 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -147,7 +147,11 @@ fail-closed behavior as other mutating `agentd` routes. `POST /agent-cards` creates local AgentCards bound to local daemon identities. `POST /agent-cards/:id/sign` signs the stored card with the local agent identity key using the canonical AgentCard signing model, and -`POST /agent-cards/:id/verify` verifies the signed card when present. These +`POST /agent-cards/:id/verify` verifies the signed card when present and returns +`valid: true` only when the canonical proof is also bound to the advertised +agent identity. The response includes `canonicalValid` and `identityBound` for +signed cards so callers can distinguish malformed signatures from signatures +made by the wrong DID. These routes are durable across daemon restarts when SQLite local state is enabled, but remain prototype-local until the daemon state is migrated from snapshot storage to normalized identity/card tables. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 1e2087d..dd31252 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -68,6 +68,7 @@ import { verifySignedRegistryIndexRecord, verifySignedRegistryPeerRecord, verifySignedAgentCard, + verifySignedAgentCardIdentity, verifyDelegationTokenSignature, verifyDomainDid, evaluateInvocationPreflight, @@ -944,7 +945,14 @@ app.post('/agent-cards/:id/verify', async (c) => { const id = c.req.param('id') const signed = localSignedAgentCards.get(id) if (signed) { - return c.json({ valid: await verifySignedAgentCard(signed), signed: true }) + const canonicalValid = await verifySignedAgentCard(signed) + const identityBound = await verifySignedAgentCardIdentity(signed) + return c.json({ + valid: identityBound, + signed: true, + canonicalValid, + identityBound, + }) } const card = localAgentCards.get(id) @@ -2461,6 +2469,16 @@ app.post('/dht/publish', async (c) => { if (!card.capabilities.some(candidate => candidate.id === capability)) { return c.json({ error: 'AgentCard does not advertise capability', capability, cardId: card.id }, 400) } + const publisherId = typeof body.publisherId === 'string' + ? body.publisherId + : typeof body.publisher_id === 'string' + ? body.publisher_id + : card.publisher?.did ?? card.identity.did + const publisherIdentity = localIdentities.get(publisherId) + if (!publisherIdentity) { + return c.json({ error: 'DHT pointer publisher key not found', publisherId }, 404) + } + const pointer = await signDHTPointerRecord(createDHTPointerRecord({ capability, agentId: card.identity.did, @@ -2470,18 +2488,14 @@ app.post('/dht/publish', async (c) => { ? body.agent_card_url : `local://agent-cards/${encodeURIComponent(card.id)}`, agentCardHash: hashAgentCard(card), - publisherId: typeof body.publisherId === 'string' - ? body.publisherId - : typeof body.publisher_id === 'string' - ? body.publisher_id - : card.publisher?.did ?? card.identity.did, + publisherId, expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : typeof body.expires_at === 'string' ? body.expires_at : new Date(Date.now() + 60 * 60 * 1000).toISOString(), sequence: typeof body.sequence === 'number' ? body.sequence : undefined, - }), Buffer.from(identity.privateKeyHex, 'hex'), card.identity.did) + }), Buffer.from(publisherIdentity.privateKeyHex, 'hex'), publisherId) const storedPointer = { ...pointer, id: body.id ?? crypto.randomUUID(), @@ -2527,7 +2541,7 @@ async function findLocalDhtPointers(capability?: string) { const card = localCardForProviderRecord(pointer) const verification = await verifyDHTPointerRecord(pointerRecord, { ...(card && { card }), - verificationMethod: typeof pointer.agent_id === 'string' ? pointer.agent_id : undefined, + verificationMethod: typeof pointer.publisher_id === 'string' ? pointer.publisher_id : undefined, }) const enriched = { ...pointer, verification } if (!verification.valid) { @@ -3101,7 +3115,7 @@ async function runLocalFullDemo() { agentCardHash: hashAgentCard(payment.card), publisherId: publisher.identity.did, expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), - }), Buffer.from(payment.identity.privateKeyHex, 'hex'), payment.card.identity.did) + }), Buffer.from(publisher.privateKeyHex, 'hex'), publisher.identity.did) const dhtPointer = { ...dhtPointerRecord, id: crypto.randomUUID(), @@ -3121,7 +3135,7 @@ async function runLocalFullDemo() { (record.capabilities as string[] | undefined)?.includes(invoiceCapability.id) )) const paymentDhtPointers = await findLocalDhtPointers(paymentCapability.id) - const verifiedCards = await Promise.all([calendar.signed, invoice.signed, payment.signed].map(verifySignedAgentCard)) + const verifiedCards = await Promise.all([calendar.signed, invoice.signed, payment.signed].map(verifySignedAgentCardIdentity)) const invoiceTrust = computeLocalTrustResult(invoice.card.identity.did, invoiceCapability.id) const invoiceReputation = computeCapabilityReputation({ diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index b195344..e767921 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -419,7 +419,10 @@ describe('Agentd Service Routes', () => { const verified = await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/verify`, { method: 'POST' }) expect(verified.status).toBe(200) - expect((await verified.json()).valid).toBe(true) + const verifiedData = await verified.json() + expect(verifiedData.valid).toBe(true) + expect(verifiedData.canonicalValid).toBe(true) + expect(verifiedData.identityBound).toBe(true) const fetched = await app.request(`/agent-cards/${encodeURIComponent(identity.did)}`) expect(fetched.status).toBe(200) From 617c7a9673dd5cafba3374e553ef555829dc5664 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:52:38 +0300 Subject: [PATCH 137/282] fix(agentd): require signed cards for registration --- docs/api-reference.md | 8 +++++--- services/agentd/src/index.ts | 9 ++++++++- services/agentd/test/routes.test.ts | 30 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 9f857fb..9bc9006 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -156,9 +156,11 @@ routes are durable across daemon restarts when SQLite local state is enabled, but remain prototype-local until the daemon state is migrated from snapshot storage to normalized identity/card tables. -`POST /agents/register` registers a locally stored AgentCard as a discovery -candidate. `GET /agents` and `GET /agents/:id` expose local registration state -and the associated AgentCard. `POST /discover` and `POST /discover/local` +`POST /agents/register` registers a locally stored, identity-bound signed +AgentCard as a discovery candidate. Unsigned cards and cards signed by a DID +other than the advertised agent identity are rejected before they can enter +local discovery. `GET /agents` and `GET /agents/:id` expose local registration +state and the associated AgentCard. `POST /discover` and `POST /discover/local` search registered local agents by capability. `POST /discover/well-known`, `POST /discover/registry`, `POST /discover/relay`, `POST /discover/dht`, and `POST /discover/federation` expose provider-specific discovery aliases over diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index dd31252..cb8a4a2 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -995,12 +995,19 @@ app.post('/agents/register', async (c) => { if (!card) { return c.json({ error: 'AgentCard not found', cardId }, 404) } + const signedCard = localSignedAgentCards.get(card.id) + if (!signedCard) { + return c.json({ error: 'Identity-bound signed AgentCard is required before registration', cardId }, 400) + } + if (!await verifySignedAgentCardIdentity(signedCard)) { + return c.json({ error: 'Signed AgentCard is not bound to the advertised agent identity', cardId }, 400) + } const record: LocalRegisteredAgent = { agentId: card.identity.did, cardId: card.id, registeredAt: new Date().toISOString(), - signed: localSignedAgentCards.has(card.id), + signed: true, } localAgents.set(record.agentId, record) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index e767921..f7556be 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -513,6 +513,36 @@ describe('Agentd Service Routes', () => { }) }) + it('rejects local agent registration before identity-bound AgentCard signing', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Unsigned Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ id: 'invoice.reconcile' }], + endpoints: [], + }), + }) + + const registered = await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + expect(registered.status).toBe(400) + expect(await registered.json()).toMatchObject({ + error: 'Identity-bound signed AgentCard is required before registration', + cardId: identity.did, + }) + }) + it('filters discovery candidates with incompatible protocol versions', async () => { const identityResponse = await app.request('/identities', { method: 'POST', From 22added7f7cf9f2b8317e50737c65394f7b78ca7 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:54:11 +0300 Subject: [PATCH 138/282] fix(agentd): reverify local discovery cards --- docs/api-reference.md | 4 +++- services/agentd/src/index.ts | 33 +++++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 9bc9006..b5ffeac 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -161,7 +161,9 @@ AgentCard as a discovery candidate. Unsigned cards and cards signed by a DID other than the advertised agent identity are rejected before they can enter local discovery. `GET /agents` and `GET /agents/:id` expose local registration state and the associated AgentCard. `POST /discover` and `POST /discover/local` -search registered local agents by capability. `POST /discover/well-known`, +search registered local agents by capability and re-check the identity-bound +AgentCard proof before returning a candidate, so restored or tampered local +state is rejected at resolution time as well. `POST /discover/well-known`, `POST /discover/registry`, `POST /discover/relay`, `POST /discover/dht`, and `POST /discover/federation` expose provider-specific discovery aliases over the daemon's local state. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index cb8a4a2..39a5e63 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -1134,20 +1134,36 @@ function dhtPointerRecordOnly(pointer: Record): DHTPointerRecor } } -function localDiscoveryResult(body: Record, provider = 'local') { +async function localDiscoveryResult(body: Record, provider = 'local') { const capability = typeof body.capability === 'string' ? body.capability : undefined if (!capability) { return { error: 'capability is required' as const } } const rejectedCandidates: Array> = [] - const candidates = Array.from(localAgents.values()).flatMap((record) => { + const candidateSets = await Promise.all(Array.from(localAgents.values()).map(async (record) => { const card = localAgentCards.get(record.cardId) if (!card) return [] const descriptor = card.capabilities.find(candidate => candidate.id === capability) if (!descriptor) return [] + const signedCard = localSignedAgentCards.get(record.cardId) + if (!signedCard || !await verifySignedAgentCardIdentity(signedCard)) { + rejectedCandidates.push({ + agentId: record.agentId, + cardId: record.cardId, + capability, + authorityGranted: false, + reasons: [ + 'candidate_matched_capability', + 'agent_card_identity_bound_signature_required', + 'discovery_does_not_grant_authority', + ], + }) + return [] + } + const versionNegotiation = discoveryVersionNegotiation(body, card) if (!versionNegotiation.compatible) { rejectedCandidates.push({ @@ -1169,7 +1185,7 @@ function localDiscoveryResult(body: Record, provider = 'local') agentId: record.agentId, cardId: record.cardId, capability, - signed: localSignedAgentCards.has(record.cardId), + signed: true, authorityGranted: false, versionNegotiation, resolution: { @@ -1188,7 +1204,8 @@ function localDiscoveryResult(body: Record, provider = 'local') 'url_not_required_for_local_discovery', ], }] - }) + })) + const candidates = candidateSets.flat() return { query: body, @@ -1203,21 +1220,21 @@ function localDiscoveryResult(body: Record, provider = 'local') app.post('/discover', async (c) => { const body = await c.req.json().catch(() => ({})) - const result = localDiscoveryResult(body) + const result = await localDiscoveryResult(body) if ('error' in result) return c.json({ error: result.error }, 400) return c.json(result) }) app.post('/discover/local', async (c) => { const body = await c.req.json().catch(() => ({})) - const result = localDiscoveryResult(body, 'local') + const result = await localDiscoveryResult(body, 'local') if ('error' in result) return c.json({ error: result.error }, 400) return c.json(result) }) app.post('/discover/well-known', async (c) => { const body = await c.req.json().catch(() => ({})) - const result = localDiscoveryResult(body, 'well-known') + const result = await localDiscoveryResult(body, 'well-known') if ('error' in result) return c.json({ error: result.error }, 400) return c.json(result) }) @@ -3137,7 +3154,7 @@ async function runLocalFullDemo() { } localDhtPointers.push(dhtPointer) - const calendarDiscovery = localDiscoveryResult({ capability: calendarCapability.id }, 'local') + const calendarDiscovery = await localDiscoveryResult({ capability: calendarCapability.id }, 'local') const invoiceRegistryRecords = Array.from(localRegistryRecords.values()).filter((record) => ( (record.capabilities as string[] | undefined)?.includes(invoiceCapability.id) )) From 2c57dc4907e5692878c704a9da3502e40780ea43 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:55:33 +0300 Subject: [PATCH 139/282] fix(agentd): reverify registry and relay cards --- docs/api-reference.md | 3 +++ services/agentd/src/index.ts | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index b5ffeac..9c27b67 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -172,6 +172,9 @@ registered locally; callers may omit `agentCardUrl`, in which case the daemon uses a `local://agent-cards/` pointer and signs it with the local identity. Unresolved external DHT publishes remain local mock pointers and are returned as unverified records. +`POST /registry/publish` and `POST /relay/register` also require the referenced +local registration to still have an identity-bound signed AgentCard before they +emit registry or relay records. Discovery responses always include `authorityGranted: false`; discovery is candidate resolution only, and invocation authority still requires policy evaluation and scoped session grants. Local discovery does not require an diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 39a5e63..af49a08 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -2626,6 +2626,10 @@ async function localRegistryRecordFor(cardId: string, mode: 'public' | 'private' if (!card || !registered) { return null } + const signedCard = localSignedAgentCards.get(card.id) + if (!signedCard || !await verifySignedAgentCardIdentity(signedCard)) { + return null + } const registryIndexRecord = createRegistryIndexRecord({ issuer: card.identity.did, mode, @@ -2645,7 +2649,7 @@ async function localRegistryRecordFor(cardId: string, mode: 'public' | 'private' cardId: card.id, mode, capabilities: card.capabilities.map(capability => capability.id), - signed: localSignedAgentCards.has(card.id), + signed: true, agentCardUrl: `local://agent-cards/${encodeURIComponent(card.id)}`, agentCardHash: registryIndexRecord.agent_card_hash, registryIndexRecord, @@ -2708,13 +2712,16 @@ async function localFederationPeerRecord(): Promise<{ signed: SignedRegistryPeer return { signed, verified: await verifySignedRegistryPeerRecord(signed) } } -function localRelayRecordFor(agentId: string, endpointHints: unknown[] = []) { +async function localRelayRecordFor(agentId: string, endpointHints: unknown[] = []) { const registered = localAgents.get(agentId) const card = registered ? localAgentCards.get(registered.cardId) : undefined if (!registered || !card) { return null } const signedCard = localSignedAgentCards.get(card.id) + if (!signedCard || !await verifySignedAgentCardIdentity(signedCard)) { + return null + } return { id: `relay_${agentId}`, agentId, @@ -2724,8 +2731,8 @@ function localRelayRecordFor(agentId: string, endpointHints: unknown[] = []) { online: true, agentCardUrl: `local://agent-cards/${encodeURIComponent(card.id)}`, agentCardHash: hashAgentCard(card), - signedAgentCard: Boolean(signedCard), - agentCardProof: signedCard?.proof ?? null, + signedAgentCard: true, + agentCardProof: signedCard.proof, registeredAt: new Date().toISOString(), authorityGranted: false, source: 'agentd-local-relay', @@ -2866,7 +2873,7 @@ app.post('/relay/register', async (c) => { if (!agentId) { return c.json({ error: 'agentId is required' }, 400) } - const record = localRelayRecordFor( + const record = await localRelayRecordFor( agentId, Array.isArray(body.endpointHints) ? body.endpointHints : [] ) @@ -3130,7 +3137,7 @@ async function runLocalFullDemo() { const registryRecord = await localRegistryRecordFor(invoice.card.id) if (registryRecord) localRegistryRecords.set(String(registryRecord.id), registryRecord) - const relayRecord = localRelayRecordFor(calendar.card.identity.did, ['local://calendar-agent']) + const relayRecord = await localRelayRecordFor(calendar.card.identity.did, ['local://calendar-agent']) if (relayRecord) localRelayRecords.set(calendar.card.identity.did, relayRecord) const dhtPointerRecord = await signDHTPointerRecord(createDHTPointerRecord({ capability: paymentCapability.id, From 82cf458e2a8256233c1183588bab7eca3774236d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 15:59:39 +0300 Subject: [PATCH 140/282] feat(agentd): sign root session grants --- docs/api-reference.md | 7 ++- docs/protocol/delegation-and-sessions.md | 5 ++ services/agentd/src/index.ts | 73 ++++++++++++++++++++---- services/agentd/test/routes.test.ts | 18 +++++- 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 9c27b67..1108934 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -205,8 +205,11 @@ invocation. `POST /delegations` creates a local unsigned `DelegationToken` intent and returns `authorityGranted: false`; it must still be signed and converted into a policy-checked SessionGrant before invocation. `POST /sessions` issues a local -`SessionGrant` only after policy allows or limits the action to dry-run. -`POST /invoke` verifies the session, verifies an optional caller-supplied +`SessionGrant` only after policy allows or limits the action to dry-run, signs +it with the daemon's local authority DID, and returns `signedSession` plus +`signedSessionVerified`. `POST /sessions/:id/verify` and `POST /invoke` require +the stored signed grant to verify against the grant issuer before treating the +session as usable. `POST /invoke` verifies the session, verifies an optional caller-supplied canonical `signedRequest`, runs the policy preflight path, validates the capability context, validates input/output schemas for the advertised capability, and returns an `InvocationResult` plus a canonical `signedResult` diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index c94b9bf..6b6828f 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -41,6 +41,11 @@ authority-safe check for session acceptance paths. A valid signature from a different DID over an otherwise valid grant payload is not enough to establish session authority. +The root local daemon issues `SessionGrantV2` records through a local authority +DID and returns the canonical signed grant as `signedSession`. Stored sessions +without a signed grant are not hydrated from local state, and invocation rejects +sessions whose signed grant no longer verifies against the grant issuer. + ## Invocation Binding An invocation must bind to a scoped `SessionGrant`. The root local daemon can diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index af49a08..8ff3749 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -60,6 +60,7 @@ import { signDHTPointerRecord, signRegistryIndexRecord, signRegistryPeerRecord, + signSessionGrantV2, verifySignedInvocationRequestIssuer, signInvocationResult, validateAgentCard, @@ -69,6 +70,7 @@ import { verifySignedRegistryPeerRecord, verifySignedAgentCard, verifySignedAgentCardIdentity, + verifySignedSessionGrantV2Issuer, verifyDelegationTokenSignature, verifyDomainDid, evaluateInvocationPreflight, @@ -94,6 +96,7 @@ import { type RevocationRecordV2, type RuntimeAttestation, type SessionGrantV2, + type SignedSessionGrantV2, type SignedInvocationRequest, type SignedRegistryIndexRecord, type SignedRegistryPeerRecord, @@ -161,6 +164,7 @@ const localRuntimeAttestations = new Map() let localEvidenceEvents: EvidenceEventV2[] = [] interface LocalSessionRecord { session: SessionGrantV2 + signedSession: SignedSessionGrantV2 policy: FidesPolicyDecision trust: TrustResult } @@ -285,7 +289,7 @@ function hydrateLocalState(snapshot: LocalDaemonStateSnapshot): void { localEvidenceEvents = normalizeEvidenceEventsV2(snapshot.evidenceEvents as Array>) localSessionGrants.clear() for (const record of snapshot.sessionGrants as LocalSessionRecord[]) { - if (record?.session?.session_id) localSessionGrants.set(record.session.session_id, record) + if (record?.session?.session_id && record.signedSession) localSessionGrants.set(record.session.session_id, record) } } @@ -407,6 +411,25 @@ async function createLocalIdentity( } } +async function getLocalAuthorityIdentity(): Promise { + const existing = Array.from(localIdentities.values()).find(record => ( + record.type === 'agent' && + (record.identity as AgentIdentity).metadata?.role === 'agentd_local_authority' + )) + if (existing) return existing + + const authority = await createLocalIdentity('agent', { name: 'agentd Local Authority' }) + authority.identity = { + ...(authority.identity as AgentIdentity), + metadata: { + ...((authority.identity as AgentIdentity).metadata ?? {}), + role: 'agentd_local_authority', + }, + } + localIdentities.set(authority.identity.did, authority) + return authority +} + function safeIdentitySummary(record: LocalIdentityRecord): Record { return { type: record.type, @@ -1526,6 +1549,7 @@ app.post('/sessions', async (c) => { const expiresAt = typeof body.expiresAt === 'string' ? body.expiresAt : new Date(Date.now() + 60 * 60 * 1000).toISOString() + const authority = await getLocalAuthorityIdentity() const session = createSessionGrantV2({ requesterAgentId, targetAgentId, @@ -1536,10 +1560,11 @@ app.post('/sessions', async (c) => { policyHash: hashProtocolPayload(policy), trustResultHash: hashProtocolPayload(trust), audience: Array.isArray(body.audience) ? body.audience.map(String) : [targetAgentId], - issuer: 'did:fides:agentd:local', + issuer: authority.identity.did, expiresAt, }) - localSessionGrants.set(session.session_id, { session, policy, trust }) + const signedSession = await signSessionGrantV2(session, Buffer.from(authority.privateKeyHex, 'hex'), authority.identity.did) + localSessionGrants.set(session.session_id, { session, signedSession, policy, trust }) const sessionEvidence = appendRootEvidence({ type: 'session.granted', actor: requesterAgentId, @@ -1560,13 +1585,15 @@ app.post('/sessions', async (c) => { authorized: true, authorityGranted: policy.decision === 'allow', session, + signedSession, + signedSessionVerified: await verifySignedSessionGrantV2Issuer(signedSession), policy, trust, evidenceRefs: [sessionEvidence.event_id], }, 201) }) -app.get('/sessions/:id', (c) => { +app.get('/sessions/:id', async (c) => { const record = localSessionGrants.get(c.req.param('id')) if (!record) { return c.json({ error: createErrorEnvelope('SESSION_NOT_FOUND', { @@ -1574,10 +1601,16 @@ app.get('/sessions/:id', (c) => { details: { sessionId: c.req.param('id') }, }) }, 404) } - return c.json({ session: record.session, policy: record.policy, trust: record.trust }) + return c.json({ + session: record.session, + signedSession: record.signedSession, + signedSessionVerified: await verifySignedSessionGrantV2Issuer(record.signedSession), + policy: record.policy, + trust: record.trust, + }) }) -app.post('/sessions/:id/verify', (c) => { +app.post('/sessions/:id/verify', async (c) => { const record = localSessionGrants.get(c.req.param('id')) if (!record) { return c.json({ @@ -1589,9 +1622,14 @@ app.post('/sessions/:id/verify', (c) => { }, 404) } + const signatureValid = await verifySignedSessionGrantV2Issuer(record.signedSession) + const notExpired = new Date(record.session.expires_at).getTime() > Date.now() return c.json({ - valid: new Date(record.session.expires_at).getTime() > Date.now(), + valid: signatureValid && notExpired, + signatureValid, + notExpired, session: record.session, + signedSession: record.signedSession, }) }) @@ -1616,6 +1654,14 @@ app.post('/invoke', async (c) => { }), sessionId }, 404) } + const signedSessionVerified = await verifySignedSessionGrantV2Issuer(record.signedSession) + if (!signedSessionVerified) { + return c.json({ error: createErrorEnvelope('IDENTITY_INVALID_SIGNATURE', { + message: 'SessionGrant signature is invalid or not bound to its issuer', + details: { sessionId }, + }), sessionId, authorityGranted: false }, 401) + } + if (new Date(record.session.expires_at).getTime() <= Date.now()) { return c.json({ error: createErrorEnvelope('SESSION_EXPIRED', { details: { sessionId, expires_at: record.session.expires_at }, @@ -1803,6 +1849,8 @@ app.post('/invoke', async (c) => { return c.json({ authorityGranted: preflight.can_execute, session: record.session, + signedSession: record.signedSession, + signedSessionVerified, request, signedRequest, signedRequestVerified, @@ -3104,6 +3152,7 @@ async function runLocalFullDemo() { localIdentities.set(principal.identity.did, principal) localIdentities.set(publisher.identity.did, publisher) localIdentities.set(requester.identity.did, requester) + const authority = await getLocalAuthorityIdentity() const calendarCapability = createCapabilityDescriptor({ id: 'calendar.schedule', @@ -3199,10 +3248,11 @@ async function runLocalFullDemo() { policyHash: hashProtocolPayload(invoicePolicy), trustResultHash: hashProtocolPayload(invoiceSessionTrust), audience: [invoice.card.identity.did], - issuer: 'did:fides:agentd:local', + issuer: authority.identity.did, expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), }) - localSessionGrants.set(invoiceSession.session_id, { session: invoiceSession, policy: invoicePolicy, trust: invoiceSessionTrust! }) + const signedInvoiceSession = await signSessionGrantV2(invoiceSession, Buffer.from(authority.privateKeyHex, 'hex'), authority.identity.did) + localSessionGrants.set(invoiceSession.session_id, { session: invoiceSession, signedSession: signedInvoiceSession, policy: invoicePolicy, trust: invoiceSessionTrust! }) const invoiceSessionEvidence = appendRootEvidence({ type: 'session.granted', actor: requester.identity.did, @@ -3306,10 +3356,11 @@ async function runLocalFullDemo() { policyHash: hashProtocolPayload(paymentPolicy), trustResultHash: hashProtocolPayload(paymentTrust), audience: [payment.card.identity.did], - issuer: 'did:fides:agentd:local', + issuer: authority.identity.did, expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), }) - localSessionGrants.set(paymentSession.session_id, { session: paymentSession, policy: paymentPolicy, trust: paymentTrust }) + const signedPaymentSession = await signSessionGrantV2(paymentSession, Buffer.from(authority.privateKeyHex, 'hex'), authority.identity.did) + localSessionGrants.set(paymentSession.session_id, { session: paymentSession, signedSession: signedPaymentSession, policy: paymentPolicy, trust: paymentTrust }) const paymentDryRunRequest = createInvocationRequest({ issuer: requester.identity.did, sessionGrant: paymentSession, diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index f7556be..7d0fb6e 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -895,10 +895,24 @@ describe('Agentd Service Routes', () => { const sessionData = await session.json() expect(sessionData.authorityGranted).toBe(true) expect(sessionData.session.capability).toBe('invoice.reconcile') + expect(sessionData.signedSession.payload).toEqual(sessionData.session) + expect(sessionData.signedSession.proof.proofPurpose).toBe('delegation') + expect(sessionData.signedSession.proof.verificationMethod).toBe(sessionData.session.issuer) + expect(sessionData.signedSessionVerified).toBe(true) const fetched = await app.request(`/sessions/${sessionData.session.session_id}`) expect(fetched.status).toBe(200) - expect((await fetched.json()).session.session_id).toBe(sessionData.session.session_id) + const fetchedData = await fetched.json() + expect(fetchedData.session.session_id).toBe(sessionData.session.session_id) + expect(fetchedData.signedSessionVerified).toBe(true) + + const verified = await app.request(`/sessions/${sessionData.session.session_id}/verify`, { method: 'POST' }) + expect(verified.status).toBe(200) + expect(await verified.json()).toMatchObject({ + valid: true, + signatureValid: true, + notExpired: true, + }) const invocation = await app.request('/invoke', { method: 'POST', @@ -913,6 +927,8 @@ describe('Agentd Service Routes', () => { expect(invocationData.preflight.can_execute).toBe(true) expect(invocationData.result.status).toBe('completed') expect(invocationData.authorityGranted).toBe(true) + expect(invocationData.signedSessionVerified).toBe(true) + expect(invocationData.signedSession.payload.session_id).toBe(sessionData.session.session_id) expect(invocationData.result.evidence_refs).toHaveLength(2) expect(invocationData.signedResult.payload).toEqual(invocationData.result) expect(invocationData.signedResult.proof.proofPurpose).toBe('capabilityInvocation') From ac000ed41f4b38e1e24c83cf7f553516b966d58f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:01:33 +0300 Subject: [PATCH 141/282] feat(agentd): sign local delegation tokens --- docs/api-reference.md | 8 +++++--- docs/cli-reference.md | 3 +++ docs/sdk-reference.md | 7 ++++--- services/agentd/src/index.ts | 15 ++++++++++---- services/agentd/test/routes.test.ts | 31 ++++++++++++++++++++++++----- 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 1108934..a293bae 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -202,9 +202,11 @@ flags. Trust and reputation are signals only; policy decisions still do not execute capabilities and allowed decisions require a scoped SessionGrant before invocation. -`POST /delegations` creates a local unsigned `DelegationToken` intent and -returns `authorityGranted: false`; it must still be signed and converted into a -policy-checked SessionGrant before invocation. `POST /sessions` issues a local +`POST /delegations` creates a local `DelegationToken` intent and returns +`authorityGranted: false`; when the delegator is a daemon-held local identity, +the token is signed immediately with that delegator key. External delegator +tokens remain unsigned drafts until signed elsewhere. A delegation must still be +converted into a policy-checked SessionGrant before invocation. `POST /sessions` issues a local `SessionGrant` only after policy allows or limits the action to dry-run, signs it with the daemon's local authority DID, and returns `signedSession` plus `signedSessionVerified`. `POST /sessions/:id/verify` and `POST /invoke` require diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f1e825c..87a424a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -160,6 +160,9 @@ records human authorization intent and evidence, but does not grant authority without a policy evaluation and scoped `SessionGrant`. `delegate create --agentd-url` records a root v2 delegation with local agentd. +If the delegator DID belongs to a daemon-held local identity, agentd signs the +DelegationToken with that key; otherwise the delegation is stored as an unsigned +external-signing draft. Without `--agentd-url`, `delegate create` keeps its legacy local `DelegationToken` generation behavior. Delegation still does not grant invocation authority until policy produces a scoped `SessionGrant`. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 7bacad0..3c971da 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -182,9 +182,10 @@ endpoints and use daemon-held local identity keys for signing. Agent registration and discovery return candidates only; `authorityGranted` remains `false`. Trust and reputation APIs return capability-scoped signals, and policy evaluation explains the decision but still requires session grant issuance -before invocation. Delegation helpers create unsigned local DelegationToken -intents; they do not grant invocation authority without signing, policy, and a -scoped SessionGrant. Session request and invocation helpers use the same root +before invocation. Delegation helpers create local DelegationToken intents; the +daemon signs them when the delegator identity is locally managed, but they still +do not grant invocation authority without policy and a scoped SessionGrant. +Session request and invocation helpers use the same root local daemon API. Approval and kill switch helpers expose local authority controls, with kill switch rules overriding normal policy while active. Revocation and incident helpers expose local governance records that feed root diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 8ff3749..74de249 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -57,6 +57,7 @@ import { normalizeAgentCard, resolveIncidentRecordV2, signAgentCard, + signDelegationToken, signDHTPointerRecord, signRegistryIndexRecord, signRegistryPeerRecord, @@ -1433,13 +1434,19 @@ app.post('/delegations', async (c) => { expiresAt, audience: Array.isArray(body.audience) ? body.audience.map(String) : undefined, }) - localDelegationTokens.set(token.id, token) + const delegatorIdentity = localIdentities.get(delegator) + const signedToken = delegatorIdentity + ? await signDelegationToken(token, Buffer.from(delegatorIdentity.privateKeyHex, 'hex')) + : token + localDelegationTokens.set(signedToken.id, signedToken) return c.json({ - token, - signed: false, + token: signedToken, + signed: Boolean(delegatorIdentity), authorityGranted: false, - explanation: 'Delegation records scoped authorization intent. It must be signed and converted into a policy-checked SessionGrant before invocation.', + explanation: delegatorIdentity + ? 'Delegation records signed scoped authorization intent. It must still be converted into a policy-checked SessionGrant before invocation.' + : 'Delegation records scoped authorization intent. It must be signed and converted into a policy-checked SessionGrant before invocation.', }, 201) }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 7d0fb6e..842f883 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -822,12 +822,19 @@ describe('Agentd Service Routes', () => { expect(policyData.requiresSessionGrant).toBe(true) }) - it('creates local delegation tokens without granting invocation authority', async () => { + it('creates signed local delegation tokens without granting invocation authority', async () => { + const principalResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'principal', name: 'Local Principal' }), + }) + const { identity: principal } = await principalResponse.json() + const delegation = await app.request('/delegations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - delegator: 'did:fides:principal:local', + delegator: principal.did, delegatee: 'did:fides:requester:local', capabilities: ['invoice.reconcile'], constraints: { maxActions: 1 }, @@ -837,14 +844,28 @@ describe('Agentd Service Routes', () => { expect(delegation.status).toBe(201) const data = await delegation.json() expect(data.authorityGranted).toBe(false) - expect(data.signed).toBe(false) + expect(data.signed).toBe(true) expect(data.token).toMatchObject({ - delegator: 'did:fides:principal:local', + delegator: principal.did, delegatee: 'did:fides:requester:local', capabilities: ['invoice.reconcile'], audience: ['did:fides:invoice-agent'], }) - expect(data.token.signature).toBe('') + expect(data.token.signature).toMatch(/^[0-9a-f]{128}$/) + + const externalDelegation = await app.request('/delegations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + delegator: 'did:fides:external-principal', + delegatee: 'did:fides:requester:local', + capabilities: ['invoice.reconcile'], + }), + }) + expect(externalDelegation.status).toBe(201) + const externalData = await externalDelegation.json() + expect(externalData.signed).toBe(false) + expect(externalData.token.signature).toBe('') const invalid = await app.request('/delegations', { method: 'POST', From a7cfe92a8503c84ebe2ce56775fcb43b74aff6e9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:06:22 +0300 Subject: [PATCH 142/282] fix(agentd): reject unverified provider records --- docs/api-reference.md | 7 +++++-- docs/protocol/discovery.md | 4 +++- docs/protocol/registry-federation.md | 4 +++- services/agentd/src/index.ts | 26 ++++++++++++++++++++++---- services/agentd/test/routes.test.ts | 21 +++++++++++++++++---- 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index a293bae..e8967a4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -109,7 +109,8 @@ local inspection and future normalized migrations. Registry records for locally signed AgentCards include `agentCardUrl`, `agentCardHash`, `registryIndexRecord`, `registryIndexProof`, and `registryIndexVerified`; search and discovery verify signed local registry index -records before returning them. +records before returning them. Records without a signed registry index proof are +returned under `rejectedRecords`, not as active candidates. `POST /relay/start`, `POST /relay/register`, and `POST /relay/discover` provide local mock relay presence and rendezvous. Relay records for locally signed AgentCards include `agentCardUrl`, `agentCardHash`, `signedAgentCard`, and @@ -177,7 +178,9 @@ local registration to still have an identity-bound signed AgentCard before they emit registry or relay records. Discovery responses always include `authorityGranted: false`; discovery is candidate resolution only, and invocation authority still requires policy -evaluation and scoped session grants. Local discovery does not require an +evaluation and scoped session grants. Provider records that cannot be resolved +back to a local AgentCard are rejected instead of being treated as unchecked +candidates. Local discovery does not require an endpoint URL; daemon-held AgentCards can resolve by capability with `resolution.urlRequired: false`. Endpoint URLs remain optional transport metadata, not authority. DHT discovery also does not require an HTTP URL when a diff --git a/docs/protocol/discovery.md b/docs/protocol/discovery.md index dd97256..d4a6623 100644 --- a/docs/protocol/discovery.md +++ b/docs/protocol/discovery.md @@ -78,7 +78,9 @@ returning provider results. A query can send `supported_versions` and active result set and returned in `rejectedCandidates`, `rejectedRecords`, or `rejectedPointers` with a `VERSION_INCOMPATIBLE` error envelope. This keeps discovery useful for explainability without treating an incompatible candidate -as invokable. +as invokable. Provider records that cannot be resolved back to a local +AgentCard are also rejected; discovery cannot treat an unresolved pointer or +presence record as an unchecked candidate. Federation discovery is deliberately candidate-only. The local mock federation provider verifies signed `RegistryPeerRecord` metadata and ignores expired or diff --git a/docs/protocol/registry-federation.md b/docs/protocol/registry-federation.md index 0a35ba3..3296faa 100644 --- a/docs/protocol/registry-federation.md +++ b/docs/protocol/registry-federation.md @@ -31,7 +31,9 @@ record includes: - `registryIndexVerified`, the daemon's local verification result Search and discovery verify signed local registry index records before returning -them. A valid registry index record still does not grant invocation authority. +them. Unsigned local registry records are reported as rejected records, not +active candidates. A valid registry index record still does not grant +invocation authority. Authority-safe ingestion should use `verifySignedRegistryIndexRecordIssuer`, which verifies both the canonical Ed25519 proof and that `proof.verificationMethod` equals the record `issuer`. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 74de249..f2f97a8 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -1109,7 +1109,15 @@ function filterVersionCompatibleProviderRecords( for (const record of records) { const card = localCardForProviderRecord(record) if (!card) { - compatible.push({ ...record, protocolCompatibility: 'not_checked_card_unresolved' }) + rejected.push({ + ...record, + authorityGranted: false, + protocolCompatibility: 'card_unresolved', + reasons: [ + 'provider_record_card_unresolved', + 'discovery_does_not_grant_authority', + ], + }) continue } @@ -2731,7 +2739,15 @@ async function filterVerifiedLocalRegistryRecords(records: Array { }) }) -app.get('/registry/index', (c) => { +app.get('/registry/index', async (c) => { + const verified = await filterVerifiedLocalRegistryRecords(Array.from(localRegistryRecords.values())) return c.json({ mode: 'local_mock_registry', - records: Array.from(localRegistryRecords.values()), + records: verified.records, + rejectedRecords: verified.rejectedRecords, authorityGranted: false, }) }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 842f883..ced6a71 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1852,8 +1852,16 @@ describe('Agentd Service Routes', () => { expect(await discoverDht.json()).toMatchObject({ provider: 'dht', authorityGranted: false, - pointers: expect.arrayContaining([ - expect.objectContaining({ agentId: 'did:fides:agent' }), + pointers: [], + rejectedPointers: expect.arrayContaining([ + expect.objectContaining({ + agentId: 'did:fides:agent', + protocolCompatibility: 'card_unresolved', + reasons: expect.arrayContaining([ + 'provider_record_card_unresolved', + 'discovery_does_not_grant_authority', + ]), + }), ]), }) }) @@ -2049,6 +2057,7 @@ describe('Agentd Service Routes', () => { expect(search.status).toBe(200) const searchData = await search.json() expect(searchData.authorityGranted).toBe(false) + expect(searchData.rejectedRecords).toEqual([]) expect(searchData.records).toEqual(expect.arrayContaining([ expect.objectContaining({ agentId: identity.did, @@ -2063,9 +2072,11 @@ describe('Agentd Service Routes', () => { body: JSON.stringify({ capability: 'calendar.schedule' }), }) expect(discoverRegistry.status).toBe(200) - expect(await discoverRegistry.json()).toMatchObject({ + const discoverRegistryData = await discoverRegistry.json() + expect(discoverRegistryData).toMatchObject({ provider: 'registry', authorityGranted: false, + rejectedRecords: [], records: expect.arrayContaining([ expect.objectContaining({ agentId: identity.did, @@ -2077,7 +2088,9 @@ describe('Agentd Service Routes', () => { const index = await app.request('/registry/index') expect(index.status).toBe(200) - expect((await index.json()).records).toEqual(expect.arrayContaining([ + const indexData = await index.json() + expect(indexData.rejectedRecords).toEqual([]) + expect(indexData.records).toEqual(expect.arrayContaining([ expect.objectContaining({ agentId: identity.did, registryIndexVerified: true, From 1b4262feecbe4cae38ca7aa0f8a879f1f45cc063 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:09:26 +0300 Subject: [PATCH 143/282] fix(sdk): type v2 policy decisions --- docs/sdk-reference.md | 3 ++ packages/sdk/README.md | 3 ++ packages/sdk/src/agentd/client.ts | 2 +- packages/sdk/src/fides-client.ts | 43 +++++++++++++++++++++++++- packages/sdk/src/index.ts | 3 ++ packages/sdk/test/fides-client.test.ts | 38 +++++++++++++++++++++++ 6 files changed, 90 insertions(+), 2 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 3c971da..f8ad0cf 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -62,6 +62,9 @@ const policy = await client.policy.evaluate({ capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], }) +// policy.policy.decision is one of: +// allow, deny, require_approval, dry_run_only, scope_limit, risk_limit. +// A policy response never grants invocation authority by itself. await client.delegations.create({ delegator: 'did:fides:principal', delegatee: 'did:fides:requester', diff --git a/packages/sdk/README.md b/packages/sdk/README.md index c3eb4c8..c7dfbe3 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -85,6 +85,9 @@ const policy = await client.policy.evaluate({ capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], }) +// policy.policy.decision is one of: +// allow, deny, require_approval, dry_run_only, scope_limit, risk_limit. +// A policy response never grants invocation authority by itself. const approval = await client.approvals.create({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', diff --git a/packages/sdk/src/agentd/client.ts b/packages/sdk/src/agentd/client.ts index f42e9fe..94c89eb 100644 --- a/packages/sdk/src/agentd/client.ts +++ b/packages/sdk/src/agentd/client.ts @@ -177,7 +177,7 @@ export interface IncidentListResponse { } export interface AuthorizationDecision { - decision: 'allow' | 'deny' + decision: 'allow' | 'deny' | 'approve-required' | 'dry-run' explanation: string factors?: Array> session?: Record diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index effd006..bd55900 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -1,4 +1,6 @@ import { + type CapabilityControl, + type TrustResult, createInvocationRequest, isErrorEnvelope, signInvocationRequest, @@ -145,6 +147,43 @@ export interface FidesInvocationResponse { signedResultVerified?: boolean } +export type FidesPolicyDecisionAction = + | 'allow' + | 'deny' + | 'require_approval' + | 'dry_run_only' + | 'scope_limit' + | 'risk_limit' + +export interface FidesPolicyDecision { + schema_version: 'fides.policy.decision.v1' + id: string + issuer: string + subject: string + decision: FidesPolicyDecisionAction + principal_id: string + requester_agent_id: string + target_agent_id: string + capability: string + reason_codes: string[] + machine_reasons: Array> + human_reasons: string[] + required_controls: CapabilityControl[] + evidence_refs: string[] + issued_at: string + evaluated_at: string + payload_hash: string + [key: string]: unknown +} + +export interface FidesPolicyEvaluationResponse { + policy: FidesPolicyDecision + trust: TrustResult + authorityGranted: false + requiresSessionGrant: boolean + explanation: string +} + export class FidesClientError extends Error { readonly name = 'FidesClientError' @@ -201,7 +240,9 @@ export class FidesClient { } readonly policy = { - evaluate: (body: Record) => this.post('/policy/evaluate', body), + evaluate: (body: Record): Promise => ( + this.post('/policy/evaluate', body) as Promise + ), } readonly delegations = { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d4b88a6..047fa77 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -148,6 +148,9 @@ export { type FidesDhtPublishRequest, type FidesInvocationRequest, type FidesInvocationResponse, + type FidesPolicyDecision, + type FidesPolicyDecisionAction, + type FidesPolicyEvaluationResponse, } from './fides-client.js' // Integration exports diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index bfc0818..2fc5f7b 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -7,6 +7,44 @@ afterEach(() => { }) describe('FidesClient', () => { + it('types root policy evaluation responses with v2 decisions', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + policy: { + schema_version: 'fides.policy.decision.v1', + id: 'poldec_1', + issuer: 'did:fides:agentd:local', + subject: 'did:fides:agent', + decision: 'require_approval', + principal_id: 'did:fides:principal', + requester_agent_id: 'did:fides:requester', + target_agent_id: 'did:fides:agent', + capability: 'payments.prepare', + reason_codes: ['HIGH_RISK_REQUIRES_APPROVAL'], + machine_reasons: [], + human_reasons: ['High-risk capability requires approval'], + required_controls: ['human_approval'], + evidence_refs: [], + issued_at: '2026-01-01T00:00:00.000Z', + evaluated_at: '2026-01-01T00:00:00.000Z', + payload_hash: 'sha256:test', + }, + trust: { score: 0.7, band: 'medium' }, + authorityGranted: false, + requiresSessionGrant: false, + explanation: 'Policy decisions do not execute capabilities.', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }))) + + const client = new FidesClient({ daemonUrl: 'http://localhost:4817' }) + const result = await client.policy.evaluate({ agentId: 'did:fides:agent', capability: 'payments.prepare' }) + + expect(result.policy.decision).toBe('require_approval') + expect(result.authorityGranted).toBe(false) + expect(result.requiresSessionGrant).toBe(false) + }) + it('exposes promise-based identity, card, discovery, trust, session, and invocation namespaces', async () => { const calls: Array<{ url: string; init?: RequestInit }> = [] vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { From b6d654d71e6d6c987a7340f7ac1182e0604e9bc8 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:11:04 +0300 Subject: [PATCH 144/282] docs(api): align policy decision schema --- docs/api/agentd.yaml | 76 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index d8da74b..a3ed549 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -342,7 +342,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "200": - $ref: "#/components/responses/JsonObject" + description: FIDES v2 policy decision. This never grants invocation authority by itself. + content: + application/json: + schema: + $ref: "#/components/schemas/LocalPolicyEvaluationResponse" /approvals: post: @@ -1674,6 +1678,74 @@ components: explanation: type: object + FidesPolicyDecision: + type: object + properties: + schema_version: + type: string + enum: [fides.policy.decision.v1] + id: + type: string + issuer: + type: string + subject: + type: string + decision: + type: string + enum: [allow, deny, require_approval, dry_run_only, scope_limit, risk_limit] + principal_id: + type: string + requester_agent_id: + type: string + target_agent_id: + type: string + capability: + type: string + reason_codes: + type: array + items: + type: string + machine_reasons: + type: array + items: + type: object + human_reasons: + type: array + items: + type: string + required_controls: + type: array + items: + type: string + enum: [dry_run, human_approval, policy_proof, runtime_attestation, scope_limit, rate_limit] + evidence_refs: + type: array + items: + type: string + issued_at: + type: string + format: date-time + evaluated_at: + type: string + format: date-time + payload_hash: + type: string + + LocalPolicyEvaluationResponse: + type: object + properties: + policy: + $ref: "#/components/schemas/FidesPolicyDecision" + trust: + type: object + authorityGranted: + type: boolean + enum: [false] + requiresSessionGrant: + type: boolean + explanation: + type: string + EvidenceSubmitRequest: type: object required: [actor, action] @@ -2086,7 +2158,7 @@ components: properties: decision: type: string - enum: [allow, deny, approve-required] + enum: [allow, deny, approve-required, dry-run] explanation: type: string factors: From b0648f2c4101366189ee540a5fefc8991b46e02d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:14:15 +0300 Subject: [PATCH 145/282] fix(registry): verify signed agent cards --- services/registry/src/index.ts | 47 ++++++++++++++++++ services/registry/test/routes.test.ts | 69 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/services/registry/src/index.ts b/services/registry/src/index.ts index a6bdcd6..453df23 100644 --- a/services/registry/src/index.ts +++ b/services/registry/src/index.ts @@ -10,6 +10,7 @@ import { serve } from '@hono/node-server' import { cors } from 'hono/cors' import { bodyLimit } from 'hono/body-limit' import { rateLimitMiddleware, MetricsCollector, metricsMiddleware } from '@fides/sdk' +import { verifySignedAgentCardIdentity, type SignedAgentCard } from '@fides/core' import { logger } from './middleware/logger.js' import { securityHeaders } from './middleware/security.js' import { errorHandler } from './middleware/error-handler.js' @@ -45,6 +46,48 @@ type DiscoveryIdentityResponse = { organizationVerificationMethod?: unknown } +function isSignedAgentCard(value: unknown): value is SignedAgentCard { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const candidate = value as Partial + return Boolean(candidate.payload && candidate.proof) +} + +function reviveByteArray(value: unknown): Uint8Array | unknown { + if (value instanceof Uint8Array) return value + if (Array.isArray(value) && value.length === 32 && value.every(isByte)) { + return Uint8Array.from(value) + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + const entries = Object.entries(value as Record) + if ( + entries.length === 32 && + entries.every(([key, entry]) => /^\d+$/.test(key) && isByte(entry)) + ) { + return Uint8Array.from(entries + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([, entry]) => entry as number)) + } + } + return value +} + +function signedAgentCardForVerification(card: SignedAgentCard): SignedAgentCard { + return { + ...card, + payload: { + ...card.payload, + identity: { + ...card.payload.identity, + publicKey: reviveByteArray(card.payload.identity.publicKey) as Uint8Array, + }, + }, + } +} + +function isByte(value: unknown): value is number { + return Number.isInteger(value) && typeof value === 'number' && value >= 0 && value <= 255 +} + function getCorsOrigin(): string { const corsOrigin = process.env.CORS_ORIGIN if (process.env.NODE_ENV === 'production') { @@ -205,6 +248,10 @@ app.post('/v1/cards', async (c) => { return c.json({ error: 'id is required' }, 400) } + if (isSignedAgentCard(body) && !await verifySignedAgentCardIdentity(signedAgentCardForVerification(body))) { + return c.json({ error: 'signed AgentCard proof must verify and match payload.identity.did' }, 422) + } + const publisherVerification = await verifyPublisherClaim(body) if (!publisherVerification.ok) { return c.json({ error: publisherVerification.error }, publisherVerification.status) diff --git a/services/registry/test/routes.test.ts b/services/registry/test/routes.test.ts index 5a4ae15..a61287e 100644 --- a/services/registry/test/routes.test.ts +++ b/services/registry/test/routes.test.ts @@ -1,4 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + createAgentIdentity, + createCapabilityDescriptor, + signAgentCard, + type AgentCard, +} from '@fides/core' const ORIGINAL_SERVICE_API_KEY = process.env.SERVICE_API_KEY const ORIGINAL_REGISTRY_API_KEYS = process.env.REGISTRY_API_KEYS @@ -72,6 +78,29 @@ describe('Registry Service Routes', () => { metadata: {}, } + async function signedTestCard(overrides: Partial = {}) { + const issued = await createAgentIdentity() + const now = new Date().toISOString() + return signAgentCard({ + id: issued.identity.did, + identity: issued.identity, + name: 'Signed Registry Agent', + capabilities: [createCapabilityDescriptor({ + id: 'web.search', + namespace: 'web', + action: 'search', + resource: 'web', + riskLevel: 'low', + })], + endpoints: [], + policies: [], + createdAt: now, + updatedAt: now, + expiresAt: '2999-01-01T00:00:00.000Z', + ...overrides, + } as AgentCard & { name: string }, issued.privateKey, issued.identity.did) + } + describe('GET /health', () => { it('returns 200 with health status', async () => { const res = await app.request('/health') @@ -109,6 +138,46 @@ describe('Registry Service Routes', () => { expect(data.error).toContain('id is required') }) + it('rejects tampered signed AgentCards', async () => { + const signed = await signedTestCard() + + const res = await app.request('/v1/cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...signed, + payload: { + ...signed.payload, + capabilities: [ + { + ...signed.payload.capabilities[0], + name: 'Tampered capability', + }, + ], + }, + }), + }) + + expect(res.status).toBe(422) + expect((await res.json()).error).toContain('signed AgentCard proof') + }) + + it('accepts identity-bound signed AgentCards', async () => { + const signed = await signedTestCard() + + const res = await app.request('/v1/cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signed), + }) + + expect(res.status).toBe(201) + expect(await res.json()).toMatchObject({ + success: true, + did: signed.payload.id, + }) + }) + it('allows a DNS-verified publisher claim when discovery state matches', async () => { process.env.DISCOVERY_URL = 'https://discovery.test' const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ From 31104c048fef6d716983e4eb4cd2fb59c5fdd4dd Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:17:09 +0300 Subject: [PATCH 146/282] feat(policy): normalize legacy policy decisions --- packages/policy/src/index.ts | 24 ++++++++++++++++++++++ packages/policy/test/policy.test.ts | 20 +++++++++++++++++- services/policy-engine/README.md | 3 ++- services/policy-engine/src/index.ts | 18 +++++++++++++--- services/policy-engine/test/routes.test.ts | 6 ++++-- 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/packages/policy/src/index.ts b/packages/policy/src/index.ts index 75319f8..8710a80 100644 --- a/packages/policy/src/index.ts +++ b/packages/policy/src/index.ts @@ -49,6 +49,30 @@ export type FidesPolicyDecisionAction = | 'scope_limit' | 'risk_limit' +export interface NormalizedPolicyResult extends Omit { + decision: Exclude + legacyDecision: PolicyResult['decision'] +} + +export function normalizePolicyDecisionAction(decision: PolicyResult['decision']): NormalizedPolicyResult['decision'] { + switch (decision) { + case 'approve-required': + return 'require_approval' + case 'dry-run': + return 'dry_run_only' + default: + return decision + } +} + +export function normalizePolicyResult(result: PolicyResult): NormalizedPolicyResult { + return { + ...result, + decision: normalizePolicyDecisionAction(result.decision), + legacyDecision: result.decision, + } +} + export interface PolicyReason { code: string severity: 'info' | 'warning' | 'error' diff --git a/packages/policy/test/policy.test.ts b/packages/policy/test/policy.test.ts index 70d7971..dbfe84a 100644 --- a/packages/policy/test/policy.test.ts +++ b/packages/policy/test/policy.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { evaluatePolicy, runPreExecutionPipeline } from '../src/index.js' +import { evaluatePolicy, normalizePolicyResult, runPreExecutionPipeline } from '../src/index.js' import type { PolicyBundle, PolicyContext } from '../src/index.js' describe('Policy Engine', () => { @@ -43,6 +43,24 @@ describe('Policy Engine', () => { expect(result.matchedRules).toContain('high-risk-approval') }) + it('normalizes legacy policy decisions to the FIDES v2 vocabulary', () => { + expect(normalizePolicyResult(evaluatePolicy(bundle, { risk: 'high' }))).toMatchObject({ + decision: 'require_approval', + legacyDecision: 'approve-required', + }) + + const dryRunBundle: PolicyBundle = { + id: 'dry-run', + version: '1', + defaultAction: 'allow', + rules: [{ id: 'r1', condition: { operator: 'eq', field: 'known', value: false }, action: 'dry-run', explanation: '' }], + } + expect(normalizePolicyResult(evaluatePolicy(dryRunBundle, { known: false }))).toMatchObject({ + decision: 'dry_run_only', + legacyDecision: 'dry-run', + }) + }) + it('should deny guest', () => { const ctx: PolicyContext = { role: 'guest', risk: 'low' } const result = evaluatePolicy(bundle, ctx) diff --git a/services/policy-engine/README.md b/services/policy-engine/README.md index 9639aef..298047d 100644 --- a/services/policy-engine/README.md +++ b/services/policy-engine/README.md @@ -2,7 +2,7 @@ Standalone deterministic policy evaluation service for FIDES agents. -The service wraps `@fides/policy` behind a small Hono HTTP API. It validates incoming policy bundles, evaluates them against request context, and returns the same `allow`, `deny`, `approve-required`, or `dry-run` decisions used by agentd guard flows. +The service wraps `@fides/policy` behind a small Hono HTTP API. It validates incoming policy bundles, evaluates them against request context, and returns FIDES v2 decision names: `allow`, `deny`, `require_approval`, or `dry_run_only`. When a legacy policy bundle uses `approve-required` or `dry-run`, the response also includes `legacyDecision` for compatibility. ## Status @@ -88,6 +88,7 @@ Example response: ```json { "decision": "deny", + "legacyDecision": "deny", "matchedRules": ["deny-large-transfer"], "explanation": { "decision": "Rule deny-large-transfer matched", diff --git a/services/policy-engine/src/index.ts b/services/policy-engine/src/index.ts index 6a0c48f..a2dc896 100644 --- a/services/policy-engine/src/index.ts +++ b/services/policy-engine/src/index.ts @@ -3,7 +3,7 @@ import { Hono } from 'hono' import { bodyLimit } from 'hono/body-limit' import { cors } from 'hono/cors' import type { MiddlewareHandler } from 'hono' -import { evaluatePolicy, type PolicyBundle, type PolicyContext } from '@fides/policy' +import { evaluatePolicy, type PolicyBundle, type PolicyContext, type PolicyResult } from '@fides/policy' import { evaluateApiKeyAuth, MetricsCollector, metricsMiddleware, parseScopedApiKeys } from '@fides/shared' const app = new Hono() @@ -45,7 +45,7 @@ app.post('/v1/policies/evaluate', apiKeyAuth(), async (c) => { ...(body.capabilityId && { capabilityId: body.capabilityId }), } - return c.json(evaluatePolicy(body.policy as PolicyBundle, context)) + return c.json(normalizePolicyResult(evaluatePolicy(body.policy as PolicyBundle, context))) }) app.post('/v1/evaluate', apiKeyAuth(), async (c) => { @@ -59,7 +59,7 @@ app.post('/v1/evaluate', apiKeyAuth(), async (c) => { return c.json({ error: 'invalid policy bundle', details: validation.errors }, 400) } - return c.json(evaluatePolicy(body.policy as PolicyBundle, isRecord(body.context) ? body.context : {})) + return c.json(normalizePolicyResult(evaluatePolicy(body.policy as PolicyBundle, isRecord(body.context) ? body.context : {}))) }) export { app } @@ -119,6 +119,18 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } +function normalizePolicyResult(result: PolicyResult) { + return { + ...result, + decision: result.decision === 'approve-required' + ? 'require_approval' + : result.decision === 'dry-run' + ? 'dry_run_only' + : result.decision, + legacyDecision: result.decision, + } +} + function apiKeyAuth(): MiddlewareHandler { return async (c, next) => { const scopedKeys = parseScopedApiKeys(process.env.POLICY_ENGINE_API_KEYS, 'POLICY_ENGINE_API_KEYS') diff --git a/services/policy-engine/test/routes.test.ts b/services/policy-engine/test/routes.test.ts index 6cd9ea6..5f6d933 100644 --- a/services/policy-engine/test/routes.test.ts +++ b/services/policy-engine/test/routes.test.ts @@ -81,7 +81,8 @@ describe('policy-engine service', () => { expect(res.status).toBe(200) const data = await res.json() - expect(data.decision).toBe('approve-required') + expect(data.decision).toBe('require_approval') + expect(data.legacyDecision).toBe('approve-required') }) it('returns dry-run when dry-run rule matches', async () => { @@ -93,7 +94,8 @@ describe('policy-engine service', () => { expect(res.status).toBe(200) const data = await res.json() - expect(data.decision).toBe('dry-run') + expect(data.decision).toBe('dry_run_only') + expect(data.legacyDecision).toBe('dry-run') }) it('rejects invalid policy bundles', async () => { From a10d9f3e35dd7c5f3643fc11196eaeb5e81c290e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:20:42 +0300 Subject: [PATCH 147/282] fix(runtime): bind local attestation signatures --- packages/runtime/src/index.ts | 50 ++++++++++++++++++++++++--- packages/runtime/test/runtime.test.ts | 22 ++++++++++++ services/agentd/test/routes.test.ts | 2 +- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 00f40da..39897a1 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -4,6 +4,8 @@ * Provides runtime attestation primitives and emergency kill switch. */ +import { createHash } from 'node:crypto' + export interface RuntimeAttestation { id: string agentDid: string @@ -97,14 +99,21 @@ export class MockTEEProvider implements TEEAdapter { timestamp: now.toISOString(), expiresAt: expires.toISOString(), evidence: { mock: true }, - signature: 'mock-signature', + signature: localAttestationSignature({ + agentDid, + provider: this.provider, + measurement, + timestamp: now.toISOString(), + expiresAt: expires.toISOString(), + evidence: { mock: true }, + }), } } async verify(attestation: RuntimeAttestation): Promise { if (attestation.provider !== this.provider) return false if (new Date(attestation.expiresAt) < new Date()) return false - return attestation.signature === 'mock-signature' + return attestation.signature === localAttestationSignature(attestation) } } @@ -330,22 +339,53 @@ function createStructuredAttestation(input: { expiresAt: string evidence: unknown }): RuntimeAttestation { + const timestamp = new Date().toISOString() return { id: crypto.randomUUID(), agentDid: input.agentDid, provider: input.provider, measurement: input.measurement, - timestamp: new Date().toISOString(), + timestamp, expiresAt: input.expiresAt, evidence: input.evidence, - signature: 'structured-local-attestation', + signature: localAttestationSignature({ + agentDid: input.agentDid, + provider: input.provider, + measurement: input.measurement, + timestamp, + expiresAt: input.expiresAt, + evidence: input.evidence, + }), } } function isFreshProvider(attestation: RuntimeAttestation, provider: string): boolean { - return attestation.provider === provider && new Date(attestation.expiresAt) >= new Date() + return attestation.provider === provider && + new Date(attestation.expiresAt) >= new Date() && + attestation.signature === localAttestationSignature(attestation) } function isSha256Digest(value: string): boolean { return /^sha256:[a-f0-9]{64}$/i.test(value) } + +function localAttestationSignature(attestation: Omit): string { + const payload = { + agentDid: attestation.agentDid, + provider: attestation.provider, + measurement: attestation.measurement, + timestamp: attestation.timestamp, + expiresAt: attestation.expiresAt, + evidence: attestation.evidence, + } + return `local-attestation:${createHash('sha256').update(stableJson(payload)).digest('hex')}` +} + +function stableJson(value: unknown): string { + return JSON.stringify(value, (_key, nested) => { + if (nested !== null && typeof nested === 'object' && !Array.isArray(nested)) { + return Object.fromEntries(Object.entries(nested as Record).sort(([a], [b]) => a.localeCompare(b))) + } + return nested + }) +} diff --git a/packages/runtime/test/runtime.test.ts b/packages/runtime/test/runtime.test.ts index 2dc052c..f12df20 100644 --- a/packages/runtime/test/runtime.test.ts +++ b/packages/runtime/test/runtime.test.ts @@ -17,6 +17,7 @@ describe('MockTEEProvider', () => { expect(attestation.provider).toBe('mock-tee') expect(attestation.agentDid).toBe('did:fides:agent1') + expect(attestation.signature).toMatch(/^local-attestation:/) const valid = await provider.verify(attestation) expect(valid).toBe(true) @@ -30,6 +31,14 @@ describe('MockTEEProvider', () => { const valid = await provider.verify(attestation) expect(valid).toBe(false) }) + + it('rejects tampered mock attestation fields', async () => { + const provider = new MockTEEProvider() + const attestation = await provider.attest('did:fides:agent1') + attestation.measurement = 'mock-measurement-did:fides:agent2' + + await expect(provider.verify(attestation)).resolves.toBe(false) + }) }) describe('Production attestation adapters', () => { @@ -49,6 +58,19 @@ describe('Production attestation adapters', () => { expect(await provider.verify(attestation)).toBe(false) }) + it('rejects tampered local structured attestation signatures', async () => { + const provider = new BuildProvenanceAttestationProvider() + const attestation = await provider.attest({ + agentDid: 'did:fides:agent1', + imageDigest: 'sha256:abc', + sourceCommit: 'abc123', + builderId: 'builder://github/actions', + }) + + attestation.signature = 'local-attestation:tampered' + await expect(provider.verify(attestation)).resolves.toBe(false) + }) + it('verifies container image attestations against allowed images', async () => { const provider = new ContainerImageAttestationProvider([ { registry: 'ghcr.io', repository: 'efedurmaz16/fides-agent' }, diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index ced6a71..30d382e 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -3260,7 +3260,7 @@ describe('Agentd Service Routes', () => { expect(data.agentDid).toBe(TEST_DID) expect(data.provider).toBe('mock-tee') expect(data.measurement).toBeDefined() - expect(data.signature).toBe('mock-signature') + expect(data.signature).toMatch(/^local-attestation:/) }) it('returns 400 when did is missing', async () => { From 56345d6af347335ac89986163d195aecd254bf56 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:22:06 +0300 Subject: [PATCH 148/282] fix(core): bind mock tee attestation signatures --- packages/core/src/runtime-attestation.ts | 31 ++++++++++++++++--- .../core/test/runtime-attestation.test.ts | 16 ++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/core/src/runtime-attestation.ts b/packages/core/src/runtime-attestation.ts index bf5008f..0c489c2 100644 --- a/packages/core/src/runtime-attestation.ts +++ b/packages/core/src/runtime-attestation.ts @@ -46,7 +46,7 @@ export function isRuntimeAttestationExpired(attestation: RuntimeAttestation, now export function createRuntimeAttestation(input: RuntimeAttestationIssueInput & { provider: RuntimeAttestation['provider'] issuer?: string - signature: string + signature?: string }): RuntimeAttestation { const issuedAt = input.issuedAt ?? new Date().toISOString() const id = crypto.randomUUID() @@ -76,7 +76,7 @@ export function createRuntimeAttestation(input: RuntimeAttestationIssueInput & { return { ...payload, payload_hash: hashProtocolPayload(payload), - signature: input.signature, + signature: input.signature ?? localRuntimeAttestationSignature(payload), } } @@ -96,14 +96,15 @@ export class MockTEEProvider implements TeeAttestationProvider { return createRuntimeAttestation({ ...input, provider: this.provider, - signature: 'mock-tee-signature', }) } async verify(attestation: RuntimeAttestation): Promise { if (attestation.provider !== this.provider) return false if (isRuntimeAttestationExpired(attestation)) return false - return attestation.signature === 'mock-tee-signature' && + const payload = runtimeAttestationPayload(attestation) + return attestation.payload_hash === hashProtocolPayload(payload) && + attestation.signature === localRuntimeAttestationSignature(payload) && isSha256(attestation.code_hash) && isSha256(attestation.runtime_hash) && isSha256(attestation.policy_hash) && @@ -130,3 +131,25 @@ export class NullAttestationProvider implements AttestationProvider { function isSha256(value: string): boolean { return /^sha256:[a-f0-9]{64}$/i.test(value) } + +function runtimeAttestationPayload(attestation: RuntimeAttestation): Omit { + return { + schema_version: attestation.schema_version, + id: attestation.id, + issuer: attestation.issuer, + subject: attestation.subject, + attestation_id: attestation.attestation_id, + agent_id: attestation.agent_id, + provider: attestation.provider, + code_hash: attestation.code_hash, + runtime_hash: attestation.runtime_hash, + policy_hash: attestation.policy_hash, + enclave_measurement: attestation.enclave_measurement, + issued_at: attestation.issued_at, + expires_at: attestation.expires_at, + } +} + +function localRuntimeAttestationSignature(payload: Omit): string { + return `local-attestation:${hashProtocolPayload(payload).slice('sha256:'.length)}` +} diff --git a/packages/core/test/runtime-attestation.test.ts b/packages/core/test/runtime-attestation.test.ts index df61671..6c63888 100644 --- a/packages/core/test/runtime-attestation.test.ts +++ b/packages/core/test/runtime-attestation.test.ts @@ -30,10 +30,26 @@ describe('runtime attestation v2', () => { }) expect(attestation.attestation_id).toBe(attestation.id) expect(attestation.payload_hash).toMatch(/^sha256:/) + expect(attestation.signature).toMatch(/^local-attestation:/) expect(await provider.verify(attestation)).toBe(true) expect(await verifyRuntimeAttestation(attestation, provider)).toBe(true) }) + it('rejects tampered mock TEE attestation payload fields', async () => { + const provider = new MockTEEProvider() + const attestation = await provider.issue({ + agentId: 'did:fides:agent', + codeHash: `sha256:${'a'.repeat(64)}`, + runtimeHash: `sha256:${'b'.repeat(64)}`, + policyHash: `sha256:${'c'.repeat(64)}`, + }) + + attestation.code_hash = `sha256:${'d'.repeat(64)}` + + expect(await provider.verify(attestation)).toBe(false) + expect(await verifyRuntimeAttestation(attestation, provider)).toBe(false) + }) + it('rejects expired attestations', async () => { const provider = new MockTEEProvider() const attestation = await provider.issue({ From 59562b679afe91c6a7053164b7c83f0c096f93a8 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:24:46 +0300 Subject: [PATCH 149/282] fix(discovery): preserve verified local agent cards --- packages/discovery/src/local-provider.ts | 70 ++++++++++++++----- .../discovery/test/local-provider.test.ts | 40 +++++++++++ 2 files changed, 94 insertions(+), 16 deletions(-) diff --git a/packages/discovery/src/local-provider.ts b/packages/discovery/src/local-provider.ts index cb75c4f..1efbc89 100644 --- a/packages/discovery/src/local-provider.ts +++ b/packages/discovery/src/local-provider.ts @@ -12,6 +12,11 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { homedir } from 'node:os' +interface LocalAgentRecord { + card: AgentCard + signedCard?: SignedAgentCard +} + /** * LocalDiscoveryProvider discovers agents via a local file store. * @@ -30,17 +35,20 @@ export class LocalDiscoveryProvider implements DiscoveryProvider { async resolve(did: string): Promise { const store = this.loadStore() - return store.get(did) || null + const record = store.get(did) + if (!record) return null + return (await this.resolveRecord(record)).card } async discover(query: DiscoveryQuery): Promise { - return this.list() - .filter(card => cardSupportsCapability(card, query.capability)) - .map((card, index) => createDiscoveryCandidate({ + const records = await this.listResolvedRecords() + return records + .filter(record => cardSupportsCapability(record.card, query.capability)) + .map((record, index) => createDiscoveryCandidate({ provider: this.name, - card, + card: record.card, capability: query.capability, - verified: false, + verified: record.verified, rank: 100 - index, explanations: [ query.capability @@ -57,7 +65,7 @@ export class LocalDiscoveryProvider implements DiscoveryProvider { } const store = this.loadStore() const did = card.payload.id - store.set(did, card.payload as AgentCard) + store.set(did, { card: card.payload as AgentCard, signedCard: card }) this.saveStore(store) } @@ -72,7 +80,7 @@ export class LocalDiscoveryProvider implements DiscoveryProvider { */ registerCard(card: AgentCard): void { const store = this.loadStore() - store.set(card.id, card) + store.set(card.id, { card }) this.saveStore(store) } @@ -81,10 +89,29 @@ export class LocalDiscoveryProvider implements DiscoveryProvider { */ list(): AgentCard[] { const store = this.loadStore() - return Array.from(store.values()) + return Array.from(store.values()).map(record => record.card) } - private loadStore(): Map { + private async listResolvedRecords(): Promise> { + const records: Array = [] + for (const record of this.loadStore().values()) { + records.push(await this.resolveRecord(record)) + } + return records + } + + private async resolveRecord(record: LocalAgentRecord): Promise { + if (record.signedCard && await verifySignedAgentCardIdentity(record.signedCard)) { + return { + card: record.signedCard.payload as AgentCard, + signedCard: record.signedCard, + verified: true, + } + } + return { card: record.card, verified: false } + } + + private loadStore(): Map { if (!existsSync(this.storePath)) { return new Map() } @@ -97,9 +124,9 @@ export class LocalDiscoveryProvider implements DiscoveryProvider { } return value }) - const map = new Map() - for (const [did, card] of Object.entries(data)) { - map.set(did, card as AgentCard) + const map = new Map() + for (const [did, value] of Object.entries(data)) { + map.set(did, normalizeLocalAgentRecord(value)) } return map } catch { @@ -107,15 +134,15 @@ export class LocalDiscoveryProvider implements DiscoveryProvider { } } - private saveStore(store: Map): void { + private saveStore(store: Map): void { const dir = join(homedir(), '.fides') if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } const obj: Record = {} - for (const [did, card] of store) { + for (const [did, record] of store) { // Convert Uint8Array to JSON-safe format - const serialized = JSON.parse(JSON.stringify(card, (key, value) => { + const serialized = JSON.parse(JSON.stringify(record, (key, value) => { if (value instanceof Uint8Array) { return { type: 'Buffer', data: Array.from(value) } } @@ -126,3 +153,14 @@ export class LocalDiscoveryProvider implements DiscoveryProvider { writeFileSync(this.storePath, JSON.stringify(obj, null, 2)) } } + +function normalizeLocalAgentRecord(value: unknown): LocalAgentRecord { + if (value && typeof value === 'object' && 'card' in value) { + const record = value as Partial + return { + card: record.card as AgentCard, + ...(record.signedCard !== undefined && { signedCard: record.signedCard as SignedAgentCard }), + } + } + return { card: value as AgentCard } +} diff --git a/packages/discovery/test/local-provider.test.ts b/packages/discovery/test/local-provider.test.ts index d1c93cc..c9fb784 100644 --- a/packages/discovery/test/local-provider.test.ts +++ b/packages/discovery/test/local-provider.test.ts @@ -48,6 +48,46 @@ describe('LocalDiscoveryProvider', () => { await expect(local.resolve(card.id)).resolves.toMatchObject({ id: card.id }) }) + it('returns verified discovery candidates for persisted signed AgentCards', async () => { + const { agent, card } = await signedCard() + card.capabilities = [{ + id: 'invoice.reconcile', + namespace: 'invoice', + action: 'reconcile', + resource: 'invoice', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object' }, + riskClass: 'medium', + requiredScopes: ['read:invoices'], + supportedControls: ['dry_run', 'human_approval'], + dryRunSupported: true, + humanApprovalSupported: true, + policyProofSupported: false, + }] + const signed = await signAgentCard(card, agent.privateKey, agent.identity.did) + const dir = mkdtempSync(join(tmpdir(), 'fides-local-provider-')) + tempDirs.push(dir) + const storePath = join(dir, 'local-agents.json') + const local = new LocalDiscoveryProvider({ storePath }) + + await local.register(signed) + + const reloaded = new LocalDiscoveryProvider({ storePath }) + const candidates = await reloaded.discover({ + schema_version: 'fides.discovery_query.v1', + id: 'query_1', + capability: 'invoice.reconcile', + }) + + expect(candidates).toHaveLength(1) + expect(candidates[0]).toMatchObject({ + agentId: card.id, + capability: 'invoice.reconcile', + verified: true, + authority: 'candidate_only', + }) + }) + it('rejects AgentCards not signed by the advertised agent identity', async () => { const { card } = await signedCard() const attacker = await createAgentIdentity() From 92010a30de6e95a481565709d6102c3d9f702d72 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:30:19 +0300 Subject: [PATCH 150/282] fix(examples): use signed local discovery flows --- examples/calendar-agent.ts | 35 ++++++++++++---------- examples/demo.ts | 19 +++++++----- examples/invoice-agent.ts | 35 ++++++++++++++-------- examples/malicious-agent.ts | 1 + examples/payment-agent.ts | 28 ++++++++++++------ examples/requester-agent.ts | 59 +++++++++++++++++++++---------------- packages/guard/demo.ts | 18 ++++++----- 7 files changed, 117 insertions(+), 78 deletions(-) diff --git a/examples/calendar-agent.ts b/examples/calendar-agent.ts index 6bcce8b..fc74d5d 100644 --- a/examples/calendar-agent.ts +++ b/examples/calendar-agent.ts @@ -10,11 +10,11 @@ * Run: npx tsx examples/calendar-agent.ts */ -import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' import { evaluatePolicy, type PolicyBundle } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } from '@fides/evidence' +import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' import { LocalDiscoveryProvider } from '@fides/discovery' @@ -29,9 +29,9 @@ async function main() { console.log('📝 Step 1: Creating Agent Identity') console.log('─'.repeat(40)) - const { identity: calendarAgent } = await createAgentIdentity() + const { identity: calendarAgent, privateKey: calendarAgentPrivateKey } = await createAgentIdentity() calendarAgent.metadata = { name: 'Calendar Assistant', version: '1.0.0' } - const { identity: user } = await createPrincipalIdentity({ + const { identity: user, privateKey: userPrivateKey } = await createPrincipalIdentity({ type: 'individual', displayName: 'Alice', }) @@ -120,16 +120,16 @@ async function main() { console.log('─'.repeat(40)) const localDiscovery = new LocalDiscoveryProvider() - // Simulate a signed card for registration - const signedCard = { - payload: agentCard, - signature: 'mock-signature', - algorithm: 'Ed25519' as const, - timestamp: new Date().toISOString(), - } - localDiscovery.registerCard(agentCard) + const signedCard = await signAgentCard(agentCard, calendarAgentPrivateKey, calendarAgent.did) + await localDiscovery.register(signedCard) const resolved = await localDiscovery.resolve(calendarAgent.did) + const discovered = await localDiscovery.discover({ + schema_version: 'fides.discovery_query.v1', + id: 'calendar-local-query', + capability: 'calendar:create', + }) console.log(` Registered: ${resolved ? 'yes' : 'no'}`) + console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) console.log(` Resolved name: ${resolved?.identity.metadata!.name}`) console.log() @@ -137,7 +137,7 @@ async function main() { console.log('🔑 Step 5: User Delegates Calendar Access') console.log('─'.repeat(40)) - const delegation = createDelegationToken({ + const delegation = await signDelegationToken(createDelegationToken({ delegator: user.did, delegatee: calendarAgent.did, capabilities: ['calendar:create', 'calendar:list'], @@ -146,8 +146,7 @@ async function main() { allowedContexts: ['work', 'personal'], }, expiresAt: new Date(Date.now() + 86400000).toISOString(), // 24h - }) - delegation.signature = 'mock-delegation-sig' + }), userPrivateKey) const delegationValid = validateDelegationToken(delegation) console.log(` Token ID: ${delegation.id}`) @@ -245,7 +244,7 @@ async function main() { ] for (const evt of calendarEvents) { - evidenceChain = appendEvidenceEvent(evidenceChain, evt, 'mock-signature') + evidenceChain = appendEvidenceEvent(evidenceChain, evt, localEvidenceSignature(evt)) console.log(` Recorded: ${evt.type} — ${evt.action}`) } @@ -328,4 +327,8 @@ async function main() { console.log() } +function localEvidenceSignature(event: unknown): string { + return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +} + main().catch(console.error) diff --git a/examples/demo.ts b/examples/demo.ts index fac29f1..09b0a11 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -15,11 +15,12 @@ import { validateAgentCard, createDelegationToken, validateDelegationToken, + signDelegationToken, type AgentCard, type CapabilityDescriptor, } from '@fides/core' import { evaluatePolicy, type PolicyBundle } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, buildMerkleRoot, verifyEvidenceChain } from '@fides/evidence' +import { createEvidenceChain, appendEvidenceEvent, buildMerkleRoot, verifyEvidenceChain, hashEvidenceValue } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' @@ -31,7 +32,7 @@ async function demo() { console.log('\nStep 1: Creating identities') const { identity: alice } = await createAgentIdentity() const { identity: bob } = await createAgentIdentity() - const { identity: charlie } = await createPrincipalIdentity({ + const { identity: charlie, privateKey: charliePrivateKey } = await createPrincipalIdentity({ type: 'individual', displayName: 'Charlie User', }) @@ -89,17 +90,17 @@ async function demo() { } console.log('\nStep 4: Creating a delegation token') - const delegation = createDelegationToken({ + const delegation = await signDelegationToken(createDelegationToken({ delegator: charlie.did, delegatee: alice.did, capabilities: ['email:send', 'calendar:create'], constraints: { maxActions: 10, maxSpend: '10.00', allowedContexts: ['work'] }, expiresAt: new Date(Date.now() + 3600_000).toISOString(), - }) - const delegationValidation = validateDelegationToken({ ...delegation, signature: 'demo-signature' }) + }), charliePrivateKey) + const delegationValidation = validateDelegationToken(delegation) console.log(` Token: ${delegation.id}`) console.log(` Delegator -> delegatee: ${delegation.delegator} -> ${delegation.delegatee}`) - console.log(` Structure valid with demo signature: ${delegationValidation.valid}`) + console.log(` Structure valid with local signature: ${delegationValidation.valid}`) console.log('\nStep 5: Evaluating policy') const policy = { @@ -131,7 +132,7 @@ async function demo() { { id: 'e2', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'calendar:create', payload: {}, privacy: { level: 'hash_only' as const } }, { id: 'e3', type: 'policy', timestamp: new Date().toISOString(), actor: alice.did, action: 'evaluate', payload: {}, privacy: { level: 'public' as const } }, ]) { - chain = appendEvidenceEvent(chain, event, 'demo-signature') + chain = appendEvidenceEvent(chain, event, localEvidenceSignature(event)) } console.log(` Events: ${chain.events.length}`) console.log(` Chain valid: ${verifyEvidenceChain(chain)}`) @@ -193,6 +194,10 @@ async function demo() { console.log('='.repeat(60)) } +function localEvidenceSignature(event: unknown): string { + return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +} + demo().catch((error) => { console.error(error) process.exit(1) diff --git a/examples/invoice-agent.ts b/examples/invoice-agent.ts index 9e6f542..c628ad1 100644 --- a/examples/invoice-agent.ts +++ b/examples/invoice-agent.ts @@ -11,11 +11,11 @@ * Run: npx tsx examples/invoice-agent.ts */ -import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' import { evaluatePolicy, type PolicyBundle } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } from '@fides/evidence' +import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' import { MockTEEProvider } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' import { LocalDiscoveryProvider } from '@fides/discovery' @@ -30,13 +30,13 @@ async function main() { console.log('📝 Step 1: Creating Identities') console.log('─'.repeat(40)) - const { identity: invoiceAgent } = await createAgentIdentity() + const { identity: invoiceAgent, privateKey: invoiceAgentPrivateKey } = await createAgentIdentity() invoiceAgent.metadata = { name: 'Invoice Processor', version: '1.0.0' } - const { identity: financeManager } = await createPrincipalIdentity({ + const { identity: financeManager, privateKey: financeManagerPrivateKey } = await createPrincipalIdentity({ type: 'individual', displayName: 'Finance Manager', }) - const { identity: cfo } = await createPrincipalIdentity({ + const { identity: cfo, privateKey: cfoPrivateKey } = await createPrincipalIdentity({ type: 'individual', displayName: 'CFO', }) @@ -127,7 +127,7 @@ async function main() { console.log('─'.repeat(40)) // CFO delegates invoice processing to the agent with spending limits - const cfoDelegation = createDelegationToken({ + const cfoDelegation = await signDelegationToken(createDelegationToken({ delegator: cfo.did, delegatee: invoiceAgent.did, capabilities: ['invoice:create', 'invoice:approve'], @@ -138,8 +138,7 @@ async function main() { forbiddenContexts: ['personal', 'test'], }, expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(), // 7 days - }) - cfoDelegation.signature = 'mock-cfo-sig' + }), cfoPrivateKey) const cfoValid = validateDelegationToken(cfoDelegation) console.log(` Token ID: ${cfoDelegation.id}`) @@ -152,7 +151,7 @@ async function main() { console.log() // Finance manager also delegates (chain of authority) - const mgrDelegation = createDelegationToken({ + const mgrDelegation = await signDelegationToken(createDelegationToken({ delegator: financeManager.did, delegatee: invoiceAgent.did, capabilities: ['invoice:list', 'invoice:create'], @@ -162,8 +161,7 @@ async function main() { allowedContexts: ['business'], }, expiresAt: new Date(Date.now() + 86400000).toISOString(), // 24h - }) - mgrDelegation.signature = 'mock-mgr-sig' + }), financeManagerPrivateKey) console.log(` Finance Mgr → Agent: ${mgrDelegation.delegator} → ${mgrDelegation.delegatee}`) console.log(` Max spend: $${mgrDelegation.constraints.maxSpend}`) @@ -174,9 +172,16 @@ async function main() { console.log('─'.repeat(40)) const localDiscovery = new LocalDiscoveryProvider() - localDiscovery.registerCard(agentCard) + const signedCard = await signAgentCard(agentCard, invoiceAgentPrivateKey, invoiceAgent.did) + await localDiscovery.register(signedCard) const resolved = await localDiscovery.resolve(invoiceAgent.did) + const discovered = await localDiscovery.discover({ + schema_version: 'fides.discovery_query.v1', + id: 'invoice-local-query', + capability: 'invoice:create', + }) console.log(` Registered: ${resolved ? 'yes' : 'no'}`) + console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) console.log(` Resolved: ${resolved?.identity.metadata!.name}`) console.log() @@ -321,7 +326,7 @@ async function main() { ] for (const evt of auditEvents) { - evidenceChain = appendEvidenceEvent(evidenceChain, evt, 'mock-signature') + evidenceChain = appendEvidenceEvent(evidenceChain, evt, localEvidenceSignature(evt)) console.log(` Recorded: ${evt.type} — ${evt.action} → ${evt.target}`) } @@ -399,4 +404,8 @@ async function main() { console.log() } +function localEvidenceSignature(event: unknown): string { + return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +} + main().catch(console.error) diff --git a/examples/malicious-agent.ts b/examples/malicious-agent.ts index b780281..8dc139f 100644 --- a/examples/malicious-agent.ts +++ b/examples/malicious-agent.ts @@ -67,6 +67,7 @@ function main() { schema_version: 'fides.invocation.request.v1', id: 'inv_req_malicious', issuer: 'did:fides:requester', + subject: 'did:fides:malicious-agent', session_id: 'missing-session', requester_agent_id: 'did:fides:requester', target_agent_id: 'did:fides:malicious-agent', diff --git a/examples/payment-agent.ts b/examples/payment-agent.ts index 70085fd..775bdfc 100644 --- a/examples/payment-agent.ts +++ b/examples/payment-agent.ts @@ -11,11 +11,11 @@ * Run: npx tsx examples/payment-agent.ts */ -import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' import { evaluatePolicy, type PolicyBundle } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } from '@fides/evidence' +import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' import { LocalDiscoveryProvider } from '@fides/discovery' @@ -30,9 +30,9 @@ async function main() { console.log('📝 Step 1: Creating Identities') console.log('─'.repeat(40)) - const { identity: paymentAgent } = await createAgentIdentity() + const { identity: paymentAgent, privateKey: paymentAgentPrivateKey } = await createAgentIdentity() paymentAgent.metadata = { name: 'Payment Processor', version: '1.0.0' } - const { identity: merchant } = await createPrincipalIdentity({ + const { identity: merchant, privateKey: merchantPrivateKey } = await createPrincipalIdentity({ type: 'organization', displayName: 'ACME Corp', }) @@ -126,7 +126,7 @@ async function main() { console.log('🔑 Step 4: Merchant Delegates Payment Access') console.log('─'.repeat(40)) - const merchantDelegation = createDelegationToken({ + const merchantDelegation = await signDelegationToken(createDelegationToken({ delegator: merchant.did, delegatee: paymentAgent.did, capabilities: ['payment:charge', 'payment:refund'], @@ -137,8 +137,7 @@ async function main() { forbiddenContexts: ['test', 'staging'], }, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), // 30 days - }) - merchantDelegation.signature = 'mock-merchant-sig' + }), merchantPrivateKey) const delegationValid = validateDelegationToken(merchantDelegation) console.log(` Token ID: ${merchantDelegation.id}`) @@ -152,9 +151,16 @@ async function main() { console.log('─'.repeat(40)) const localDiscovery = new LocalDiscoveryProvider() - localDiscovery.registerCard(agentCard) + const signedCard = await signAgentCard(agentCard, paymentAgentPrivateKey, paymentAgent.did) + await localDiscovery.register(signedCard) const resolved = await localDiscovery.resolve(paymentAgent.did) + const discovered = await localDiscovery.discover({ + schema_version: 'fides.discovery_query.v1', + id: 'payment-local-query', + capability: 'payment:charge', + }) console.log(` Registered: ${resolved ? 'yes' : 'no'}`) + console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) console.log(` Resolved: ${resolved?.identity.metadata!.name}`) console.log() @@ -310,7 +316,7 @@ async function main() { ] for (const evt of paymentEvents) { - evidenceChain = appendEvidenceEvent(evidenceChain, evt, 'mock-signature') + evidenceChain = appendEvidenceEvent(evidenceChain, evt, localEvidenceSignature(evt)) console.log(` Recorded: ${evt.type} — ${evt.action} → ${evt.target}`) } @@ -432,4 +438,8 @@ async function main() { console.log() } +function localEvidenceSignature(event: unknown): string { + return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +} + main().catch(console.error) diff --git a/examples/requester-agent.ts b/examples/requester-agent.ts index a7b411b..ca34037 100644 --- a/examples/requester-agent.ts +++ b/examples/requester-agent.ts @@ -11,11 +11,11 @@ * Run: npx tsx examples/requester-agent.ts */ -import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { classifyCapabilityRisk } from '@fides/core' import { evaluatePolicy, type PolicyBundle } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } from '@fides/evidence' +import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' import { LocalDiscoveryProvider } from '@fides/discovery' @@ -32,7 +32,7 @@ async function main() { const { identity: requesterAgent } = await createAgentIdentity() requesterAgent.metadata = { name: 'Task Orchestrator', version: '1.0.0' } - const { identity: user } = await createPrincipalIdentity({ + const { identity: user, privateKey: userPrivateKey } = await createPrincipalIdentity({ type: 'individual', displayName: 'Alice', }) @@ -46,7 +46,7 @@ async function main() { console.log('─'.repeat(40)) // Calendar service provider - const { identity: calendarAgent } = await createAgentIdentity() + const { identity: calendarAgent, privateKey: calendarAgentPrivateKey } = await createAgentIdentity() calendarAgent.metadata = { name: 'Calendar Service' } const calendarCapabilities: CapabilityDescriptor[] = [ { @@ -83,7 +83,7 @@ async function main() { } // Payment service provider - const { identity: paymentAgent } = await createAgentIdentity() + const { identity: paymentAgent, privateKey: paymentAgentPrivateKey } = await createAgentIdentity() paymentAgent.metadata = { name: 'Payment Service' } const paymentCapabilities: CapabilityDescriptor[] = [ { @@ -120,7 +120,7 @@ async function main() { } // Invoice service provider - const { identity: invoiceAgent } = await createAgentIdentity() + const { identity: invoiceAgent, privateKey: invoiceAgentPrivateKey } = await createAgentIdentity() invoiceAgent.metadata = { name: 'Invoice Service' } const invoiceCapabilities: CapabilityDescriptor[] = [ { @@ -166,9 +166,9 @@ async function main() { console.log('─'.repeat(40)) const localDiscovery = new LocalDiscoveryProvider() - localDiscovery.registerCard(calendarCard) - localDiscovery.registerCard(paymentCard) - localDiscovery.registerCard(invoiceCard) + await localDiscovery.register(await signAgentCard(calendarCard, calendarAgentPrivateKey, calendarAgent.did)) + await localDiscovery.register(await signAgentCard(paymentCard, paymentAgentPrivateKey, paymentAgent.did)) + await localDiscovery.register(await signAgentCard(invoiceCard, invoiceAgentPrivateKey, invoiceAgent.did)) console.log(` Registered 3 service providers`) @@ -183,14 +183,20 @@ async function main() { // List all available agents const allAgents = localDiscovery.list() + const verifiedCalendarCandidates = await localDiscovery.discover({ + schema_version: 'fides.discovery_query.v1', + id: 'requester-calendar-query', + capability: 'calendar:create', + }) console.log(` Total agents in discovery: ${allAgents.length}`) + console.log(` Verified calendar candidates: ${verifiedCalendarCandidates.filter(candidate => candidate.verified).length}`) console.log() // ─── Step 4: User Delegates to Requester ───────────────────── console.log('🔑 Step 4: User Delegates to Requester Agent') console.log('─'.repeat(40)) - const userDelegation = createDelegationToken({ + const userDelegation = await signDelegationToken(createDelegationToken({ delegator: user.did, delegatee: requesterAgent.did, capabilities: ['calendar:create', 'payment:charge', 'invoice:create'], @@ -200,8 +206,7 @@ async function main() { allowedContexts: ['work'], }, expiresAt: new Date(Date.now() + 86400000).toISOString(), - }) - userDelegation.signature = 'mock-user-sig' + }), userPrivateKey) const delegationValid = validateDelegationToken(userDelegation) console.log(` Token ID: ${userDelegation.id}`) @@ -300,7 +305,6 @@ async function main() { if (calendarGuard.decision === 'allow') { console.log(` │ 1d. ✅ Invoked calendar:create successfully`) - evidenceChain.events.length // just to reference the chain // Record evidence const calendarEvent = { @@ -313,12 +317,7 @@ async function main() { payload: { title: 'Team Meeting', date: '2026-05-06T10:00:00Z' }, privacy: { level: 'redacted' as const }, } - evidenceChain.events.push({ - ...calendarEvent, - prevHash: evidenceChain.events.length > 0 ? evidenceChain.events[evidenceChain.events.length - 1].hash : '0', - hash: 'mock-hash-001', - signature: 'mock-sig', - } as any) + appendIntoEvidenceChain(evidenceChain, calendarEvent) } else { console.log(` │ 1d. ❌ Invocation blocked: ${calendarGuard.explanation}`) } @@ -365,12 +364,7 @@ async function main() { payload: { amount: 150, currency: 'USD' }, privacy: { level: 'redacted' as const }, } - evidenceChain.events.push({ - ...paymentEvent, - prevHash: evidenceChain.events.length > 0 ? evidenceChain.events[evidenceChain.events.length - 1].hash : '0', - hash: 'mock-hash-002', - signature: 'mock-sig', - } as any) + appendIntoEvidenceChain(evidenceChain, paymentEvent) } else { console.log(` │ 2d. ❌ Invocation blocked: ${paymentGuard.explanation}`) } @@ -454,7 +448,7 @@ async function main() { let fullChain = createEvidenceChain() for (const evt of [...evidenceChain.events, ...policyEvents]) { - fullChain = appendEvidenceEvent(fullChain, evt, 'mock-signature') + fullChain = appendEvidenceEvent(fullChain, evt, localEvidenceSignature(evt)) console.log(` Recorded: ${evt.type} — ${evt.action} → ${evt.target}`) } @@ -499,4 +493,17 @@ async function main() { console.log() } +function appendIntoEvidenceChain( + chain: ReturnType, + event: Parameters[1] +): void { + const next = appendEvidenceEvent(chain, event, localEvidenceSignature(event)) + chain.events = next.events + chain.merkleRoot = next.merkleRoot +} + +function localEvidenceSignature(event: unknown): string { + return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +} + main().catch(console.error) diff --git a/packages/guard/demo.ts b/packages/guard/demo.ts index b92b93f..56e8ce1 100644 --- a/packages/guard/demo.ts +++ b/packages/guard/demo.ts @@ -5,10 +5,10 @@ * Run: pnpm demo */ -import { createAgentIdentity, createPrincipalIdentity, classifyCapabilityRisk, validateAgentCard, createDelegationToken, validateDelegationToken } from '@fides/core' +import { createAgentIdentity, createPrincipalIdentity, classifyCapabilityRisk, validateAgentCard, createDelegationToken, validateDelegationToken, signDelegationToken } from '@fides/core' import type { AgentCard, CapabilityDescriptor } from '@fides/core' import { evaluatePolicy } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, buildMerkleRoot, verifyEvidenceChain } from '@fides/evidence' +import { createEvidenceChain, appendEvidenceEvent, buildMerkleRoot, verifyEvidenceChain, hashEvidenceValue } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateGuard, createTrustContext } from './src/index.js' @@ -24,7 +24,7 @@ async function demo() { alice.metadata = { name: 'Alice Assistant' } const { identity: bob } = await createAgentIdentity() bob.metadata = { name: 'Bob Scheduler' } - const { identity: charlie } = await createPrincipalIdentity({ + const { identity: charlie, privateKey: charliePrivateKey } = await createPrincipalIdentity({ type: 'individual', displayName: 'Charlie User', }) @@ -62,13 +62,13 @@ async function demo() { // Step 4: Delegation console.log('🔑 Step 4: Delegation Token') - const delegation = createDelegationToken({ + const delegation = await signDelegationToken(createDelegationToken({ delegator: charlie.did, delegatee: alice.did, capabilities: ['email:send', 'calendar:create'], constraints: { maxActions: 10, maxSpend: '10.00', allowedContexts: ['work'] }, expiresAt: new Date(Date.now() + 3600000).toISOString(), - }) - const valid = validateDelegationToken({ ...delegation, signature: 'demo-signature' }) + }), charliePrivateKey) + const valid = validateDelegationToken(delegation) console.log(` Token: ${delegation.id}`) console.log(` Delegator: ${delegation.delegator} → ${delegation.delegatee}`) console.log(` Valid: ${valid.valid}`) @@ -98,7 +98,7 @@ async function demo() { { id: 'e2', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'calendar:create', payload: {}, privacy: { level: 'hash_only' as const } }, { id: 'e3', type: 'policy', timestamp: new Date().toISOString(), actor: alice.did, action: 'evaluate', payload: {}, privacy: { level: 'public' as const } }, ]) { - chain = appendEvidenceEvent(chain, evt, 'mock-sig') + chain = appendEvidenceEvent(chain, evt, localEvidenceSignature(evt)) } console.log(` Events: ${chain.events.length}`) console.log(` Chain valid: ${verifyEvidenceChain(chain)}`) @@ -142,4 +142,8 @@ async function demo() { console.log('═'.repeat(60)) } +function localEvidenceSignature(event: unknown): string { + return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +} + demo().catch(console.error) From 42e66af92d17c87de4df54e2d561db2c74334c76 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:32:35 +0300 Subject: [PATCH 151/282] fix(discovery-service): allow url-less local agents --- docs/api-reference.md | 4 ++ services/discovery/src/routes/agents.ts | 12 ++++-- services/discovery/src/types.ts | 4 +- services/discovery/test/routes.test.ts | 49 +++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index e8967a4..0ae915a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -191,6 +191,10 @@ discovery also negotiate protocol compatibility between query `protocolVersions`; incompatible candidates are omitted from provider results and reported under `rejectedCandidates`, `rejectedRecords`, or `rejectedPointers` with `VERSION_INCOMPATIBLE`. +The standalone discovery service follows the same URL-less rule for local +candidate registration: `POST /agents` requires `did` and `name`, but can omit +`url`. In that case the service stores a `local://agents/` transport hint +and returns `urlRequired: false` plus `authorityGranted: false`. Federation discovery wraps verified local registry records with a signed `RegistryPeerRecord`, marks them as provider `federation`, and reports incompatible records under `rejectedRecords`. Federation expands discovery diff --git a/services/discovery/src/routes/agents.ts b/services/discovery/src/routes/agents.ts index d25f997..0c010a2 100644 --- a/services/discovery/src/routes/agents.ts +++ b/services/discovery/src/routes/agents.ts @@ -25,9 +25,15 @@ function toAgentResponse(agent: typeof agents.$inferSelect, identity: typeof ide heartbeatAt: agent.heartbeatAt.toISOString(), createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), + urlRequired: false, + authorityGranted: false, } } +function localAgentUrl(did: string): string { + return `local://agents/${encodeURIComponent(did)}` +} + // GET /agents - Search agents by capability, status, tag, provider agentsRouter.get('/', async (c) => { try { @@ -90,8 +96,8 @@ agentsRouter.post('/', async (c) => { try { const body = await c.req.json() - if (!body.did || !body.name || !body.url) { - return c.json({ error: 'Missing required fields: did, name, url' }, 400) + if (!body.did || !body.name) { + return c.json({ error: 'Missing required fields: did, name' }, 400) } if (!body.did.startsWith(DID_PREFIX)) { @@ -109,7 +115,7 @@ agentsRouter.post('/', async (c) => { did: body.did, name: body.name, description: body.description || null, - url: body.url, + url: body.url || localAgentUrl(body.did), version: body.version || '1.0.0', provider: body.provider || null, capabilities: body.capabilities || {}, diff --git a/services/discovery/src/types.ts b/services/discovery/src/types.ts index c08b5f2..efd45c9 100644 --- a/services/discovery/src/types.ts +++ b/services/discovery/src/types.ts @@ -52,7 +52,7 @@ export interface RegisterAgentRequest { did: string name: string description?: string - url: string + url?: string version?: string provider?: { organization: string; url?: string } capabilities?: { @@ -91,4 +91,6 @@ export interface AgentResponse { heartbeatAt: string createdAt: string updatedAt: string + urlRequired: false + authorityGranted: false } diff --git a/services/discovery/test/routes.test.ts b/services/discovery/test/routes.test.ts index 1adfc08..0bd2e64 100644 --- a/services/discovery/test/routes.test.ts +++ b/services/discovery/test/routes.test.ts @@ -279,6 +279,55 @@ describe('Discovery Service Routes', () => { }) }) + describe('POST /agents', () => { + it('registers URL-less local agent candidates without granting authority', async () => { + const { db } = await import('../src/db/client.js') + const now = new Date('2026-05-30T00:00:00.000Z') + vi.mocked(db.insert).mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ + did: TEST_DID, + name: 'URL-less Agent', + description: null, + url: `local://agents/${encodeURIComponent(TEST_DID)}`, + version: '1.0.0', + provider: null, + capabilities: {}, + skills: [{ id: 'invoice.reconcile', name: 'Invoice Reconcile' }], + defaultInputModes: [], + defaultOutputModes: [], + status: 'online', + heartbeatAt: now, + createdAt: now, + updatedAt: now, + }]), + }), + } as any) + + const req = new Request('http://localhost/agents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + did: TEST_DID, + name: 'URL-less Agent', + skills: [{ id: 'invoice.reconcile', name: 'Invoice Reconcile' }], + }), + }) + + const res = await app.fetch(req) + + expect(res.status).toBe(201) + const data = await res.json() + expect(data).toMatchObject({ + did: TEST_DID, + name: 'URL-less Agent', + url: `local://agents/${encodeURIComponent(TEST_DID)}`, + urlRequired: false, + authorityGranted: false, + }) + }) + }) + describe('POST /identities/:did/domain/verify', () => { it('verifies and persists domain ownership', async () => { const dns = await import('node:dns/promises') From e4a363161cf438aa1fd61ea56748c87603cfed82 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:33:13 +0300 Subject: [PATCH 152/282] docs(api): allow url-less discovery agents --- docs/api/discovery.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/api/discovery.yaml b/docs/api/discovery.yaml index dc1ae87..d51feac 100644 --- a/docs/api/discovery.yaml +++ b/docs/api/discovery.yaml @@ -610,7 +610,7 @@ components: RegisterAgentRequest: type: object - required: [did, name, url] + required: [did, name] properties: did: type: string @@ -620,7 +620,7 @@ components: type: string url: type: string - format: uri + description: Optional transport URL. When omitted, the service stores a local://agents/ hint. version: type: string provider: @@ -686,6 +686,14 @@ components: updatedAt: type: string format: date-time + urlRequired: + type: boolean + enum: [false] + description: Agent registration and discovery do not require an HTTP URL. + authorityGranted: + type: boolean + enum: [false] + description: Discovery records are candidates only and never grant invocation authority. DiscoveryDocument: type: object From a9368b9e24b6b313dbd15262e6f836d4ce1b539b Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:34:45 +0300 Subject: [PATCH 153/282] fix(sdk): support url-less discovery registration --- packages/sdk/README.md | 7 ++++--- packages/sdk/src/discovery/agent-client.ts | 2 +- packages/sdk/test/discovery.test.ts | 1 - packages/shared/src/types.ts | 2 ++ 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index c7dfbe3..08e1a7c 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -263,11 +263,12 @@ await agents.registerAgent({ did: 'did:fides:agent', name: 'Payment Agent', description: 'Executes approved payment workflows', - capabilities: ['payments.execute'], - endpoints: [{ type: 'mcp', url: 'https://agent.example.com/mcp' }], - trustLevel: 'high', + skills: [{ id: 'payments.prepare', name: 'Prepare payment dry-runs' }], }) +// URL-less registration is supported. The discovery service stores a +// local://agents/ transport hint and returns authorityGranted: false. + await agents.heartbeat('did:fides:agent') ``` diff --git a/packages/sdk/src/discovery/agent-client.ts b/packages/sdk/src/discovery/agent-client.ts index d63bd11..e28eac2 100644 --- a/packages/sdk/src/discovery/agent-client.ts +++ b/packages/sdk/src/discovery/agent-client.ts @@ -5,7 +5,7 @@ export interface RegisterAgentParams { did: string name: string description?: string - url: string + url?: string version?: string provider?: AgentProvider capabilities?: AgentCapabilities diff --git a/packages/sdk/test/discovery.test.ts b/packages/sdk/test/discovery.test.ts index fb6eb2c..c23fc27 100644 --- a/packages/sdk/test/discovery.test.ts +++ b/packages/sdk/test/discovery.test.ts @@ -267,7 +267,6 @@ describe('AgentDiscoveryClient', () => { await client.registerAgent({ did: 'did:fides:agent', name: 'Agent', - url: 'https://agent.example.com', }) await client.updateAgent('did:fides:agent', { name: 'Agent v2' }) await client.heartbeat('did:fides:agent') diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2b9e4b1..d9836fe 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -130,6 +130,8 @@ export interface AgentCard { heartbeatAt?: string createdAt: string updatedAt: string + urlRequired?: false + authorityGranted?: false } export interface AgentCardQuery { From 30097e8b126a6d879983d1011a353f0f5ec3cffb Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:36:28 +0300 Subject: [PATCH 154/282] docs: align legacy protocol spec with fides v2 --- README.md | 27 +++++++++++++++++++-------- docs/protocol-spec.md | 19 ++++++++++++++++++- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index eb26729..a019c96 100644 --- a/README.md +++ b/README.md @@ -56,27 +56,37 @@ pnpm build ### Basic Usage ```typescript -import { createIdentity, classifyCapabilityRisk, createDelegationToken } from '@fides/core' +import { + createAgentIdentity, + createPrincipalIdentity, + classifyCapabilityRisk, + createDelegationToken, + signDelegationToken, +} from '@fides/core' import { evaluatePolicy } from '@fides/policy' import { evaluateGuard, createTrustContext } from '@fides/guard' -import { createEvidenceChain, appendEvidenceEvent } from '@fides/evidence' +import { createEvidenceChain, appendEvidenceEvent, hashEvidenceValue } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' // Create agent identities -const alice = createIdentity('did:fides:alice', 'agent', { name: 'Alice Assistant' }) -const charlie = createIdentity('did:fides:charlie', 'principal', { name: 'Charlie User' }) +const { identity: alice } = await createAgentIdentity() +alice.metadata = { name: 'Alice Assistant' } +const { identity: charlie, privateKey: charliePrivateKey } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Charlie User', +}) // Classify capability risk const risk = classifyCapabilityRisk('email:send') // 'high' // Delegate capabilities with constraints -const token = createDelegationToken({ +const token = await signDelegationToken(createDelegationToken({ delegator: charlie.did, delegatee: alice.did, capabilities: ['email:send', 'calendar:create'], constraints: { maxActions: 10, maxSpend: '10.00', allowedContexts: ['work'] }, expiresAt: new Date(Date.now() + 3600000).toISOString(), -}) +}), charliePrivateKey) // Evaluate policy const policy = { @@ -90,11 +100,12 @@ const result = evaluatePolicy(policy, { reputationScore: 0.9 }) // Build evidence chain let chain = createEvidenceChain() -chain = appendEvidenceEvent(chain, { +const event = { id: 'e1', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'email:send', payload: {}, privacy: { level: 'redacted' }, -}, 'signature-hex') +} +chain = appendEvidenceEvent(chain, event, `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}`) // Run guard decision const trust = createTrustContext({ diff --git a/docs/protocol-spec.md b/docs/protocol-spec.md index 86345d6..683c938 100644 --- a/docs/protocol-spec.md +++ b/docs/protocol-spec.md @@ -1,5 +1,17 @@ # FIDES Protocol Specification +> **Legacy v1 draft.** This document is retained for historical context only. +> FIDES v2 is the active protocol direction. Use +> [docs/protocol/fides-v2-spec.md](protocol/fides-v2-spec.md), +> [docs/protocol/canonical-object-signing.md](protocol/canonical-object-signing.md), +> [docs/protocol/discovery.md](protocol/discovery.md), +> [docs/protocol/policy-engine.md](protocol/policy-engine.md), +> [docs/protocol/evidence-ledger.md](protocol/evidence-ledger.md), and +> [docs/protocol/revocation.md](protocol/revocation.md) for current behavior. +> In v2, discovery never grants authority, signed AgentCards are required for +> trusted local discovery paths, revocation exists, and scoped SessionGrants are +> required before invocation. + ## Overview FIDES (Federated Identity and Decentralized Endorsement System) is a protocol enabling autonomous AI agents to establish cryptographic identities, authenticate requests, and build trust relationships. @@ -598,7 +610,7 @@ Response: 200 OK - Private key theft from compromised systems - Social engineering attacks -### Known Limitations +### Legacy v1 Known Limitations 1. **No Nonce Tracking:** Replay attacks possible within 300-second window 2. **No Clock Drift Tolerance:** Strict timestamp checking requires synchronized clocks @@ -608,6 +620,11 @@ Response: 200 OK 6. **Centralized Discovery:** Single point of failure (mitigated by .well-known) 7. **Simple Trust Decay:** Vulnerable to Sybil attacks +These are not accepted FIDES v2 limitations. The v2 architecture adds +revocation records, session nonces, signed AgentCards, multi-provider discovery, +policy-before-execution, runtime attestation, kill switches, incident records, +and tamper-evident evidence events. + ### Recommended Practices 1. **Secure key storage:** Encrypt private keys, use strong passwords From 02e7adc82cee0c1c16fc12c01f4f82a576c60ea1 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:37:27 +0300 Subject: [PATCH 155/282] docs: update package quickstarts for v2 signing --- packages/core/README.md | 22 +++++++++++++++------- packages/evidence/README.md | 11 +++++++++-- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 5417a25..ff0760a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -13,19 +13,27 @@ npm install @fides/core ## Usage ```typescript -import { createIdentity, createDelegationToken, classifyCapabilityRisk } from '@fides/core' - -const principal = createIdentity('did:fides:principal', 'principal') -const agent = createIdentity('did:fides:agent', 'agent') +import { + createAgentIdentity, + createPrincipalIdentity, + createDelegationToken, + signDelegationToken, + classifyCapabilityRisk, +} from '@fides/core' + +const { identity: principal, privateKey: principalPrivateKey } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Operator', +}) +const { identity: agent } = await createAgentIdentity() -const token = createDelegationToken({ +const token = await signDelegationToken(createDelegationToken({ delegator: principal.did, delegatee: agent.did, capabilities: ['payments.execute'], - capabilityId: 'payments.execute', constraints: { maxActions: 3 }, expiresAt: new Date(Date.now() + 60_000).toISOString(), -}) +}), principalPrivateKey) const risk = classifyCapabilityRisk('payments.execute') ``` diff --git a/packages/evidence/README.md b/packages/evidence/README.md index 09dbecb..7f0280b 100644 --- a/packages/evidence/README.md +++ b/packages/evidence/README.md @@ -19,13 +19,14 @@ import { appendEvidenceEvent, buildEvidenceMerkleProof, createEvidenceChain, + hashEvidenceValue, verifyEvidenceChain, verifyMerkleProof, } from '@fides/evidence' let chain = createEvidenceChain() -chain = appendEvidenceEvent(chain, { +const event = { id: 'evt_1', type: 'invoke', timestamp: new Date().toISOString(), @@ -33,7 +34,13 @@ chain = appendEvidenceEvent(chain, { action: 'payments.execute', payload: { amount: '10.00' }, privacy: { level: 'redacted' }, -}, 'signature-hex') +} + +chain = appendEvidenceEvent( + chain, + event, + `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +) const valid = verifyEvidenceChain(chain) const proof = buildEvidenceMerkleProof(chain, 'evt_1') From 49348625f536a8e316ff4057bb29f087f73f4604 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:39:15 +0300 Subject: [PATCH 156/282] fix(discovery-service): mark candidates unverified --- docs/api-reference.md | 5 ++++- docs/api/discovery.yaml | 9 +++++++++ services/discovery/src/routes/agents.ts | 6 ++++++ services/discovery/src/types.ts | 2 ++ services/discovery/test/routes.test.ts | 3 +++ 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 0ae915a..1cc742f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -194,7 +194,10 @@ and reported under `rejectedCandidates`, `rejectedRecords`, or The standalone discovery service follows the same URL-less rule for local candidate registration: `POST /agents` requires `did` and `name`, but can omit `url`. In that case the service stores a `local://agents/` transport hint -and returns `urlRequired: false` plus `authorityGranted: false`. +and returns `verified: false`, `urlRequired: false`, `authorityGranted: false`, +and machine-readable reasons. Signed AgentCard verification happens in the +root `agentd` local discovery path, not in this legacy standalone metadata +registry. Federation discovery wraps verified local registry records with a signed `RegistryPeerRecord`, marks them as provider `federation`, and reports incompatible records under `rejectedRecords`. Federation expands discovery diff --git a/docs/api/discovery.yaml b/docs/api/discovery.yaml index d51feac..4cce0ac 100644 --- a/docs/api/discovery.yaml +++ b/docs/api/discovery.yaml @@ -686,6 +686,10 @@ components: updatedAt: type: string format: date-time + verified: + type: boolean + enum: [false] + description: Standalone discovery service records are candidate metadata and do not verify signed AgentCards. urlRequired: type: boolean enum: [false] @@ -694,6 +698,11 @@ components: type: boolean enum: [false] description: Discovery records are candidates only and never grant invocation authority. + reasons: + type: array + items: + type: string + description: Machine-readable explanation codes for candidate-only discovery semantics. DiscoveryDocument: type: object diff --git a/services/discovery/src/routes/agents.ts b/services/discovery/src/routes/agents.ts index 0c010a2..8e184a7 100644 --- a/services/discovery/src/routes/agents.ts +++ b/services/discovery/src/routes/agents.ts @@ -25,8 +25,14 @@ function toAgentResponse(agent: typeof agents.$inferSelect, identity: typeof ide heartbeatAt: agent.heartbeatAt.toISOString(), createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), + verified: false, urlRequired: false, authorityGranted: false, + reasons: [ + 'standalone_discovery_candidate', + 'signed_agent_card_not_verified_by_discovery_service', + 'discovery_does_not_grant_authority', + ], } } diff --git a/services/discovery/src/types.ts b/services/discovery/src/types.ts index efd45c9..fce474a 100644 --- a/services/discovery/src/types.ts +++ b/services/discovery/src/types.ts @@ -91,6 +91,8 @@ export interface AgentResponse { heartbeatAt: string createdAt: string updatedAt: string + verified: false urlRequired: false authorityGranted: false + reasons: string[] } diff --git a/services/discovery/test/routes.test.ts b/services/discovery/test/routes.test.ts index 0bd2e64..d2bc563 100644 --- a/services/discovery/test/routes.test.ts +++ b/services/discovery/test/routes.test.ts @@ -322,9 +322,12 @@ describe('Discovery Service Routes', () => { did: TEST_DID, name: 'URL-less Agent', url: `local://agents/${encodeURIComponent(TEST_DID)}`, + verified: false, urlRequired: false, authorityGranted: false, }) + expect(data.reasons).toContain('signed_agent_card_not_verified_by_discovery_service') + expect(data.reasons).toContain('discovery_does_not_grant_authority') }) }) From 0f8d0d87251a190ca7e6d914a0a4d465cb990f30 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:42:15 +0300 Subject: [PATCH 157/282] test(discovery-service): lock candidate response semantics --- docs/api/discovery.yaml | 17 +++- services/discovery/test/routes.test.ts | 106 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/docs/api/discovery.yaml b/docs/api/discovery.yaml index 4cce0ac..c2f9d32 100644 --- a/docs/api/discovery.yaml +++ b/docs/api/discovery.yaml @@ -624,7 +624,7 @@ components: version: type: string provider: - type: string + $ref: "#/components/schemas/AgentProvider" capabilities: type: object skills: @@ -660,7 +660,8 @@ components: type: string example: ed25519 provider: - type: string + allOf: + - $ref: "#/components/schemas/AgentProvider" nullable: true capabilities: type: object @@ -742,7 +743,7 @@ components: version: type: string provider: - type: string + $ref: "#/components/schemas/AgentProvider" capabilities: type: object skills: @@ -775,6 +776,16 @@ components: type: string example: "Agent deregistered" + AgentProvider: + type: object + required: [organization] + properties: + organization: + type: string + url: + type: string + format: uri + responses: BadRequest: description: Invalid request diff --git a/services/discovery/test/routes.test.ts b/services/discovery/test/routes.test.ts index d2bc563..367d071 100644 --- a/services/discovery/test/routes.test.ts +++ b/services/discovery/test/routes.test.ts @@ -331,6 +331,112 @@ describe('Discovery Service Routes', () => { }) }) + describe('GET /agents', () => { + it('marks listed agents as unverified candidates without authority', async () => { + const { db } = await import('../src/db/client.js') + const now = new Date('2026-05-30T00:00:00.000Z') + const agent = { + did: TEST_DID, + name: 'Listed Agent', + description: null, + url: `local://agents/${encodeURIComponent(TEST_DID)}`, + version: '1.0.0', + provider: { organization: 'example', url: 'https://example.com' }, + capabilities: {}, + skills: [{ id: 'invoice.reconcile', name: 'Invoice Reconcile' }], + defaultInputModes: [], + defaultOutputModes: [], + status: 'online', + heartbeatAt: now, + createdAt: now, + updatedAt: now, + } + const identity = { + did: TEST_DID, + publicKey: TEST_PUBLIC_KEY, + } + + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + offset: vi.fn().mockResolvedValue([{ agents: agent, identities: identity }]), + }), + }), + }), + }), + } as any) + + const res = await app.fetch(new Request('http://localhost/agents?capability=invoice.reconcile')) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data).toHaveLength(1) + expect(data[0]).toMatchObject({ + did: TEST_DID, + name: 'Listed Agent', + provider: { organization: 'example', url: 'https://example.com' }, + verified: false, + urlRequired: false, + authorityGranted: false, + }) + expect(data[0].reasons).toContain('standalone_discovery_candidate') + expect(data[0].reasons).toContain('signed_agent_card_not_verified_by_discovery_service') + expect(data[0].reasons).toContain('discovery_does_not_grant_authority') + }) + }) + + describe('GET /agents/:did', () => { + it('marks agent detail responses as unverified candidates without authority', async () => { + const { db } = await import('../src/db/client.js') + const now = new Date('2026-05-30T00:00:00.000Z') + const agent = { + did: TEST_DID, + name: 'Detail Agent', + description: null, + url: `local://agents/${encodeURIComponent(TEST_DID)}`, + version: '1.0.0', + provider: null, + capabilities: {}, + skills: [{ id: 'calendar.schedule', name: 'Calendar Schedule' }], + defaultInputModes: [], + defaultOutputModes: [], + status: 'online', + heartbeatAt: now, + createdAt: now, + updatedAt: now, + } + const identity = { + did: TEST_DID, + publicKey: TEST_PUBLIC_KEY, + } + + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ agents: agent, identities: identity }]), + }), + }), + } as any) + + const res = await app.fetch(new Request(`http://localhost/agents/${encodeURIComponent(TEST_DID)}`)) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data).toMatchObject({ + did: TEST_DID, + name: 'Detail Agent', + verified: false, + urlRequired: false, + authorityGranted: false, + }) + expect(data.reasons).toContain('standalone_discovery_candidate') + expect(data.reasons).toContain('signed_agent_card_not_verified_by_discovery_service') + expect(data.reasons).toContain('discovery_does_not_grant_authority') + }) + }) + describe('POST /identities/:did/domain/verify', () => { it('verifies and persists domain ownership', async () => { const dns = await import('node:dns/promises') From 43688d0560856c708f5c5d0b70dede48a307a27c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:44:08 +0300 Subject: [PATCH 158/282] fix(sdk): type discovery candidate metadata --- docs/sdk-reference.md | 4 ++- packages/sdk/README.md | 7 ++-- packages/sdk/test/discovery.test.ts | 54 +++++++++++++++++++++++++++++ packages/shared/src/types.ts | 2 ++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index f8ad0cf..95a88d9 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -183,7 +183,9 @@ await client.evidence.export({ privacy_mode: 'hash_only', include_metadata: fals intentionally thin. The AgentCard helpers target root `agentd` AgentCard endpoints and use daemon-held local identity keys for signing. Agent registration and discovery return candidates only; `authorityGranted` remains -`false`. Trust and reputation APIs return capability-scoped signals, and policy +`false`. Standalone discovery candidate metadata also carries `verified: false` +and machine-readable `reasons`, which the SDK preserves on returned AgentCard +objects. Trust and reputation APIs return capability-scoped signals, and policy evaluation explains the decision but still requires session grant issuance before invocation. Delegation helpers create local DelegationToken intents; the daemon signs them when the delegator identity is locally managed, but they still diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 08e1a7c..f4b6366 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -154,8 +154,11 @@ await client.evidence.export({ privacy_mode: 'hash_only', include_metadata: fals The local identity API returns public identity data only; it does not return private keys. AgentCard signing uses the daemon-held local identity key. Registration and discovery produce candidate records only; discovery does not -grant authority to invoke the agent. Trust and reputation are capability-scoped -signals; policy decisions still require scoped session grants before invocation. +grant authority to invoke the agent. Standalone discovery responses preserve +`verified: false`, `authorityGranted: false`, and machine-readable `reasons` +so SDK callers do not accidentally treat metadata discovery as trust or +permission. Trust and reputation are capability-scoped signals; policy +decisions still require scoped session grants before invocation. Root session and invocation helpers use the local daemon preflight path and are currently in-memory. Approval and kill switch helpers expose local authority controls, with active kill switch rules overriding normal policy. Revocation diff --git a/packages/sdk/test/discovery.test.ts b/packages/sdk/test/discovery.test.ts index c23fc27..e8351fe 100644 --- a/packages/sdk/test/discovery.test.ts +++ b/packages/sdk/test/discovery.test.ts @@ -311,6 +311,60 @@ describe('AgentDiscoveryClient', () => { }) ) }) + + it('preserves candidate-only discovery metadata on agent responses', async () => { + const agent = { + did: 'did:fides:agent', + name: 'Agent', + url: 'local://agents/did%3Afides%3Aagent', + version: '1.0.0', + publicKey: '00'.repeat(32), + algorithm: 'ed25519', + capabilities: {}, + skills: [{ id: 'invoice.reconcile', name: 'Invoice Reconcile' }], + status: 'online', + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + verified: false, + urlRequired: false, + authorityGranted: false, + reasons: [ + 'standalone_discovery_candidate', + 'signed_agent_card_not_verified_by_discovery_service', + 'discovery_does_not_grant_authority', + ], + } as const + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => agent, + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => [agent], + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => agent, + }) + + const registered = await client.registerAgent({ + did: agent.did, + name: agent.name, + }) + const discovered = await client.discoverAgents({ capability: 'invoice.reconcile' }) + const fetched = await client.getAgent(agent.did) + + expect(registered).toMatchObject({ + verified: false, + urlRequired: false, + authorityGranted: false, + }) + expect(registered.reasons).toContain('discovery_does_not_grant_authority') + expect(discovered[0].reasons).toContain('standalone_discovery_candidate') + expect(fetched?.reasons).toContain('signed_agent_card_not_verified_by_discovery_service') + }) }) describe('IdentityResolver', () => { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index d9836fe..d13e4c9 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -130,8 +130,10 @@ export interface AgentCard { heartbeatAt?: string createdAt: string updatedAt: string + verified?: false urlRequired?: false authorityGranted?: false + reasons?: string[] } export interface AgentCardQuery { From f27c050d9fde08c0f0b2fb35d3d930d2b47b978e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:44:58 +0300 Subject: [PATCH 159/282] fix(sdk): invalidate discovery cache on registration --- packages/sdk/src/discovery/agent-client.ts | 4 +- packages/sdk/test/discovery.test.ts | 48 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/discovery/agent-client.ts b/packages/sdk/src/discovery/agent-client.ts index e28eac2..c9a4992 100644 --- a/packages/sdk/src/discovery/agent-client.ts +++ b/packages/sdk/src/discovery/agent-client.ts @@ -51,7 +51,9 @@ export class AgentDiscoveryClient { throw new DiscoveryError(`Failed to register agent: ${response.status} ${text}`) } - return await response.json() + const agent: AgentCard = await response.json() + this.clearCache() + return agent } catch (error) { if (error instanceof DiscoveryError) throw error throw new DiscoveryError( diff --git a/packages/sdk/test/discovery.test.ts b/packages/sdk/test/discovery.test.ts index e8351fe..f3960bf 100644 --- a/packages/sdk/test/discovery.test.ts +++ b/packages/sdk/test/discovery.test.ts @@ -365,6 +365,54 @@ describe('AgentDiscoveryClient', () => { expect(discovered[0].reasons).toContain('standalone_discovery_candidate') expect(fetched?.reasons).toContain('signed_agent_card_not_verified_by_discovery_service') }) + + it('invalidates cached discovery results after registering an agent', async () => { + const oldAgent = { + did: 'did:fides:old', + name: 'Old Agent', + url: 'local://agents/did%3Afides%3Aold', + version: '1.0.0', + publicKey: '00'.repeat(32), + algorithm: 'ed25519', + skills: [{ id: 'invoice.reconcile', name: 'Invoice Reconcile' }], + status: 'online', + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + } + const newAgent = { + did: 'did:fides:new', + name: 'New Agent', + url: 'local://agents/did%3Afides%3Anew', + version: '1.0.0', + publicKey: '11'.repeat(32), + algorithm: 'ed25519', + skills: [{ id: 'invoice.reconcile', name: 'Invoice Reconcile' }], + status: 'online', + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + } + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => [oldAgent], + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => newAgent, + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => [newAgent], + }) + + await expect(client.discoverAgents({ capability: 'invoice.reconcile' })) + .resolves.toEqual([oldAgent]) + await client.registerAgent({ did: newAgent.did, name: newAgent.name }) + await expect(client.discoverAgents({ capability: 'invoice.reconcile' })) + .resolves.toEqual([newAgent]) + expect(mockFetch).toHaveBeenCalledTimes(3) + }) }) describe('IdentityResolver', () => { From fc673a6c15668f4f8e6b7aaf1920cdc0a6a5b406 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:45:46 +0300 Subject: [PATCH 160/282] fix(sdk): invalidate discovery cache on heartbeat --- packages/sdk/src/discovery/agent-client.ts | 2 ++ packages/sdk/test/discovery.test.ts | 39 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/packages/sdk/src/discovery/agent-client.ts b/packages/sdk/src/discovery/agent-client.ts index c9a4992..a3543d4 100644 --- a/packages/sdk/src/discovery/agent-client.ts +++ b/packages/sdk/src/discovery/agent-client.ts @@ -174,6 +174,8 @@ export class AgentDiscoveryClient { const text = await response.text() throw new DiscoveryError(`Heartbeat failed: ${response.status} ${text}`) } + + this.clearCache() } catch (error) { if (error instanceof DiscoveryError) throw error throw new DiscoveryError( diff --git a/packages/sdk/test/discovery.test.ts b/packages/sdk/test/discovery.test.ts index f3960bf..f87dd82 100644 --- a/packages/sdk/test/discovery.test.ts +++ b/packages/sdk/test/discovery.test.ts @@ -413,6 +413,45 @@ describe('AgentDiscoveryClient', () => { .resolves.toEqual([newAgent]) expect(mockFetch).toHaveBeenCalledTimes(3) }) + + it('invalidates cached discovery results after heartbeat', async () => { + const offlineAgent = { + did: 'did:fides:agent', + name: 'Agent', + url: 'local://agents/did%3Afides%3Aagent', + version: '1.0.0', + publicKey: '00'.repeat(32), + algorithm: 'ed25519', + skills: [{ id: 'invoice.reconcile', name: 'Invoice Reconcile' }], + status: 'offline', + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + } + const onlineAgent = { + ...offlineAgent, + status: 'online', + updatedAt: '2026-05-30T00:01:00.000Z', + } + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => [offlineAgent], + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'online', heartbeatAt: '2026-05-30T00:01:00.000Z' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => [onlineAgent], + }) + + await expect(client.discoverAgents({ capability: 'invoice.reconcile' })).resolves.toEqual([offlineAgent]) + await client.heartbeat(offlineAgent.did) + await expect(client.discoverAgents({ capability: 'invoice.reconcile' })).resolves.toEqual([onlineAgent]) + expect(mockFetch).toHaveBeenCalledTimes(3) + }) }) describe('IdentityResolver', () => { From d1f8ddb7f401390135b7ef3a0238a0c4486a4786 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:47:22 +0300 Subject: [PATCH 161/282] fix(discovery-service): mark presence writes unauthorized --- docs/api/discovery.yaml | 8 +++++++ services/discovery/src/routes/agents.ts | 4 ++-- services/discovery/test/routes.test.ts | 28 ++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/api/discovery.yaml b/docs/api/discovery.yaml index c2f9d32..12caf7b 100644 --- a/docs/api/discovery.yaml +++ b/docs/api/discovery.yaml @@ -384,6 +384,10 @@ paths: heartbeatAt: type: string format: date-time + authorityGranted: + type: boolean + enum: [false] + description: Heartbeat updates presence only and do not grant invocation authority. /.well-known/fides.json: get: @@ -771,10 +775,14 @@ components: MessageResponse: type: object + required: [message, authorityGranted] properties: message: type: string example: "Agent deregistered" + authorityGranted: + type: boolean + enum: [false] AgentProvider: type: object diff --git a/services/discovery/src/routes/agents.ts b/services/discovery/src/routes/agents.ts index 8e184a7..351a160 100644 --- a/services/discovery/src/routes/agents.ts +++ b/services/discovery/src/routes/agents.ts @@ -218,7 +218,7 @@ agentsRouter.put('/:did/heartbeat', async (c) => { return c.json({ error: 'Agent not found' }, 404) } - return c.json({ status: 'online', heartbeatAt: now.toISOString() }) + return c.json({ status: 'online', heartbeatAt: now.toISOString(), authorityGranted: false }) } catch (error) { return c.json({ error: 'Internal server error' }, 500) } @@ -238,7 +238,7 @@ agentsRouter.delete('/:did', async (c) => { return c.json({ error: 'Agent not found' }, 404) } - return c.json({ message: 'Agent deregistered' }) + return c.json({ message: 'Agent deregistered', authorityGranted: false }) } catch (error) { return c.json({ error: 'Internal server error' }, 500) } diff --git a/services/discovery/test/routes.test.ts b/services/discovery/test/routes.test.ts index 367d071..021adb5 100644 --- a/services/discovery/test/routes.test.ts +++ b/services/discovery/test/routes.test.ts @@ -55,6 +55,11 @@ vi.mock('../src/db/client.js', () => { }), }), }), + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ did: baseIdentity.did }]), + }), + }), } // Create a mock sql that supports template literal calls (e.g., sql`SELECT 1`) @@ -140,7 +145,7 @@ describe('Discovery Service Routes', () => { const allowedRes = await app.fetch(allowedReq) expect(allowedRes.status).toBe(200) - expect(await allowedRes.json()).toMatchObject({ status: 'online' }) + expect(await allowedRes.json()).toMatchObject({ status: 'online', authorityGranted: false }) }) it('fails closed when scoped discovery API keys are malformed', async () => { @@ -437,6 +442,27 @@ describe('Discovery Service Routes', () => { }) }) + describe('DELETE /agents/:did', () => { + it('does not grant authority when deregistering an agent', async () => { + const { db } = await import('../src/db/client.js') + vi.mocked(db.delete).mockReturnValueOnce({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ did: TEST_DID }]), + }), + } as any) + + const res = await app.fetch(new Request(`http://localhost/agents/${encodeURIComponent(TEST_DID)}`, { + method: 'DELETE', + })) + + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ + message: 'Agent deregistered', + authorityGranted: false, + }) + }) + }) + describe('POST /identities/:did/domain/verify', () => { it('verifies and persists domain ownership', async () => { const dns = await import('node:dns/promises') From 00f633f3c0af63890a9c0d6e64eaf68df314876b Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:49:24 +0300 Subject: [PATCH 162/282] fix(agentd): mark local registrations as candidates --- docs/api-reference.md | 14 +++++++---- services/agentd/src/index.ts | 10 +++++++- services/agentd/test/routes.test.ts | 36 +++++++++++++++++++++++++++-- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 1cc742f..71a969a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -160,11 +160,15 @@ storage to normalized identity/card tables. `POST /agents/register` registers a locally stored, identity-bound signed AgentCard as a discovery candidate. Unsigned cards and cards signed by a DID other than the advertised agent identity are rejected before they can enter -local discovery. `GET /agents` and `GET /agents/:id` expose local registration -state and the associated AgentCard. `POST /discover` and `POST /discover/local` -search registered local agents by capability and re-check the identity-bound -AgentCard proof before returning a candidate, so restored or tampered local -state is rejected at resolution time as well. `POST /discover/well-known`, +local discovery. Registration, `GET /agents`, and `GET /agents/:id` expose +`authority: "candidate_only"`, `verified: true` only for identity-bound signed +AgentCards, `authorityGranted: false`, and machine-readable `reasons` so local +registration cannot be confused with invocation authority. `GET /agents/:id` +also returns the associated AgentCard. `POST /discover` and +`POST /discover/local` search registered local agents by capability and re-check +the identity-bound AgentCard proof before returning a candidate, so restored or +tampered local state is rejected at resolution time as well. +`POST /discover/well-known`, `POST /discover/registry`, `POST /discover/relay`, `POST /discover/dht`, and `POST /discover/federation` expose provider-specific discovery aliases over the daemon's local state. diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index f2f97a8..04901e6 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -449,11 +449,19 @@ function safeIdentityRecord(record: LocalIdentityRecord): Record { const card = localAgentCards.get(record.cardId) + const signed = localSignedAgentCards.has(record.cardId) || record.signed return { ...record, - signed: localSignedAgentCards.has(record.cardId) || record.signed, + signed, + verified: signed, + authority: 'candidate_only', capabilities: card?.capabilities.map(capability => capability.id) ?? [], authorityGranted: false, + reasons: [ + signed ? 'identity_bound_signed_agent_card_verified' : 'signed_agent_card_not_verified', + 'local_registration_candidate_only', + 'discovery_does_not_grant_authority', + ], } } diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 30d382e..a02c88a 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -453,14 +453,46 @@ describe('Agentd Service Routes', () => { body: JSON.stringify({ agentCardId: identity.did }), }) expect(registered.status).toBe(201) - expect((await registered.json()).authorityGranted).toBe(false) + const registeredData = await registered.json() + expect(registeredData).toMatchObject({ + authority: 'candidate_only', + verified: true, + authorityGranted: false, + }) + expect(registeredData.reasons).toEqual(expect.arrayContaining([ + 'identity_bound_signed_agent_card_verified', + 'local_registration_candidate_only', + 'discovery_does_not_grant_authority', + ])) const listed = await app.request('/agents') expect(listed.status).toBe(200) expect((await listed.json()).agents).toEqual(expect.arrayContaining([ - expect.objectContaining({ agentId: identity.did }), + expect.objectContaining({ + agentId: identity.did, + authority: 'candidate_only', + verified: true, + authorityGranted: false, + reasons: expect.arrayContaining([ + 'identity_bound_signed_agent_card_verified', + 'discovery_does_not_grant_authority', + ]), + }), ])) + const detail = await app.request(`/agents/${encodeURIComponent(identity.did)}`) + expect(detail.status).toBe(200) + expect(await detail.json()).toMatchObject({ + agentId: identity.did, + authority: 'candidate_only', + verified: true, + authorityGranted: false, + reasons: expect.arrayContaining([ + 'local_registration_candidate_only', + 'discovery_does_not_grant_authority', + ]), + }) + const discovered = await app.request('/discover', { method: 'POST', headers: { 'Content-Type': 'application/json' }, From 0dceb83b81c765af86f108803ab64d7c8054ccc1 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:51:01 +0300 Subject: [PATCH 163/282] docs(api): schema local agent candidates --- docs/api/agentd.yaml | 85 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index a3ed549..d1c8b66 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -193,7 +193,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Local agent registered as a discovery candidate + content: + application/json: + schema: + $ref: "#/components/schemas/LocalAgentRegistration" /agents: get: @@ -202,7 +206,11 @@ paths: summary: List locally registered agents responses: "200": - $ref: "#/components/responses/JsonObject" + description: Locally registered discovery candidates + content: + application/json: + schema: + $ref: "#/components/schemas/LocalAgentListResponse" /agents/{id}: get: @@ -213,7 +221,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local registration detail + content: + application/json: + schema: + $ref: "#/components/schemas/LocalAgentDetailResponse" "404": $ref: "#/components/responses/Error" @@ -1578,6 +1590,73 @@ components: items: type: string + LocalAgentRegistration: + type: object + required: [agentId, cardId, registeredAt, signed, verified, authority, authorityGranted, reasons] + properties: + registered: + type: boolean + enum: [true] + description: Present on POST /agents/register responses. + agentId: + type: string + cardId: + type: string + registeredAt: + type: string + format: date-time + signed: + type: boolean + description: Whether the local AgentCard has an identity-bound canonical signature. + verified: + type: boolean + description: True only when the local registration is backed by an identity-bound signed AgentCard. + authority: + type: string + enum: [candidate_only] + capabilities: + type: array + items: + type: string + authorityGranted: + type: boolean + enum: [false] + reasons: + type: array + items: + type: string + example: + - identity_bound_signed_agent_card_verified + - local_registration_candidate_only + - discovery_does_not_grant_authority + reason: + type: string + description: Human-readable registration note returned by POST /agents/register. + + LocalAgentListResponse: + type: object + required: [agents, authorityGranted] + properties: + agents: + type: array + items: + $ref: "#/components/schemas/LocalAgentRegistration" + authorityGranted: + type: boolean + enum: [false] + + LocalAgentDetailResponse: + allOf: + - $ref: "#/components/schemas/LocalAgentRegistration" + - type: object + properties: + card: + type: object + nullable: true + signedCard: + type: object + nullable: true + DiscoveryResponse: type: object required: [authorityGranted] From 69002ef415229a5bc31167d49bf8a968f8d19bab Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:52:11 +0300 Subject: [PATCH 164/282] fix(sdk): type local agent candidate responses --- packages/sdk/src/fides-client.ts | 35 ++++++++++++-- packages/sdk/test/fides-client.test.ts | 64 ++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index bd55900..503734e 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -67,6 +67,31 @@ export interface FidesDiscoveryResponse { [key: string]: unknown } +export interface FidesLocalAgentRegistration { + registered?: true + agentId: string + cardId: string + registeredAt: string + signed: boolean + verified: boolean + authority: 'candidate_only' + capabilities: string[] + authorityGranted: false + reasons: string[] + reason?: string + [key: string]: unknown +} + +export interface FidesLocalAgentListResponse { + agents: FidesLocalAgentRegistration[] + authorityGranted: false +} + +export interface FidesLocalAgentDetailResponse extends FidesLocalAgentRegistration { + card: Record | null + signedCard: Record | null +} + export interface FidesRegistryPublishRequest { agentCardId: string mode?: 'public' | 'private' @@ -214,9 +239,13 @@ export class FidesClient { } readonly agents = { - register: (card: Record) => this.post('/agents/register', card), - list: () => this.get('/agents'), - inspect: (agentId: string) => this.get(`/agents/${encodeURIComponent(agentId)}`), + register: (card: Record): Promise => ( + this.post('/agents/register', card) as Promise + ), + list: (): Promise => this.get('/agents') as Promise, + inspect: (agentId: string): Promise => ( + this.get(`/agents/${encodeURIComponent(agentId)}`) as Promise + ), } readonly discovery = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 2fc5f7b..13459fc 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -512,13 +512,53 @@ describe('FidesClient', () => { vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { calls.push({ url: String(url), init }) if (String(url).endsWith('/agents/register')) { - return new Response(JSON.stringify({ registered: true, agentId: 'did:fides:agent', authorityGranted: false }), { status: 201 }) + return new Response(JSON.stringify({ + registered: true, + agentId: 'did:fides:agent', + cardId: 'card_1', + registeredAt: '2026-05-30T00:00:00.000Z', + signed: true, + verified: true, + authority: 'candidate_only', + capabilities: ['invoice.reconcile'], + authorityGranted: false, + reasons: [ + 'identity_bound_signed_agent_card_verified', + 'local_registration_candidate_only', + 'discovery_does_not_grant_authority', + ], + }), { status: 201 }) } if (String(url).endsWith('/agents/did%3Afides%3Aagent')) { - return new Response(JSON.stringify({ agentId: 'did:fides:agent', card: {} }), { status: 200 }) + return new Response(JSON.stringify({ + agentId: 'did:fides:agent', + cardId: 'card_1', + registeredAt: '2026-05-30T00:00:00.000Z', + signed: true, + verified: true, + authority: 'candidate_only', + capabilities: ['invoice.reconcile'], + authorityGranted: false, + reasons: ['local_registration_candidate_only', 'discovery_does_not_grant_authority'], + card: {}, + signedCard: {}, + }), { status: 200 }) } if (String(url).endsWith('/agents')) { - return new Response(JSON.stringify({ agents: [{ agentId: 'did:fides:agent' }] }), { status: 200 }) + return new Response(JSON.stringify({ + agents: [{ + agentId: 'did:fides:agent', + cardId: 'card_1', + registeredAt: '2026-05-30T00:00:00.000Z', + signed: true, + verified: true, + authority: 'candidate_only', + capabilities: ['invoice.reconcile'], + authorityGranted: false, + reasons: ['identity_bound_signed_agent_card_verified', 'discovery_does_not_grant_authority'], + }], + authorityGranted: false, + }), { status: 200 }) } return new Response(JSON.stringify({ authorityGranted: false, @@ -534,10 +574,24 @@ describe('FidesClient', () => { await expect(client.agents.register({ agentCardId: 'did:fides:agent' })).resolves.toMatchObject({ registered: true, + authority: 'candidate_only', + verified: true, + authorityGranted: false, + reasons: expect.arrayContaining(['discovery_does_not_grant_authority']), + }) + await expect(client.agents.list()).resolves.toMatchObject({ + agents: [{ + agentId: 'did:fides:agent', + authority: 'candidate_only', + authorityGranted: false, + }], + authorityGranted: false, + }) + await expect(client.agents.inspect('did:fides:agent')).resolves.toMatchObject({ + agentId: 'did:fides:agent', + authority: 'candidate_only', authorityGranted: false, }) - await expect(client.agents.list()).resolves.toMatchObject({ agents: [{ agentId: 'did:fides:agent' }] }) - await expect(client.agents.inspect('did:fides:agent')).resolves.toMatchObject({ agentId: 'did:fides:agent' }) await expect(client.discovery.find({ capability: 'invoice.reconcile' })).resolves.toMatchObject({ authorityGranted: false, candidates: [{ From 4af290ee5a3de5af8c6995f89fc788a4734f7a24 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 16:53:05 +0300 Subject: [PATCH 165/282] docs(sdk): document local agent candidate responses --- docs/sdk-reference.md | 12 +++++++++--- packages/sdk/README.md | 15 ++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 95a88d9..1e31230 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -25,7 +25,10 @@ const card = await client.cards.create({ await client.cards.sign({ id: identity.identity.did }) await client.cards.verify(identity.identity.did) await client.cards.get(identity.identity.did) -await client.agents.register({ agentCardId: identity.identity.did }) +const registration = await client.agents.register({ agentCardId: identity.identity.did }) +if (registration.authority !== 'candidate_only' || registration.authorityGranted !== false) { + throw new Error('Registration must remain candidate-only') +} await client.agents.list() await client.agents.inspect(identity.identity.did) const results = await client.discovery.find({ capability: 'invoice.reconcile' }) @@ -183,8 +186,11 @@ await client.evidence.export({ privacy_mode: 'hash_only', include_metadata: fals intentionally thin. The AgentCard helpers target root `agentd` AgentCard endpoints and use daemon-held local identity keys for signing. Agent registration and discovery return candidates only; `authorityGranted` remains -`false`. Standalone discovery candidate metadata also carries `verified: false` -and machine-readable `reasons`, which the SDK preserves on returned AgentCard +`false`. Root `client.agents.register`, `client.agents.list`, and +`client.agents.inspect` preserve `authority: "candidate_only"`, +`authorityGranted: false`, `verified`, and machine-readable `reasons`. +Standalone discovery candidate metadata also carries `verified: false` and +machine-readable `reasons`, which the SDK preserves on returned AgentCard objects. Trust and reputation APIs return capability-scoped signals, and policy evaluation explains the decision but still requires session grant issuance before invocation. Delegation helpers create local DelegationToken intents; the diff --git a/packages/sdk/README.md b/packages/sdk/README.md index f4b6366..5347b4e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -61,7 +61,10 @@ const card = await client.cards.create({ const signed = await client.cards.sign({ id: identity.identity.did }) const verified = await client.cards.verify(identity.identity.did) -await client.agents.register({ agentCardId: identity.identity.did }) +const registration = await client.agents.register({ agentCardId: identity.identity.did }) +if (registration.authority !== 'candidate_only' || registration.authorityGranted !== false) { + throw new Error('Registration must remain candidate-only') +} const agents = await client.agents.list() const candidateAgent = await client.agents.inspect(identity.identity.did) const candidates = await client.discovery.find({ capability: 'invoice.reconcile' }) @@ -154,10 +157,12 @@ await client.evidence.export({ privacy_mode: 'hash_only', include_metadata: fals The local identity API returns public identity data only; it does not return private keys. AgentCard signing uses the daemon-held local identity key. Registration and discovery produce candidate records only; discovery does not -grant authority to invoke the agent. Standalone discovery responses preserve -`verified: false`, `authorityGranted: false`, and machine-readable `reasons` -so SDK callers do not accidentally treat metadata discovery as trust or -permission. Trust and reputation are capability-scoped signals; policy +grant authority to invoke the agent. Root agent registration/list/detail +responses preserve `authority: "candidate_only"`, `authorityGranted: false`, +`verified`, and machine-readable `reasons`. Standalone discovery responses +preserve `verified: false`, `authorityGranted: false`, and machine-readable +`reasons` so SDK callers do not accidentally treat metadata discovery as trust +or permission. Trust and reputation are capability-scoped signals; policy decisions still require scoped session grants before invocation. Root session and invocation helpers use the local daemon preflight path and are currently in-memory. Approval and kill switch helpers expose local authority From ce028b31a28a05f5fdf2fd8bc0fed60b3727db8a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:04:15 +0300 Subject: [PATCH 166/282] fix(authority): enforce dry-run-only session grants --- docs/api-reference.md | 12 ++++--- docs/api/agentd.yaml | 47 ++++++++++++++++++++++-- docs/sdk-reference.md | 12 +++++-- packages/core/src/invocation.ts | 4 +++ packages/core/test/invocation.test.ts | 41 +++++++++++++++++++++ packages/sdk/README.md | 15 +++++--- packages/sdk/src/fides-client.ts | 36 +++++++++++++++++-- packages/sdk/test/fides-client.test.ts | 50 ++++++++++++++++++++++++++ services/agentd/src/index.ts | 33 +++++++++++++---- services/agentd/test/routes.test.ts | 2 ++ 10 files changed, 231 insertions(+), 21 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 71a969a..786587b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -223,15 +223,19 @@ tokens remain unsigned drafts until signed elsewhere. A delegation must still be converted into a policy-checked SessionGrant before invocation. `POST /sessions` issues a local `SessionGrant` only after policy allows or limits the action to dry-run, signs it with the daemon's local authority DID, and returns `signedSession` plus -`signedSessionVerified`. `POST /sessions/:id/verify` and `POST /invoke` require -the stored signed grant to verify against the grant issuer before treating the -session as usable. `POST /invoke` verifies the session, verifies an optional caller-supplied +`signedSessionVerified`. Session responses include `authorityMode` and +`allowedActions`; a `dry_run_only` session sets `authorityGranted: false`, +forces `session.constraints.dryRunOnly: true`, and can only be used for dry-run +invocation. `POST /sessions/:id/verify` and `POST /invoke` require the stored +signed grant to verify against the grant issuer before treating the session as +usable. `POST /invoke` verifies the session, verifies an optional caller-supplied canonical `signedRequest`, runs the policy preflight path, validates the capability context, validates input/output schemas for the advertised capability, and returns an `InvocationResult` plus a canonical `signedResult` proof from the target agent identity when the target is locally managed. A supplied signed request must verify and match the session, input hash, and -dry-run mode before execution. Invocation state and result evidence are +dry-run mode before execution; core validation rejects non-dry-run requests +against dry-run-only SessionGrants. Invocation state and result evidence are persisted in the local daemon snapshot when SQLite state is enabled; normalized durable invocation tables remain follow-up hardening work. Session issuance and invocation failures return a stable `ErrorEnvelope` on the `error` field for diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index d1c8b66..50bad42 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -434,7 +434,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Policy-checked local SessionGrant + content: + application/json: + schema: + $ref: "#/components/schemas/LocalSessionResponse" /sessions/{id}: get: @@ -445,7 +449,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Stored local SessionGrant + content: + application/json: + schema: + $ref: "#/components/schemas/LocalSessionResponse" "404": $ref: "#/components/responses/Error" @@ -1657,6 +1665,41 @@ components: type: object nullable: true + LocalSessionResponse: + type: object + required: [session, authorityGranted] + properties: + authorized: + type: boolean + description: Present on POST /sessions responses. + authorityGranted: + type: boolean + description: True only when the session permits execution, false for dry-run-only sessions. + authorityMode: + type: string + enum: [full, dry_run_only] + allowedActions: + type: array + items: + type: string + enum: [execute, dry_run] + session: + type: object + description: FIDES v2 SessionGrant. Dry-run-only sessions include constraints.dryRunOnly=true. + signedSession: + type: object + description: Canonically signed SessionGrant. + signedSessionVerified: + type: boolean + policy: + type: object + trust: + type: object + evidenceRefs: + type: array + items: + type: string + DiscoveryResponse: type: object required: [authorityGranted] diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 1e31230..62464a2 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -161,6 +161,9 @@ const session = await client.sessions.request({ capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], }) +if (session.authorityMode === 'dry_run_only' && session.allowedActions?.includes('dry_run')) { + // Dry-run-only sessions are simulation authority, not execution authority. +} const invocation = await client.invoke({ sessionId: session.session.session_id, input: { invoiceId: 'inv_123' }, @@ -197,8 +200,13 @@ before invocation. Delegation helpers create local DelegationToken intents; the daemon signs them when the delegator identity is locally managed, but they still do not grant invocation authority without policy and a scoped SessionGrant. Session request and invocation helpers use the same root -local daemon API. Approval and kill switch helpers expose local authority -controls, with kill switch rules overriding normal policy while active. +local daemon API. Session responses preserve `authorityMode` and +`allowedActions`; full sessions return `authorityGranted: true`, while +dry-run-only sessions return `authorityGranted: false`, include +`allowedActions: ["dry_run"]`, and carry +`session.constraints.dryRunOnly: true`. Approval and kill switch helpers expose +local authority controls, with kill switch rules overriding normal policy while +active. Revocation and incident helpers expose local governance records that feed root session policy decisions. Attestation helpers include local mock identity trust anchors for GitHub, email, domain, package registry, and wallet claims, plus diff --git a/packages/core/src/invocation.ts b/packages/core/src/invocation.ts index 618c0c8..f897437 100644 --- a/packages/core/src/invocation.ts +++ b/packages/core/src/invocation.ts @@ -221,6 +221,10 @@ export function validateInvocationRequestAgainstSessionGrant( } } + if (sessionGrant.constraints?.dryRunOnly === true && request.dry_run !== true) { + errors.push('InvocationRequest.dry_run must be true for dry-run-only SessionGrant') + } + return { valid: errors.length === 0, errors } } diff --git a/packages/core/test/invocation.test.ts b/packages/core/test/invocation.test.ts index 41d4a12..483a64f 100644 --- a/packages/core/test/invocation.test.ts +++ b/packages/core/test/invocation.test.ts @@ -130,6 +130,47 @@ describe('invocation protocol objects', () => { ])) }) + it('enforces dry-run-only SessionGrant constraints', async () => { + const issuer = await createIdentityKeyPair() + const grant = createSessionGrantV2({ + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:target', + principalId: 'did:fides:principal', + capability: 'payments.prepare', + scopes: ['payments:prepare'], + constraints: { dryRunOnly: true }, + policyHash: 'sha256:policy', + trustResultHash: 'sha256:trust', + issuer: issuer.did, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }) + + const executeRequest = createInvocationRequest({ + issuer: 'did:fides:requester', + sessionGrant: grant, + input: { amount: 100 }, + dryRun: false, + }) + expect(validateInvocationRequestAgainstSessionGrant({ + request: executeRequest, + sessionGrant: grant, + })).toEqual({ + valid: false, + errors: ['InvocationRequest.dry_run must be true for dry-run-only SessionGrant'], + }) + + const dryRunRequest = createInvocationRequest({ + issuer: 'did:fides:requester', + sessionGrant: grant, + input: { amount: 100 }, + dryRun: true, + }) + expect(validateInvocationRequestAgainstSessionGrant({ + request: dryRunRequest, + sessionGrant: grant, + })).toEqual({ valid: true, errors: [] }) + }) + it('creates and verifies signed invocation results', async () => { const target = await createIdentityKeyPair() const result = createInvocationResult({ diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 5347b4e..b6bbc67 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -138,6 +138,9 @@ const session = await client.sessions.request({ capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], }) +if (session.authorityMode === 'dry_run_only' && session.allowedActions?.includes('dry_run')) { + // Dry-run-only sessions are simulation authority, not execution authority. +} const invocation = await client.invoke({ sessionId: session.session.session_id, input: { invoiceId: 'inv_123' }, @@ -165,10 +168,14 @@ preserve `verified: false`, `authorityGranted: false`, and machine-readable or permission. Trust and reputation are capability-scoped signals; policy decisions still require scoped session grants before invocation. Root session and invocation helpers use the local daemon preflight path and are -currently in-memory. Approval and kill switch helpers expose local authority -controls, with active kill switch rules overriding normal policy. Revocation -and incident helpers expose local governance records that feed root session -policy decisions. Runtime attestation helpers issue and verify local MockTEE +currently in-memory. Session responses preserve `authorityMode` and +`allowedActions`; full sessions return `authorityGranted: true`, while +dry-run-only sessions return `authorityGranted: false`, include +`allowedActions: ["dry_run"]`, and carry +`session.constraints.dryRunOnly: true`. Approval and kill switch helpers expose +local authority controls, with active kill switch rules overriding normal +policy. Revocation and incident helpers expose local governance records that +feed root session policy decisions. Runtime attestation helpers issue and verify local MockTEE attestations that can satisfy high-risk session policy when passed as an `attestationId`. Evidence helpers append hash-only events by default, inspect individual events, verify the root hash chain, and export the current local diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 503734e..a4b839a 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -8,6 +8,7 @@ import { type InvocationRequest, type InvocationResult, type SessionGrantV2, + type SignedSessionGrantV2, type SignedInvocationRequest, type SignedInvocationResult, } from '@fides/core' @@ -209,6 +210,29 @@ export interface FidesPolicyEvaluationResponse { explanation: string } +export interface FidesSessionResponse { + authorized: boolean + authorityGranted: boolean + authorityMode?: 'full' | 'dry_run_only' + allowedActions?: Array<'execute' | 'dry_run'> + session: SessionGrantV2 + signedSession?: SignedSessionGrantV2 + signedSessionVerified?: boolean + policy?: FidesPolicyDecision + trust?: TrustResult + evidenceRefs?: string[] + [key: string]: unknown +} + +export interface FidesSessionVerifyResponse { + valid: boolean + signatureValid: boolean + notExpired: boolean + session?: SessionGrantV2 + authorityGranted?: boolean + [key: string]: unknown +} + export class FidesClientError extends Error { readonly name = 'FidesClientError' @@ -316,9 +340,15 @@ export class FidesClient { } readonly sessions = { - request: (body: Record) => this.post('/sessions', body), - verify: (sessionId: string) => this.post(`/sessions/${encodeURIComponent(sessionId)}/verify`, {}), - get: (sessionId: string) => this.get(`/sessions/${encodeURIComponent(sessionId)}`), + request: (body: Record): Promise => ( + this.post('/sessions', body) as Promise + ), + verify: (sessionId: string): Promise => ( + this.post(`/sessions/${encodeURIComponent(sessionId)}/verify`, {}) as Promise + ), + get: (sessionId: string): Promise => ( + this.get(`/sessions/${encodeURIComponent(sessionId)}`) as Promise + ), } readonly registry = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 13459fc..9800f80 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -349,6 +349,56 @@ describe('FidesClient', () => { } }) + it('types root session responses with authority mode and allowed actions', async () => { + const session: SessionGrantV2 = { + schema_version: 'fides.session_grant.v1', + id: 'sess_1', + session_id: 'sess_1', + issuer: 'did:fides:agentd', + subject: 'did:fides:agent', + requester_agent_id: 'did:fides:requester', + target_agent_id: 'did:fides:agent', + principal_id: 'did:fides:principal', + capability: 'payments.prepare', + scopes: ['payments:prepare'], + constraints: { dryRunOnly: true }, + policy_hash: 'sha256:policy', + trust_result_hash: 'sha256:trust', + issued_at: '2026-05-30T00:00:00.000Z', + expires_at: '2026-05-30T01:00:00.000Z', + audience: ['did:fides:agent'], + nonce: 'nonce_1', + payload_hash: 'sha256:payload', + } + + vi.stubGlobal('fetch', vi.fn(async () => { + return new Response(JSON.stringify({ + authorized: true, + authorityGranted: false, + authorityMode: 'dry_run_only', + allowedActions: ['dry_run'], + session, + signedSessionVerified: true, + evidenceRefs: ['evt_session'], + }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const result = await client.sessions.request({ + agentId: 'did:fides:agent', + capability: 'payments.prepare', + }) + + expect(result.authorized).toBe(true) + expect(result.authorityGranted).toBe(false) + expect(result.authorityMode).toBe('dry_run_only') + expect(result.allowedActions).toEqual(['dry_run']) + expect(result.session.constraints).toEqual({ dryRunOnly: true }) + }) + it('adds identity trust-anchor attestations through promise helpers', async () => { const calls: Array<{ url: string; init?: RequestInit }> = [] vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 04901e6..3c8459c 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -465,6 +465,17 @@ function safeRegisteredAgent(record: LocalRegisteredAgent): Record +} { + if (policy.decision === 'allow') { + return { authorityGranted: true, authorityMode: 'full', allowedActions: ['execute', 'dry_run'] } + } + return { authorityGranted: false, authorityMode: 'dry_run_only', allowedActions: ['dry_run'] } +} + function localCapabilityKey(agentId: string, capability: string): string { return `${agentId}::${capability}` } @@ -1573,13 +1584,18 @@ app.post('/sessions', async (c) => { ? body.expiresAt : new Date(Date.now() + 60 * 60 * 1000).toISOString() const authority = await getLocalAuthorityIdentity() + const sessionAuthority = sessionAuthorityFor(policy) + const requestedConstraints = typeof body.constraints === 'object' && body.constraints !== null ? body.constraints as Record : {} + const sessionConstraints = policy.decision === 'dry_run_only' + ? { ...requestedConstraints, dryRunOnly: true } + : requestedConstraints const session = createSessionGrantV2({ requesterAgentId, targetAgentId, principalId, capability: capabilityId, scopes: requestedScopes, - constraints: typeof body.constraints === 'object' && body.constraints !== null ? body.constraints as Record : {}, + constraints: sessionConstraints, policyHash: hashProtocolPayload(policy), trustResultHash: hashProtocolPayload(trust), audience: Array.isArray(body.audience) ? body.audience.map(String) : [targetAgentId], @@ -1600,13 +1616,15 @@ app.post('/sessions', async (c) => { privacy_mode: 'hash_only', metadata: { session_id: session.session_id, - authority_granted: policy.decision === 'allow', + authority_granted: sessionAuthority.authorityGranted, + authority_mode: sessionAuthority.authorityMode, + allowed_actions: sessionAuthority.allowedActions, }, }) return c.json({ authorized: true, - authorityGranted: policy.decision === 'allow', + ...sessionAuthority, session, signedSession, signedSessionVerified: await verifySignedSessionGrantV2Issuer(signedSession), @@ -1715,11 +1733,15 @@ app.post('/invoke', async (c) => { }), authorityGranted: false }, 400) } + const effectiveDryRun = typeof body.dryRun === 'boolean' + ? body.dryRun + : record.session.constraints?.dryRunOnly === true + let request = createInvocationRequest({ issuer: record.session.requester_agent_id, sessionGrant: record.session, input: body.input ?? {}, - dryRun: typeof body.dryRun === 'boolean' ? body.dryRun : false, + dryRun: effectiveDryRun, inputSchema: found.capability.inputSchema, outputSchema: found.capability.outputSchema, }) @@ -1737,13 +1759,12 @@ app.post('/invoke', async (c) => { signedRequestVerified = await verifySignedInvocationRequestIssuer(candidateSignedRequest) const signedPayload = candidateSignedRequest.payload const expectedInputHash = hashProtocolPayload(body.input ?? {}) - const expectedDryRun = typeof body.dryRun === 'boolean' ? body.dryRun : false const grantValidation = validateInvocationRequestAgainstSessionGrant({ request: signedPayload, sessionGrant: record.session, }) const payloadMatchesInput = signedPayload.input_hash === expectedInputHash && - signedPayload.dry_run === expectedDryRun + signedPayload.dry_run === effectiveDryRun if (!signedRequestVerified || !grantValidation.valid || !payloadMatchesInput) { return c.json({ error: createErrorEnvelope('IDENTITY_INVALID_SIGNATURE', { diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index a02c88a..f3067b2 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -947,6 +947,8 @@ describe('Agentd Service Routes', () => { expect(session.status).toBe(201) const sessionData = await session.json() expect(sessionData.authorityGranted).toBe(true) + expect(sessionData.authorityMode).toBe('full') + expect(sessionData.allowedActions).toEqual(['execute', 'dry_run']) expect(sessionData.session.capability).toBe('invoice.reconcile') expect(sessionData.signedSession.payload).toEqual(sessionData.session) expect(sessionData.signedSession.proof.proofPurpose).toBe('delegation') From c4cec75e323c47f3af49bc07554dce5207f4884c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:05:18 +0300 Subject: [PATCH 167/282] test(cli): cover federation all-provider discovery --- packages/cli/test/commands.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index cf13bd4..58a1dd0 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -656,6 +656,12 @@ describe('CLI Commands', () => { 'http://agentd.test/discover/dht', expect.objectContaining({ method: 'POST' }) ); + expect(mockFetch).toHaveBeenNthCalledWith( + 6, + 'http://agentd.test/discover/federation', + expect.objectContaining({ method: 'POST' }) + ); + expect(mockFetch).toHaveBeenCalledTimes(6); }); }); From 2072166568206faaa1525738546db4fc57404609 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:06:42 +0300 Subject: [PATCH 168/282] fix(cli): tolerate all-provider discovery failures --- packages/cli/src/commands/discover.ts | 20 ++++++++++-- packages/cli/test/commands.test.ts | 44 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/discover.ts b/packages/cli/src/commands/discover.ts index ac52ca1..299bd08 100644 --- a/packages/cli/src/commands/discover.ts +++ b/packages/cli/src/commands/discover.ts @@ -56,6 +56,7 @@ async function discoverCapability( const providers = options.allProviders || options.provider === 'all' ? DISCOVERY_PROVIDERS : [normalizeProvider(options.provider ?? 'local')] + const tolerateProviderFailures = providers.length > 1 const constraints = options.constraints ? parseObject(options.constraints, '--constraints') : undefined const query = { ...(intent ? { intent } : {}), @@ -66,9 +67,22 @@ async function discoverCapability( } const results = await Promise.all(providers.map(async (provider) => { const path = provider === 'local' ? '/discover/local' : `/discover/${provider}` - return { - provider, - result: await postJson(`${baseUrl(options.agentdUrl)}${path}`, query), + try { + return { + provider, + ok: true, + result: await postJson(`${baseUrl(options.agentdUrl)}${path}`, query), + } + } catch (err) { + if (!tolerateProviderFailures) { + throw err + } + return { + provider, + ok: false, + authorityGranted: false, + error: err instanceof Error ? err.message : String(err), + } } })) diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 58a1dd0..4dec340 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -663,6 +663,50 @@ describe('CLI Commands', () => { ); expect(mockFetch).toHaveBeenCalledTimes(6); }); + + it('keeps all-provider discovery results when one provider fails', async () => { + const mockFetch = vi.fn(async (url: string | URL | Request) => { + if (String(url).endsWith('/discover/relay')) { + return new Response(JSON.stringify({ + error: { code: 'RELAY_UNAVAILABLE' }, + }), { status: 503, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + provider: String(url).split('/').at(-1), + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createDiscoverCommand } = await import('../src/commands/discover.js'); + const cmd = createDiscoverCommand(); + + await cmd.parseAsync([ + '--capability', + 'calendar.schedule', + '--all-providers', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledTimes(6); + const output = JSON.parse(vi.mocked(console.log).mock.calls.at(-1)?.[0] as string); + expect(output.authorityGranted).toBe(false); + expect(output.results).toEqual(expect.arrayContaining([ + expect.objectContaining({ + provider: 'relay', + ok: false, + authorityGranted: false, + error: expect.stringContaining('HTTP 503'), + }), + expect.objectContaining({ + provider: 'local', + ok: true, + result: expect.objectContaining({ authorityGranted: false }), + }), + ])); + }); }); describe('identity domain commands', () => { From 01d2a6981b787f802a28f7bb25c75476968d1627 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:10:53 +0300 Subject: [PATCH 169/282] feat(sdk): add all-provider discovery orchestration --- docs/sdk-reference.md | 7 ++- packages/sdk/README.md | 6 +- packages/sdk/src/fides-client.ts | 76 ++++++++++++++++++++++++++ packages/sdk/test/fides-client.test.ts | 71 +++++++++++++++++++++++- 4 files changed, 155 insertions(+), 5 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 62464a2..7ad1fef 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -194,7 +194,12 @@ registration and discovery return candidates only; `authorityGranted` remains `authorityGranted: false`, `verified`, and machine-readable `reasons`. Standalone discovery candidate metadata also carries `verified: false` and machine-readable `reasons`, which the SDK preserves on returned AgentCard -objects. Trust and reputation APIs return capability-scoped signals, and policy +objects. `client.discovery.allProviders()` queries local, well-known, registry, +relay, DHT, and federation providers and returns one result per provider; failed +providers are preserved as `ok: false` records with typed error metadata while +successful provider responses remain available. The aggregate response keeps +`authorityGranted: false`; provider orchestration is still discovery, not +authority. Trust and reputation APIs return capability-scoped signals, and policy evaluation explains the decision but still requires session grant issuance before invocation. Delegation helpers create local DelegationToken intents; the daemon signs them when the delegator identity is locally managed, but they still diff --git a/packages/sdk/README.md b/packages/sdk/README.md index b6bbc67..be64083 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -72,6 +72,7 @@ await client.discovery.local({ capability: 'invoice.reconcile' }) await client.discovery.registry({ capability: 'invoice.reconcile' }) await client.discovery.relay({ capability: 'invoice.reconcile' }) await client.discovery.dht({ capability: 'invoice.reconcile' }) +const providerResults = await client.discovery.allProviders({ capability: 'invoice.reconcile' }) const trust = await client.trust.evaluate({ agentId: identity.identity.did, capability: 'invoice.reconcile', @@ -165,7 +166,10 @@ responses preserve `authority: "candidate_only"`, `authorityGranted: false`, `verified`, and machine-readable `reasons`. Standalone discovery responses preserve `verified: false`, `authorityGranted: false`, and machine-readable `reasons` so SDK callers do not accidentally treat metadata discovery as trust -or permission. Trust and reputation are capability-scoped signals; policy +or permission. `client.discovery.allProviders()` queries local, well-known, +registry, relay, DHT, and federation surfaces and preserves partial provider +failures as `ok: false` results instead of granting authority or dropping +successful candidates. Trust and reputation are capability-scoped signals; policy decisions still require scoped session grants before invocation. Root session and invocation helpers use the local daemon preflight path and are currently in-memory. Session responses preserve `authorityMode` and diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index a4b839a..53502cf 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -30,6 +30,9 @@ export interface FidesDiscoveryQuery { required_versions?: string[] } +export const FIDES_DISCOVERY_PROVIDERS = ['local', 'well-known', 'registry', 'relay', 'dht', 'federation'] as const +export type FidesDiscoveryProviderName = typeof FIDES_DISCOVERY_PROVIDERS[number] + export interface FidesProviderRecord { agentId?: string agent_id?: string @@ -68,6 +71,32 @@ export interface FidesDiscoveryResponse { [key: string]: unknown } +export interface FidesDiscoveryProviderSuccess { + provider: FidesDiscoveryProviderName + ok: true + result: FidesDiscoveryResponse +} + +export interface FidesDiscoveryProviderFailure { + provider: FidesDiscoveryProviderName + ok: false + authorityGranted: false + error: { + message: string + status?: number + code?: string + payload?: unknown + } +} + +export type FidesDiscoveryProviderResult = FidesDiscoveryProviderSuccess | FidesDiscoveryProviderFailure + +export interface FidesAllProvidersDiscoveryResponse { + query: FidesDiscoveryQuery + authorityGranted: false + results: FidesDiscoveryProviderResult[] +} + export interface FidesLocalAgentRegistration { registered?: true agentId: string @@ -280,6 +309,10 @@ export class FidesClient { relay: (query: FidesDiscoveryQuery): Promise => this.post('/discover/relay', query) as Promise, dht: (query: FidesDiscoveryQuery): Promise => this.post('/discover/dht', query) as Promise, federation: (query: FidesDiscoveryQuery): Promise => this.post('/discover/federation', query) as Promise, + allProviders: ( + query: FidesDiscoveryQuery, + providers: readonly FidesDiscoveryProviderName[] = FIDES_DISCOVERY_PROVIDERS + ): Promise => this.discoverAllProviders(query, providers), } readonly trust = { @@ -437,6 +470,35 @@ export class FidesClient { return this.request(path, { method: 'DELETE' }) } + private async discoverAllProviders( + query: FidesDiscoveryQuery, + providers: readonly FidesDiscoveryProviderName[] + ): Promise { + const results = await Promise.all(providers.map(async (provider): Promise => { + const path = provider === 'local' ? '/discover/local' : `/discover/${provider}` + try { + return { + provider, + ok: true, + result: await this.post(path, query) as FidesDiscoveryResponse, + } + } catch (err) { + return { + provider, + ok: false, + authorityGranted: false, + error: discoveryProviderError(err), + } + } + })) + + return { + query, + authorityGranted: false, + results, + } + } + private async request(path: string, init: RequestInit): Promise { const headers = new Headers(init.headers) if (this.options.apiKey) { @@ -465,6 +527,20 @@ function extractErrorEnvelope(payload: unknown): ErrorEnvelope | undefined { return isErrorEnvelope(error) ? error : undefined } +function discoveryProviderError(err: unknown): FidesDiscoveryProviderFailure['error'] { + if (err instanceof FidesClientError) { + return { + message: err.message, + status: err.status || undefined, + code: err.error?.code, + payload: err.payload, + } + } + return { + message: err instanceof Error ? err.message : String(err), + } +} + function privateKeyBytes(key: Uint8Array | string): Uint8Array { const bytes = typeof key === 'string' ? Uint8Array.from(Buffer.from(key, 'hex')) : key if (bytes.length !== 32) { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 9800f80..caf93da 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -75,6 +75,7 @@ describe('FidesClient', () => { }) await client.discovery.dht({ capability: 'invoice.reconcile' }) await client.discovery.federation({ capability: 'invoice.reconcile' }) + await client.discovery.allProviders({ capability: 'invoice.reconcile' }) await client.trust.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) await client.trust.get('did:fides:agent') await client.reputation.update({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) @@ -146,6 +147,12 @@ describe('FidesClient', () => { 'http://localhost:4817/discover/relay', 'http://localhost:4817/discover/dht', 'http://localhost:4817/discover/federation', + 'http://localhost:4817/discover/local', + 'http://localhost:4817/discover/well-known', + 'http://localhost:4817/discover/registry', + 'http://localhost:4817/discover/relay', + 'http://localhost:4817/discover/dht', + 'http://localhost:4817/discover/federation', 'http://localhost:4817/trust/evaluate', 'http://localhost:4817/trust/did%3Afides%3Aagent', 'http://localhost:4817/reputation/update', @@ -207,6 +214,12 @@ describe('FidesClient', () => { 'POST', 'POST', 'POST', + 'POST', + 'POST', + 'POST', + 'POST', + 'POST', + 'POST', 'GET', 'POST', 'GET', @@ -259,16 +272,16 @@ describe('FidesClient', () => { supported_versions: ['fides.v2.0'], required_versions: ['fides.v2.0'], }) - expect(JSON.parse(calls[39].init?.body as string)).toEqual({ + expect(JSON.parse(calls[45].init?.body as string)).toEqual({ capability: 'invoice.reconcile', supported_versions: ['fides.v2.0'], required_versions: ['fides.v2.0'], }) - expect(JSON.parse(calls[45].init?.body as string)).toEqual({ + expect(JSON.parse(calls[51].init?.body as string)).toEqual({ capability: 'invoice.reconcile', agentId: 'did:fides:agent', }) - expect(JSON.parse(calls[54].init?.body as string)).toEqual({ + expect(JSON.parse(calls[60].init?.body as string)).toEqual({ privacy_mode: 'hash_only', include_metadata: false, }) @@ -658,4 +671,56 @@ describe('FidesClient', () => { 'http://localhost:7345/discover', ]) }) + + it('keeps SDK all-provider discovery results when one provider fails', async () => { + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request) => { + if (String(url).endsWith('/discover/relay')) { + return new Response(JSON.stringify({ + error: { + code: 'VERSION_INCOMPATIBLE', + category: 'version', + severity: 'error', + retryable: false, + message: 'Relay candidate protocol version is incompatible', + details: { provider: 'relay' }, + }, + }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return new Response(JSON.stringify({ + provider: String(url).split('/').at(-1), + authorityGranted: false, + candidates: [{ agentId: 'did:fides:agent', authorityGranted: false }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const result = await client.discovery.allProviders({ capability: 'invoice.reconcile' }) + + expect(result.authorityGranted).toBe(false) + expect(result.results).toHaveLength(6) + expect(result.results).toEqual(expect.arrayContaining([ + expect.objectContaining({ + provider: 'local', + ok: true, + result: expect.objectContaining({ authorityGranted: false }), + }), + expect.objectContaining({ + provider: 'relay', + ok: false, + authorityGranted: false, + error: expect.objectContaining({ + status: 503, + code: 'VERSION_INCOMPATIBLE', + message: 'Relay candidate protocol version is incompatible', + }), + }), + ])) + }) }) From 20894b616417fd8dd8f3e6434c4bcc4c55a52021 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:13:06 +0300 Subject: [PATCH 170/282] feat(cli): support agentd identity commands --- packages/cli/src/commands/identity.ts | 34 ++++++++++++++- packages/cli/test/commands.test.ts | 60 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/identity.ts b/packages/cli/src/commands/identity.ts index 9990a98..d7eea20 100644 --- a/packages/cli/src/commands/identity.ts +++ b/packages/cli/src/commands/identity.ts @@ -14,6 +14,7 @@ import { type PublisherIdentity, } from '@fides/core' import { error, formatTable, info, success } from '../utils/output.js' +import { getJson, postJson, printResult } from './authority-utils.js' type LocalIdentityType = 'agent' | 'publisher' | 'principal' @@ -34,10 +35,21 @@ export function createIdentityCommand(): Command { .requiredOption('--type ', 'Identity type: agent, publisher, or principal') .option('--name ', 'Display name for publisher/principal or agent metadata') .option('--domain ', 'Optional domain for publisher/principal identities') + .option('--agentd-url ', 'Create the identity through a local agentd root v2 API instead of local files') .option('--json', 'Emit JSON output') .action(async (options) => { try { const type = parseIdentityType(options.type) + if (options.agentdUrl) { + const result = await postJson(`${baseUrl(options.agentdUrl)}/identities`, { + type, + ...(options.name && { name: options.name }), + ...(options.domain && { domain: options.domain }), + }) + printResult('Identity created:', result, options) + return + } + const stored = await createStoredIdentity(type, { name: options.name, domain: options.domain, @@ -67,9 +79,16 @@ export function createIdentityCommand(): Command { cmd.command('list') .description('List local FIDES identities') + .option('--agentd-url ', 'List identities through a local agentd root v2 API instead of local files') .option('--json', 'Emit JSON output') - .action((options) => { + .action(async (options) => { try { + if (options.agentdUrl) { + const result = await getJson(`${baseUrl(options.agentdUrl)}/identities`) + printResult('Identities:', result, options) + return + } + const identities = readStoredIdentities().map(({ privateKeyHex: _privateKeyHex, ...stored }) => ({ type: stored.type, did: stored.identity.did, @@ -104,9 +123,16 @@ export function createIdentityCommand(): Command { cmd.command('show') .description('Show a local FIDES identity without exposing its private key') .argument('', 'Identity DID') + .option('--agentd-url ', 'Read the identity through a local agentd root v2 API instead of local files') .option('--json', 'Emit JSON output') - .action((did, options) => { + .action(async (did, options) => { try { + if (options.agentdUrl) { + const result = await getJson(`${baseUrl(options.agentdUrl)}/identities/${encodeURIComponent(did)}`) + printResult('Identity:', result, options) + return + } + const stored = readStoredIdentity(did) if (!stored) { error(`Identity not found: ${did}`) @@ -301,3 +327,7 @@ function readStoredIdentity(did: string): StoredIdentity | null { } return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as StoredIdentity } + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 4dec340..2ba1dad 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -710,6 +710,66 @@ describe('CLI Commands', () => { }); describe('identity domain commands', () => { + it('identity commands can use root local agentd APIs', async () => { + const mockFetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/identities') && init?.method === 'POST') { + return new Response(JSON.stringify({ + type: 'principal', + identity: { did: 'did:fides:principal', displayName: 'Efe' }, + publicKeyHex: 'ab'.repeat(32), + }), { status: 201, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/identities') && init?.method === 'GET') { + return new Response(JSON.stringify({ + identities: [{ did: 'did:fides:principal', type: 'principal' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + type: 'principal', + identity: { did: 'did:fides:principal' }, + publicKeyHex: 'ab'.repeat(32), + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createIdentityCommand } = await import('../src/commands/identity.js'); + const cmd = createIdentityCommand(); + + await cmd.parseAsync([ + 'create', + '--type', + 'principal', + '--name', + 'Efe', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + await cmd.parseAsync(['list', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await cmd.parseAsync(['show', 'did:fides:principal', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'http://agentd.test/identities', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ type: 'principal', name: 'Efe' }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'http://agentd.test/identities', + expect.objectContaining({ method: 'GET' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'http://agentd.test/identities/did%3Afides%3Aprincipal', + expect.objectContaining({ method: 'GET' }) + ); + const outputs = vi.mocked(console.log).mock.calls.map(call => String(call[0])); + expect(outputs.join('\n')).not.toContain('privateKeyHex'); + }); + it('prints a domain verification challenge as JSON', async () => { const { createIdentityCommand } = await import('../src/commands/identity.js'); const cmd = createIdentityCommand(); From e4512b8e147c68895a9d92a00eff984ea17d0ce5 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:13:37 +0300 Subject: [PATCH 171/282] docs(cli): document agentd identity mode --- docs/cli-reference.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 87a424a..e04fba8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -110,7 +110,10 @@ agentd daemon status Local identity files are stored under `~/.fides/identities` by default. Set `FIDES_HOME=/path/to/workdir` to isolate local CLI state for demos or tests. `identity show` and `identity list` do not print private keys; private keys stay -inside the local identity file. +inside the local identity file. Add `--agentd-url http://localhost:7345` to +`identity create`, `identity list`, or `identity show` to use the root v2 local +agentd identity API instead of local files. The daemon response also omits +private key material. `card create --agentd-url`, `card sign`, `card inspect`, and `card verify --agentd-url` use the root v2 local agentd AgentCard endpoints. From 5ab8f7f484b0549e8cf2ea7a1984fc9f139674fb Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:15:03 +0300 Subject: [PATCH 172/282] docs(api): schema local identity responses --- docs/api/agentd.yaml | 53 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 50bad42..b5b1649 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -61,14 +61,22 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Created local identity without private key material + content: + application/json: + schema: + $ref: "#/components/schemas/LocalIdentityResponse" get: operationId: listLocalIdentities tags: [Identity] summary: List local FIDES identities responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local identity summaries without private key material + content: + application/json: + schema: + $ref: "#/components/schemas/LocalIdentityListResponse" /identities/{id}: get: @@ -79,7 +87,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local identity without private key material + content: + application/json: + schema: + $ref: "#/components/schemas/LocalIdentityResponse" "404": $ref: "#/components/responses/Error" @@ -1517,6 +1529,41 @@ components: detail: type: string + LocalIdentitySummary: + type: object + required: [type, did, publicKeyHex, createdAt] + properties: + type: + type: string + enum: [agent, publisher, principal] + did: + type: string + publicKeyHex: + type: string + description: Public Ed25519 key hex. Private keys are never returned by this API. + createdAt: + type: string + format: date-time + + LocalIdentityResponse: + allOf: + - $ref: "#/components/schemas/LocalIdentitySummary" + - type: object + required: [identity] + properties: + identity: + type: object + description: FIDES AgentIdentity, PublisherIdentity, or PrincipalIdentity object. + + LocalIdentityListResponse: + type: object + required: [identities] + properties: + identities: + type: array + items: + $ref: "#/components/schemas/LocalIdentitySummary" + IdentityResolutionResponse: type: object properties: From 08ded05423bb20dd54b22f645393317ca30cb806 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:16:31 +0300 Subject: [PATCH 173/282] fix(sdk): type identity responses --- docs/sdk-reference.md | 6 ++-- packages/sdk/README.md | 4 ++- packages/sdk/src/fides-client.ts | 38 ++++++++++++++++++++++---- packages/sdk/test/fides-client.test.ts | 15 ++++++++-- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 7ad1fef..b6c983c 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -185,8 +185,10 @@ await client.evidence.export({ privacy_mode: 'hash_only', include_metadata: fals ``` `identity.createAgent`, `identity.list`, and `identity.show` target the root -`agentd` identity API. The API does not return private keys. The facade is -intentionally thin. The AgentCard helpers target root `agentd` AgentCard +`agentd` identity API. The API does not return private keys, and the SDK types +model only public identity records (`did`, `type`, `publicKeyHex`, +`createdAt`, and the public `identity` object). The facade is intentionally +thin. The AgentCard helpers target root `agentd` AgentCard endpoints and use daemon-held local identity keys for signing. Agent registration and discovery return candidates only; `authorityGranted` remains `false`. Root `client.agents.register`, `client.agents.list`, and diff --git a/packages/sdk/README.md b/packages/sdk/README.md index be64083..2ea841a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -159,7 +159,9 @@ await client.evidence.export({ privacy_mode: 'hash_only', include_metadata: fals ``` The local identity API returns public identity data only; it does not return -private keys. AgentCard signing uses the daemon-held local identity key. +private keys. SDK identity response types model only public records (`did`, +`type`, `publicKeyHex`, `createdAt`, and the public `identity` object). +AgentCard signing uses the daemon-held local identity key. Registration and discovery produce candidate records only; discovery does not grant authority to invoke the agent. Root agent registration/list/detail responses preserve `authority: "candidate_only"`, `authorityGranted: false`, diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 53502cf..08e1d74 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -1,5 +1,8 @@ import { + type AgentIdentity, type CapabilityControl, + type PrincipalIdentity, + type PublisherIdentity, type TrustResult, createInvocationRequest, isErrorEnvelope, @@ -22,6 +25,23 @@ export interface FidesRequestOptions { headers?: Record } +export type FidesIdentityType = 'agent' | 'publisher' | 'principal' +export type FidesIdentity = AgentIdentity | PublisherIdentity | PrincipalIdentity + +export interface FidesIdentityResponse { + type: FidesIdentityType + did: string + publicKeyHex: string + createdAt: string + identity: FidesIdentity + [key: string]: unknown +} + +export interface FidesIdentityListResponse { + identities: Array & { identity?: FidesIdentity }> + [key: string]: unknown +} + export interface FidesDiscoveryQuery { intent?: string capability: string @@ -277,11 +297,19 @@ export class FidesClientError extends Error { export class FidesClient { readonly identity = { - createAgent: (body: Record = {}) => this.post('/identities', { ...body, type: 'agent' }), - createPublisher: (body: Record = {}) => this.post('/identities', { ...body, type: 'publisher' }), - createPrincipal: (body: Record = {}) => this.post('/identities', { ...body, type: 'principal' }), - list: () => this.get('/identities'), - show: (id: string) => this.get(`/identities/${encodeURIComponent(id)}`), + createAgent: (body: Record = {}): Promise => ( + this.post('/identities', { ...body, type: 'agent' }) as Promise + ), + createPublisher: (body: Record = {}): Promise => ( + this.post('/identities', { ...body, type: 'publisher' }) as Promise + ), + createPrincipal: (body: Record = {}): Promise => ( + this.post('/identities', { ...body, type: 'principal' }) as Promise + ), + list: (): Promise => this.get('/identities') as Promise, + show: (id: string): Promise => ( + this.get(`/identities/${encodeURIComponent(id)}`) as Promise + ), } readonly cards = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index caf93da..7fba745 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -302,15 +302,24 @@ describe('FidesClient', () => { const client = new FidesClient({ daemonUrl: 'http://localhost:7345', apiKey: 'sdk-key' }) - await expect(client.identity.createAgent({ name: 'Calendar Agent' })).resolves.toMatchObject({ + const created = await client.identity.createAgent({ name: 'Calendar Agent' }) + expect(created).toMatchObject({ identity: { did: 'did:fides:agent' }, }) - await expect(client.identity.list()).resolves.toMatchObject({ + expect(created.identity.did).toBe('did:fides:agent') + expect(created.publicKeyHex).toBeUndefined() + + const listed = await client.identity.list() + expect(listed).toMatchObject({ identities: [{ did: 'did:fides:agent', type: 'agent' }], }) - await expect(client.identity.show('did:fides:agent')).resolves.toMatchObject({ + expect(listed.identities[0]?.did).toBe('did:fides:agent') + + const shown = await client.identity.show('did:fides:agent') + expect(shown).toMatchObject({ identity: { did: 'did:fides:agent' }, }) + expect(shown.identity.did).toBe('did:fides:agent') expect(calls.map(call => call.url)).toEqual([ 'http://localhost:7345/identities', From 509c22c8fd96fbea20bb089aaf2f12d3111835b3 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:19:21 +0300 Subject: [PATCH 174/282] docs(api): schema local approval responses --- docs/api/agentd.yaml | 181 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 177 insertions(+), 4 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index b5b1649..095e0a0 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -383,14 +383,22 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Created approval request; no invocation authority is granted + content: + application/json: + schema: + $ref: "#/components/schemas/LocalApprovalRequestResponse" get: operationId: listLocalApprovals tags: [Policy] summary: List local approval requests responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local approval requests and decisions + content: + application/json: + schema: + $ref: "#/components/schemas/LocalApprovalListResponse" /approvals/{id}/approve: post: @@ -405,7 +413,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Approval decision recorded; no invocation authority is granted + content: + application/json: + schema: + $ref: "#/components/schemas/LocalApprovalDecisionResponse" /approvals/{id}/deny: post: @@ -420,7 +432,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Approval decision recorded; no invocation authority is granted + content: + application/json: + schema: + $ref: "#/components/schemas/LocalApprovalDecisionResponse" /delegations: post: @@ -1915,6 +1931,163 @@ components: explanation: type: string + ApprovalRequest: + type: object + required: + - schema_version + - id + - issuer + - subject + - requester_agent_id + - target_agent_id + - principal_id + - capability + - requested_scopes + - risk_level + - evidence_refs + - status + - created_at + - payload_hash + properties: + schema_version: + type: string + enum: [fides.approval.request.v1] + id: + type: string + issuer: + type: string + subject: + type: string + requester_agent_id: + type: string + target_agent_id: + type: string + principal_id: + type: string + capability: + type: string + requested_scopes: + type: array + items: + type: string + risk_level: + type: string + enum: [low, medium, high, critical] + policy_decision_hash: + type: string + evidence_refs: + type: array + items: + type: string + status: + type: string + enum: [pending, approved, denied, expired] + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + payload_hash: + type: string + + ApprovalDecision: + type: object + required: + - schema_version + - id + - issuer + - subject + - approval_request_id + - approver_id + - decision + - reason + - constraints + - evidence_refs + - decided_at + - payload_hash + properties: + schema_version: + type: string + enum: [fides.approval.decision.v1] + id: + type: string + issuer: + type: string + subject: + type: string + approval_request_id: + type: string + approver_id: + type: string + decision: + type: string + enum: [approved, denied] + reason: + type: string + constraints: + type: object + additionalProperties: true + evidence_refs: + type: array + items: + type: string + decided_at: + type: string + format: date-time + payload_hash: + type: string + + LocalApprovalRequestResponse: + type: object + required: [approval, evidenceRefs, authorityGranted] + properties: + approval: + $ref: "#/components/schemas/ApprovalRequest" + evidenceRefs: + type: array + items: + type: string + authorityGranted: + type: boolean + enum: [false] + explanation: + type: string + + LocalApprovalDecisionResponse: + type: object + required: [approval, decision, evidenceRefs, authorityGranted] + properties: + approval: + $ref: "#/components/schemas/ApprovalRequest" + decision: + $ref: "#/components/schemas/ApprovalDecision" + evidenceRefs: + type: array + items: + type: string + authorityGranted: + type: boolean + enum: [false] + explanation: + type: string + + LocalApprovalListResponse: + type: object + required: [approvals, decisions, authorityGranted] + properties: + approvals: + type: array + items: + $ref: "#/components/schemas/ApprovalRequest" + decisions: + type: array + items: + $ref: "#/components/schemas/ApprovalDecision" + authorityGranted: + type: boolean + enum: [false] + EvidenceSubmitRequest: type: object required: [actor, action] From 8d659b5bf186c88c910df2664cc628f1f6f5b5b7 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:21:26 +0300 Subject: [PATCH 175/282] fix(sdk): type approval responses --- docs/sdk-reference.md | 4 +- packages/sdk/README.md | 6 ++- packages/sdk/src/fides-client.ts | 40 +++++++++++++-- packages/sdk/test/fides-client.test.ts | 70 ++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 7 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index b6c983c..33962f4 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -213,7 +213,9 @@ dry-run-only sessions return `authorityGranted: false`, include `allowedActions: ["dry_run"]`, and carry `session.constraints.dryRunOnly: true`. Approval and kill switch helpers expose local authority controls, with kill switch rules overriding normal policy while -active. +active. Approval helpers return typed `ApprovalRequest` / `ApprovalDecision` +responses and keep `authorityGranted: false`; an approval record is evidence for +policy, not invocation authority by itself. Revocation and incident helpers expose local governance records that feed root session policy decisions. Attestation helpers include local mock identity trust anchors for GitHub, email, domain, package registry, and wallet claims, plus diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 2ea841a..fc4f337 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -180,8 +180,10 @@ dry-run-only sessions return `authorityGranted: false`, include `allowedActions: ["dry_run"]`, and carry `session.constraints.dryRunOnly: true`. Approval and kill switch helpers expose local authority controls, with active kill switch rules overriding normal -policy. Revocation and incident helpers expose local governance records that -feed root session policy decisions. Runtime attestation helpers issue and verify local MockTEE +policy. Approval helpers return typed `ApprovalRequest` / `ApprovalDecision` +responses and keep `authorityGranted: false`; approval records inform policy but +do not grant invocation authority by themselves. Revocation and incident helpers +expose local governance records that feed root session policy decisions. Runtime attestation helpers issue and verify local MockTEE attestations that can satisfy high-risk session policy when passed as an `attestationId`. Evidence helpers append hash-only events by default, inspect individual events, verify the root hash chain, and export the current local diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 08e1d74..a39fbd9 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -1,5 +1,7 @@ import { type AgentIdentity, + type ApprovalDecision, + type ApprovalRequest, type CapabilityControl, type PrincipalIdentity, type PublisherIdentity, @@ -259,6 +261,30 @@ export interface FidesPolicyEvaluationResponse { explanation: string } +export interface FidesApprovalRequestResponse { + approval: ApprovalRequest + evidenceRefs: string[] + authorityGranted: false + explanation?: string + [key: string]: unknown +} + +export interface FidesApprovalDecisionResponse { + approval: ApprovalRequest + decision: ApprovalDecision + evidenceRefs: string[] + authorityGranted: false + explanation?: string + [key: string]: unknown +} + +export interface FidesApprovalListResponse { + approvals: ApprovalRequest[] + decisions: ApprovalDecision[] + authorityGranted: false + [key: string]: unknown +} + export interface FidesSessionResponse { authorized: boolean authorityGranted: boolean @@ -364,10 +390,16 @@ export class FidesClient { } readonly approvals = { - create: (body: Record) => this.post('/approvals', body), - list: () => this.get('/approvals'), - approve: (approvalId: string, body: Record = {}) => this.post(`/approvals/${encodeURIComponent(approvalId)}/approve`, body), - deny: (approvalId: string, body: Record = {}) => this.post(`/approvals/${encodeURIComponent(approvalId)}/deny`, body), + create: (body: Record): Promise => ( + this.post('/approvals', body) as Promise + ), + list: (): Promise => this.get('/approvals') as Promise, + approve: (approvalId: string, body: Record = {}): Promise => ( + this.post(`/approvals/${encodeURIComponent(approvalId)}/approve`, body) as Promise + ), + deny: (approvalId: string, body: Record = {}): Promise => ( + this.post(`/approvals/${encodeURIComponent(approvalId)}/deny`, body) as Promise + ), } readonly killSwitch = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 7fba745..4cb8e42 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -45,6 +45,76 @@ describe('FidesClient', () => { expect(result.requiresSessionGrant).toBe(false) }) + it('types root approval responses without granting authority', async () => { + const approval = { + schema_version: 'fides.approval.request.v1', + id: 'appr_1', + issuer: 'did:fides:requester', + subject: 'did:fides:target', + requester_agent_id: 'did:fides:requester', + target_agent_id: 'did:fides:target', + principal_id: 'did:fides:principal', + capability: 'payments.prepare', + requested_scopes: ['payments:prepare'], + risk_level: 'high', + evidence_refs: [], + status: 'pending', + created_at: '2026-05-30T00:00:00.000Z', + payload_hash: 'sha256:approval', + } + const decision = { + schema_version: 'fides.approval.decision.v1', + id: 'apprd_1', + issuer: 'did:fides:approver', + subject: 'appr_1', + approval_request_id: 'appr_1', + approver_id: 'did:fides:approver', + decision: 'approved', + reason: 'human approved', + constraints: {}, + evidence_refs: [], + decided_at: '2026-05-30T00:01:00.000Z', + payload_hash: 'sha256:decision', + } + + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/approvals/appr_1/approve')) { + return new Response(JSON.stringify({ + approval: { ...approval, status: 'approved' }, + decision, + evidenceRefs: ['evt_approval_decision'], + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/approvals') && init?.method === 'GET') { + return new Response(JSON.stringify({ + approvals: [approval], + decisions: [], + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + approval, + evidenceRefs: ['evt_approval_requested'], + authorityGranted: false, + }), { status: 201, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const created = await client.approvals.create({ agentId: 'did:fides:target', capability: 'payments.prepare' }) + expect(created.approval.status).toBe('pending') + expect(created.authorityGranted).toBe(false) + + const listed = await client.approvals.list() + expect(listed.approvals[0]?.capability).toBe('payments.prepare') + expect(listed.authorityGranted).toBe(false) + + const approved = await client.approvals.approve('appr_1', { approverId: 'did:fides:approver' }) + expect(approved.approval.status).toBe('approved') + expect(approved.decision.decision).toBe('approved') + expect(approved.authorityGranted).toBe(false) + }) + it('exposes promise-based identity, card, discovery, trust, session, and invocation namespaces', async () => { const calls: Array<{ url: string; init?: RequestInit }> = [] vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { From 21ef9173eda73868587666422e3f4b15accebf10 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:23:27 +0300 Subject: [PATCH 176/282] docs(api): schema local kill switch responses --- docs/api/agentd.yaml | 85 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 095e0a0..295edb2 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -660,14 +660,22 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Kill switch rule created; active rules override policy while enabled + content: + application/json: + schema: + $ref: "#/components/schemas/LocalKillSwitchRuleResponse" get: operationId: listLocalKillSwitchRules tags: [Kill Switch] summary: List local kill switch rules responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local kill switch rules and active subset + content: + application/json: + schema: + $ref: "#/components/schemas/LocalKillSwitchListResponse" /killswitch/{id}: delete: @@ -680,7 +688,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Disabled kill switch rule + content: + application/json: + schema: + $ref: "#/components/schemas/LocalKillSwitchRuleResponse" /dht/start: post: @@ -2088,6 +2100,73 @@ components: type: boolean enum: [false] + KillSwitchRule: + type: object + required: + - schema_version + - id + - issuer + - target_type + - target + - reason + - enabled + - created_at + - payload_hash + properties: + schema_version: + type: string + enum: [fides.kill_switch.rule.v1] + id: + type: string + issuer: + type: string + target_type: + type: string + enum: [agent, publisher, capability, session, principal, risk_class] + target: + type: string + reason: + type: string + enabled: + type: boolean + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + payload_hash: + type: string + + LocalKillSwitchRuleResponse: + type: object + required: [rule] + properties: + rule: + $ref: "#/components/schemas/KillSwitchRule" + evidenceRefs: + type: array + items: + type: string + authorityOverride: + type: boolean + description: True when the rule is enabled and can override normal policy evaluation. + explanation: + type: string + + LocalKillSwitchListResponse: + type: object + required: [rules, active] + properties: + rules: + type: array + items: + $ref: "#/components/schemas/KillSwitchRule" + active: + type: array + items: + $ref: "#/components/schemas/KillSwitchRule" + EvidenceSubmitRequest: type: object required: [actor, action] From be71f06f0304cd0a42cb43a3f5eb176b661eedf6 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:24:39 +0300 Subject: [PATCH 177/282] fix(sdk): type kill switch responses --- docs/sdk-reference.md | 8 +++-- packages/sdk/README.md | 10 +++--- packages/sdk/src/fides-client.ts | 25 +++++++++++-- packages/sdk/test/fides-client.test.ts | 49 ++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 33962f4..a78f8ba 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -213,9 +213,11 @@ dry-run-only sessions return `authorityGranted: false`, include `allowedActions: ["dry_run"]`, and carry `session.constraints.dryRunOnly: true`. Approval and kill switch helpers expose local authority controls, with kill switch rules overriding normal policy while -active. Approval helpers return typed `ApprovalRequest` / `ApprovalDecision` -responses and keep `authorityGranted: false`; an approval record is evidence for -policy, not invocation authority by itself. +active. Kill switch helpers return typed `KillSwitchRule` responses; an enabled +rule is an authority override that denies or limits policy, not a session grant. +Approval helpers return typed `ApprovalRequest` / `ApprovalDecision` responses +and keep `authorityGranted: false`; an approval record is evidence for policy, +not invocation authority by itself. Revocation and incident helpers expose local governance records that feed root session policy decisions. Attestation helpers include local mock identity trust anchors for GitHub, email, domain, package registry, and wallet claims, plus diff --git a/packages/sdk/README.md b/packages/sdk/README.md index fc4f337..76c4ccc 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -180,10 +180,12 @@ dry-run-only sessions return `authorityGranted: false`, include `allowedActions: ["dry_run"]`, and carry `session.constraints.dryRunOnly: true`. Approval and kill switch helpers expose local authority controls, with active kill switch rules overriding normal -policy. Approval helpers return typed `ApprovalRequest` / `ApprovalDecision` -responses and keep `authorityGranted: false`; approval records inform policy but -do not grant invocation authority by themselves. Revocation and incident helpers -expose local governance records that feed root session policy decisions. Runtime attestation helpers issue and verify local MockTEE +policy. Kill switch helpers return typed `KillSwitchRule` responses; an enabled +rule is an authority override that denies or limits policy, not a session grant. +Approval helpers return typed `ApprovalRequest` / `ApprovalDecision` responses +and keep `authorityGranted: false`; approval records inform policy but do not +grant invocation authority by themselves. Revocation and incident helpers expose +local governance records that feed root session policy decisions. Runtime attestation helpers issue and verify local MockTEE attestations that can satisfy high-risk session policy when passed as an `attestationId`. Evidence helpers append hash-only events by default, inspect individual events, verify the root hash chain, and export the current local diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index a39fbd9..c3039ba 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -3,6 +3,7 @@ import { type ApprovalDecision, type ApprovalRequest, type CapabilityControl, + type KillSwitchRule, type PrincipalIdentity, type PublisherIdentity, type TrustResult, @@ -285,6 +286,20 @@ export interface FidesApprovalListResponse { [key: string]: unknown } +export interface FidesKillSwitchRuleResponse { + rule: KillSwitchRule + evidenceRefs?: string[] + authorityOverride?: boolean + explanation?: string + [key: string]: unknown +} + +export interface FidesKillSwitchListResponse { + rules: KillSwitchRule[] + active: KillSwitchRule[] + [key: string]: unknown +} + export interface FidesSessionResponse { authorized: boolean authorityGranted: boolean @@ -403,9 +418,13 @@ export class FidesClient { } readonly killSwitch = { - enable: (body: Record) => this.post('/killswitch', body), - list: () => this.get('/killswitch'), - disable: (ruleId: string) => this.delete(`/killswitch/${encodeURIComponent(ruleId)}`), + enable: (body: Record): Promise => ( + this.post('/killswitch', body) as Promise + ), + list: (): Promise => this.get('/killswitch') as Promise, + disable: (ruleId: string): Promise => ( + this.delete(`/killswitch/${encodeURIComponent(ruleId)}`) as Promise + ), } readonly revocations = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 4cb8e42..347c5fa 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -115,6 +115,55 @@ describe('FidesClient', () => { expect(approved.authorityGranted).toBe(false) }) + it('types root kill switch responses as policy overrides', async () => { + const rule = { + schema_version: 'fides.kill_switch.rule.v1', + id: 'ks_1', + issuer: 'did:fides:operator', + target_type: 'capability', + target: 'payments.prepare', + reason: 'incident response', + enabled: true, + created_at: '2026-05-30T00:00:00.000Z', + payload_hash: 'sha256:killswitch', + } + + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/killswitch/ks_1') && init?.method === 'DELETE') { + return new Response(JSON.stringify({ + rule: { ...rule, enabled: false }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/killswitch') && init?.method === 'GET') { + return new Response(JSON.stringify({ + rules: [rule], + active: [rule], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + rule, + evidenceRefs: ['evt_kill_switch'], + authorityOverride: true, + explanation: 'Kill switch rules override normal trust and policy evaluation while active.', + }), { status: 201, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const enabled = await client.killSwitch.enable({ + targetType: 'capability', + target: 'payments.prepare', + reason: 'incident response', + }) + expect(enabled.rule.enabled).toBe(true) + expect(enabled.authorityOverride).toBe(true) + + const listed = await client.killSwitch.list() + expect(listed.active[0]?.target).toBe('payments.prepare') + + const disabled = await client.killSwitch.disable('ks_1') + expect(disabled.rule.enabled).toBe(false) + }) + it('exposes promise-based identity, card, discovery, trust, session, and invocation namespaces', async () => { const calls: Array<{ url: string; init?: RequestInit }> = [] vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { From e190ae1bb4e6ff6bcfed1b586229379977ccfb2d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:27:55 +0300 Subject: [PATCH 178/282] docs(api): schema local revocation responses --- docs/api/agentd.yaml | 105 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 295edb2..238be90 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -579,14 +579,22 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Revocation recorded; active records override matching trust and policy evaluation + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRevocationRecordResponse" get: operationId: listLocalRevocations tags: [Authority] summary: List local revocations responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local revocation records and active subset + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRevocationListResponse" /revocations/{id}: get: @@ -597,7 +605,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local revocation status and record + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRevocationStatusResponse" "404": $ref: "#/components/responses/Error" @@ -2167,6 +2179,93 @@ components: items: $ref: "#/components/schemas/KillSwitchRule" + RevocationRecordV2: + type: object + required: + - schema_version + - id + - issuer + - subject + - target_type + - target_id + - reason + - status + - evidence_refs + - created_at + - payload_hash + properties: + schema_version: + type: string + enum: [fides.revocation.record.v1] + id: + type: string + issuer: + type: string + subject: + type: string + target_type: + type: string + enum: [key, identity, agent, agent_card, capability, session, attestation, publisher] + target_id: + type: string + reason: + type: string + status: + type: string + enum: [active, superseded, expired] + evidence_refs: + type: array + items: + type: string + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + payload_hash: + type: string + + LocalRevocationRecordResponse: + type: object + required: [record, authorityOverride] + properties: + record: + $ref: "#/components/schemas/RevocationRecordV2" + evidenceRefs: + type: array + items: + type: string + authorityOverride: + type: boolean + description: True when the revocation can override normal trust and policy evaluation. + explanation: + type: string + + LocalRevocationListResponse: + type: object + required: [records, active] + properties: + records: + type: array + items: + $ref: "#/components/schemas/RevocationRecordV2" + active: + type: array + items: + $ref: "#/components/schemas/RevocationRecordV2" + + LocalRevocationStatusResponse: + type: object + required: [id, revoked] + properties: + id: + type: string + revoked: + type: boolean + record: + $ref: "#/components/schemas/RevocationRecordV2" + EvidenceSubmitRequest: type: object required: [actor, action] From c95f00f8021aabfae7b57367da5f0a250495ec97 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:29:35 +0300 Subject: [PATCH 179/282] fix(sdk): type revocation responses --- docs/sdk-reference.md | 11 ++++-- packages/sdk/README.md | 5 ++- packages/sdk/src/fides-client.ts | 32 +++++++++++++-- packages/sdk/test/fides-client.test.ts | 54 ++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index a78f8ba..c7bf879 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -219,10 +219,13 @@ Approval helpers return typed `ApprovalRequest` / `ApprovalDecision` responses and keep `authorityGranted: false`; an approval record is evidence for policy, not invocation authority by itself. Revocation and incident helpers expose local governance records that feed root -session policy decisions. Attestation helpers include local mock identity trust -anchors for GitHub, email, domain, package registry, and wallet claims, plus -runtime MockTEE attestations that can satisfy high-risk session policy when -passed as an `attestationId`. Registry, relay, DHT, federation, and well-known +session policy decisions. Revocation helpers return typed `RevocationRecordV2` +responses, and active revocations are authority overrides that deny matching +trust and policy paths rather than grant new authority. Attestation helpers +include local mock identity trust anchors for GitHub, email, domain, package +registry, and wallet claims, plus runtime MockTEE attestations that can satisfy +high-risk session policy when passed as an `attestationId`. Registry, relay, +DHT, federation, and well-known helpers expose the local mock discovery surfaces. They return candidate records or pointers only; they do not convert discovery into authority. Discovery, registry, relay, and federation helpers accept `supported_versions` and diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 76c4ccc..55e5987 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -185,7 +185,10 @@ rule is an authority override that denies or limits policy, not a session grant. Approval helpers return typed `ApprovalRequest` / `ApprovalDecision` responses and keep `authorityGranted: false`; approval records inform policy but do not grant invocation authority by themselves. Revocation and incident helpers expose -local governance records that feed root session policy decisions. Runtime attestation helpers issue and verify local MockTEE +local governance records that feed root session policy decisions. Revocation +helpers return typed `RevocationRecordV2` responses, and active revocations are +authority overrides that deny matching trust and policy paths rather than grant +new authority. Runtime attestation helpers issue and verify local MockTEE attestations that can satisfy high-risk session policy when passed as an `attestationId`. Evidence helpers append hash-only events by default, inspect individual events, verify the root hash chain, and export the current local diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index c3039ba..7b373b6 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -6,6 +6,7 @@ import { type KillSwitchRule, type PrincipalIdentity, type PublisherIdentity, + type RevocationRecordV2, type TrustResult, createInvocationRequest, isErrorEnvelope, @@ -300,6 +301,27 @@ export interface FidesKillSwitchListResponse { [key: string]: unknown } +export interface FidesRevocationRecordResponse { + record: RevocationRecordV2 + evidenceRefs?: string[] + authorityOverride?: boolean + explanation?: string + [key: string]: unknown +} + +export interface FidesRevocationListResponse { + records: RevocationRecordV2[] + active: RevocationRecordV2[] + [key: string]: unknown +} + +export interface FidesRevocationStatusResponse { + id: string + revoked: boolean + record?: RevocationRecordV2 + [key: string]: unknown +} + export interface FidesSessionResponse { authorized: boolean authorityGranted: boolean @@ -428,9 +450,13 @@ export class FidesClient { } readonly revocations = { - create: (body: Record) => this.post('/revocations', body), - list: () => this.get('/revocations'), - get: (recordId: string) => this.get(`/revocations/${encodeURIComponent(recordId)}`), + create: (body: Record): Promise => ( + this.post('/revocations', body) as Promise + ), + list: (): Promise => this.get('/revocations') as Promise, + get: (recordId: string): Promise => ( + this.get(`/revocations/${encodeURIComponent(recordId)}`) as Promise + ), } readonly incidents = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 347c5fa..2590df2 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -164,6 +164,60 @@ describe('FidesClient', () => { expect(disabled.rule.enabled).toBe(false) }) + it('types root revocation responses as authority overrides', async () => { + const record = { + schema_version: 'fides.revocation.record.v1', + id: 'rev_1', + issuer: 'did:fides:operator', + subject: 'did:fides:agent', + target_type: 'agent', + target_id: 'did:fides:agent', + reason: 'compromised deployment key', + status: 'active', + evidence_refs: [], + created_at: '2026-05-30T00:00:00.000Z', + payload_hash: 'sha256:revocation', + } + + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/revocations/rev_1')) { + return new Response(JSON.stringify({ + id: 'rev_1', + revoked: true, + record, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/revocations') && init?.method === 'GET') { + return new Response(JSON.stringify({ + records: [record], + active: [record], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + record, + evidenceRefs: ['evt_revocation'], + authorityOverride: true, + explanation: 'Active revocation records override normal trust and policy evaluation for matching requests.', + }), { status: 201, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const created = await client.revocations.create({ + targetType: 'agent', + targetId: 'did:fides:agent', + reason: 'compromised deployment key', + }) + expect(created.record.status).toBe('active') + expect(created.authorityOverride).toBe(true) + + const listed = await client.revocations.list() + expect(listed.active[0]?.target_type).toBe('agent') + + const status = await client.revocations.get('rev_1') + expect(status.revoked).toBe(true) + expect(status.record?.target_id).toBe('did:fides:agent') + }) + it('exposes promise-based identity, card, discovery, trust, session, and invocation namespaces', async () => { const calls: Array<{ url: string; init?: RequestInit }> = [] vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { From 3688061040dda598e4bd0f9d92a301ad822a0fe2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:30:47 +0300 Subject: [PATCH 180/282] docs(api): schema local incident responses --- docs/api/agentd.yaml | 118 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 4 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 238be90..aec433d 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -624,14 +624,22 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Incident recorded; open incidents require policy review for matching target agents + content: + application/json: + schema: + $ref: "#/components/schemas/LocalIncidentRecordResponse" get: operationId: listLocalIncidents tags: [Authority] summary: List local incidents responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local incident records and open subset + content: + application/json: + schema: + $ref: "#/components/schemas/LocalIncidentListResponse" /incidents/{id}: get: @@ -642,7 +650,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local incident record + content: + application/json: + schema: + $ref: "#/components/schemas/LocalIncidentRecordResponse" "404": $ref: "#/components/responses/Error" @@ -659,7 +671,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Resolved local incident record + content: + application/json: + schema: + $ref: "#/components/schemas/LocalIncidentRecordResponse" /killswitch: post: @@ -2266,6 +2282,100 @@ components: record: $ref: "#/components/schemas/RevocationRecordV2" + IncidentRecordV2: + type: object + required: + - schema_version + - id + - issuer + - subject + - reporter + - target_agent_id + - severity + - category + - description + - evidence_refs + - resolution_status + - trust_penalty + - reputation_penalty + - created_at + - payload_hash + properties: + schema_version: + type: string + enum: [fides.incident.record.v1] + id: + type: string + issuer: + type: string + subject: + type: string + reporter: + type: string + target_agent_id: + type: string + severity: + type: string + enum: [low, medium, high, critical] + category: + type: string + enum: + - policy_violation + - data_exfiltration + - malicious_output + - sandbox_escape + - unauthorized_action + - prompt_injection_failure + - payment_error + - suspicious_behavior + description: + type: string + evidence_refs: + type: array + items: + type: string + resolution_status: + type: string + enum: [open, resolved, dismissed, false_positive] + trust_penalty: + type: number + reputation_penalty: + type: number + created_at: + type: string + format: date-time + resolved_at: + type: string + format: date-time + payload_hash: + type: string + + LocalIncidentRecordResponse: + type: object + required: [record] + properties: + record: + $ref: "#/components/schemas/IncidentRecordV2" + evidenceRefs: + type: array + items: + type: string + explanation: + type: string + + LocalIncidentListResponse: + type: object + required: [records, open] + properties: + records: + type: array + items: + $ref: "#/components/schemas/IncidentRecordV2" + open: + type: array + items: + $ref: "#/components/schemas/IncidentRecordV2" + EvidenceSubmitRequest: type: object required: [actor, action] From 811a6b6dc350d0dd0f9707e073420a5443243e3d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:31:54 +0300 Subject: [PATCH 181/282] fix(sdk): type incident responses --- docs/sdk-reference.md | 4 +- packages/sdk/README.md | 4 +- packages/sdk/src/fides-client.ts | 28 ++++++++++-- packages/sdk/test/fides-client.test.ts | 61 ++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index c7bf879..467917f 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -224,7 +224,9 @@ responses, and active revocations are authority overrides that deny matching trust and policy paths rather than grant new authority. Attestation helpers include local mock identity trust anchors for GitHub, email, domain, package registry, and wallet claims, plus runtime MockTEE attestations that can satisfy -high-risk session policy when passed as an `attestationId`. Registry, relay, +high-risk session policy when passed as an `attestationId`. Incident helpers +return typed `IncidentRecordV2` responses; open incidents are policy-review +inputs that affect trust and session policy until resolved. Registry, relay, DHT, federation, and well-known helpers expose the local mock discovery surfaces. They return candidate records or pointers only; they do not convert discovery into authority. Discovery, diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 55e5987..a4c6543 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -188,7 +188,9 @@ grant invocation authority by themselves. Revocation and incident helpers expose local governance records that feed root session policy decisions. Revocation helpers return typed `RevocationRecordV2` responses, and active revocations are authority overrides that deny matching trust and policy paths rather than grant -new authority. Runtime attestation helpers issue and verify local MockTEE +new authority. Incident helpers return typed `IncidentRecordV2` responses; open +incidents are policy-review inputs that affect trust and session policy until +resolved. Runtime attestation helpers issue and verify local MockTEE attestations that can satisfy high-risk session policy when passed as an `attestationId`. Evidence helpers append hash-only events by default, inspect individual events, verify the root hash chain, and export the current local diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 7b373b6..3bd4c42 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -3,6 +3,7 @@ import { type ApprovalDecision, type ApprovalRequest, type CapabilityControl, + type IncidentRecordV2, type KillSwitchRule, type PrincipalIdentity, type PublisherIdentity, @@ -322,6 +323,19 @@ export interface FidesRevocationStatusResponse { [key: string]: unknown } +export interface FidesIncidentRecordResponse { + record: IncidentRecordV2 + evidenceRefs?: string[] + explanation?: string + [key: string]: unknown +} + +export interface FidesIncidentListResponse { + records: IncidentRecordV2[] + open: IncidentRecordV2[] + [key: string]: unknown +} + export interface FidesSessionResponse { authorized: boolean authorityGranted: boolean @@ -460,10 +474,16 @@ export class FidesClient { } readonly incidents = { - report: (body: Record) => this.post('/incidents', body), - list: () => this.get('/incidents'), - get: (recordId: string) => this.get(`/incidents/${encodeURIComponent(recordId)}`), - resolve: (recordId: string, body: Record = {}) => this.post(`/incidents/${encodeURIComponent(recordId)}/resolve`, body), + report: (body: Record): Promise => ( + this.post('/incidents', body) as Promise + ), + list: (): Promise => this.get('/incidents') as Promise, + get: (recordId: string): Promise => ( + this.get(`/incidents/${encodeURIComponent(recordId)}`) as Promise + ), + resolve: (recordId: string, body: Record = {}): Promise => ( + this.post(`/incidents/${encodeURIComponent(recordId)}/resolve`, body) as Promise + ), } readonly attestations = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 2590df2..36c2fc9 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -218,6 +218,67 @@ describe('FidesClient', () => { expect(status.record?.target_id).toBe('did:fides:agent') }) + it('types root incident responses as policy review inputs', async () => { + const record = { + schema_version: 'fides.incident.record.v1', + id: 'inc_1', + issuer: 'did:fides:reporter', + subject: 'did:fides:agent', + reporter: 'did:fides:reporter', + target_agent_id: 'did:fides:agent', + severity: 'high', + category: 'unauthorized_action', + description: 'attempted invocation outside delegated authority', + evidence_refs: [], + resolution_status: 'open', + trust_penalty: 0.3, + reputation_penalty: 0.2, + created_at: '2026-05-30T00:00:00.000Z', + payload_hash: 'sha256:incident', + } + + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/incidents/inc_1/resolve')) { + return new Response(JSON.stringify({ + record: { ...record, resolution_status: 'resolved', resolved_at: '2026-05-30T00:05:00.000Z' }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/incidents/inc_1')) { + return new Response(JSON.stringify({ record }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/incidents') && init?.method === 'GET') { + return new Response(JSON.stringify({ + records: [record], + open: [record], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + record, + evidenceRefs: ['evt_incident'], + explanation: 'Open incident records require policy review for matching target agents until resolved.', + }), { status: 201, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const reported = await client.incidents.report({ + targetAgentId: 'did:fides:agent', + severity: 'high', + category: 'unauthorized_action', + description: 'attempted invocation outside delegated authority', + }) + expect(reported.record.resolution_status).toBe('open') + expect(reported.record.trust_penalty).toBe(0.3) + + const listed = await client.incidents.list() + expect(listed.open[0]?.target_agent_id).toBe('did:fides:agent') + + const fetched = await client.incidents.get('inc_1') + expect(fetched.record.category).toBe('unauthorized_action') + + const resolved = await client.incidents.resolve('inc_1', { status: 'resolved' }) + expect(resolved.record.resolution_status).toBe('resolved') + }) + it('exposes promise-based identity, card, discovery, trust, session, and invocation namespaces', async () => { const calls: Array<{ url: string; init?: RequestInit }> = [] vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { From 80303a3a2275555f69b913a59cb4348a1fd48bae Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:33:10 +0300 Subject: [PATCH 182/282] docs(api): schema local attestation responses --- docs/api/agentd.yaml | 163 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 3 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index aec433d..69ab074 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -112,7 +112,13 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Local identity trust-anchor attestation or runtime attestation issued + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/LocalIdentityAttestationResponse" + - $ref: "#/components/schemas/LocalRuntimeAttestationResponse" /attestations/{id}: get: @@ -123,7 +129,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local runtime attestation + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRuntimeAttestationResponse" "404": $ref: "#/components/responses/Error" @@ -138,7 +148,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local runtime attestation verification result + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRuntimeAttestationVerificationResponse" /agent-cards: post: @@ -1620,6 +1634,149 @@ components: items: $ref: "#/components/schemas/LocalIdentitySummary" + IdentityTrustAnchor: + type: object + required: [type, value, verified] + properties: + type: + type: string + enum: + - domain + - github + - email + - npm + - pypi + - wallet + - passkey + - organization_invitation + - runtime_attestation + - build_attestation + - peer_attestation + value: + type: string + verified: + type: boolean + verifiedAt: + type: string + format: date-time + evidenceRef: + type: string + + LocalIdentityAttestation: + type: object + required: [id, schema_version, identity, trust_anchor, issued_at, mode] + properties: + id: + type: string + schema_version: + type: string + enum: [fides.identity_attestation.v1] + identity: + type: string + trust_anchor: + $ref: "#/components/schemas/IdentityTrustAnchor" + issued_at: + type: string + format: date-time + mode: + type: string + enum: [local_mock] + + LocalIdentityAttestationResponse: + type: object + required: [attestation, identity, authorityGranted] + properties: + attestation: + $ref: "#/components/schemas/LocalIdentityAttestation" + identity: + $ref: "#/components/schemas/LocalIdentityResponse" + evidenceRefs: + type: array + items: + type: string + authorityGranted: + type: boolean + enum: [false] + + RuntimeAttestationV2: + type: object + required: + - schema_version + - id + - issuer + - subject + - attestation_id + - agent_id + - provider + - code_hash + - runtime_hash + - policy_hash + - issued_at + - expires_at + - payload_hash + - signature + properties: + schema_version: + type: string + enum: [fides.runtime_attestation.v1] + id: + type: string + issuer: + type: string + subject: + type: string + attestation_id: + type: string + agent_id: + type: string + provider: + type: string + code_hash: + type: string + runtime_hash: + type: string + policy_hash: + type: string + enclave_measurement: + type: string + issued_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + payload_hash: + type: string + signature: + type: string + + LocalRuntimeAttestationResponse: + type: object + required: [attestation] + properties: + attestation: + $ref: "#/components/schemas/RuntimeAttestationV2" + evidenceRefs: + type: array + items: + type: string + authorityGranted: + type: boolean + enum: [false] + + LocalRuntimeAttestationVerificationResponse: + allOf: + - $ref: "#/components/schemas/LocalRuntimeAttestationResponse" + - type: object + required: [id, valid, authorityGranted] + properties: + id: + type: string + valid: + type: boolean + error: + type: string + IdentityResolutionResponse: type: object properties: From f0490e84d05faeeeb7e70e7cafd67d873a383738 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:35:07 +0300 Subject: [PATCH 183/282] fix(sdk): type attestation responses --- docs/sdk-reference.md | 5 +- packages/sdk/README.md | 7 ++- packages/sdk/src/fides-client.ts | 67 +++++++++++++++++--- packages/sdk/test/fides-client.test.ts | 84 +++++++++++++++++++++++++- 4 files changed, 148 insertions(+), 15 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 467917f..63c3e2d 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -226,8 +226,9 @@ include local mock identity trust anchors for GitHub, email, domain, package registry, and wallet claims, plus runtime MockTEE attestations that can satisfy high-risk session policy when passed as an `attestationId`. Incident helpers return typed `IncidentRecordV2` responses; open incidents are policy-review -inputs that affect trust and session policy until resolved. Registry, relay, -DHT, federation, and well-known +inputs that affect trust and session policy until resolved. Attestation helpers +return typed local identity trust-anchor responses or `RuntimeAttestation` +responses. Registry, relay, DHT, federation, and well-known helpers expose the local mock discovery surfaces. They return candidate records or pointers only; they do not convert discovery into authority. Discovery, registry, relay, and federation helpers accept `supported_versions` and diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a4c6543..4280181 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -190,9 +190,10 @@ helpers return typed `RevocationRecordV2` responses, and active revocations are authority overrides that deny matching trust and policy paths rather than grant new authority. Incident helpers return typed `IncidentRecordV2` responses; open incidents are policy-review inputs that affect trust and session policy until -resolved. Runtime attestation helpers issue and verify local MockTEE -attestations that can satisfy high-risk session policy when passed as an -`attestationId`. Evidence helpers append hash-only events by default, inspect +resolved. Attestation helpers return typed local identity trust-anchor responses +or `RuntimeAttestation` responses. Runtime attestation helpers issue and verify +local MockTEE attestations that can satisfy high-risk session policy when passed +as an `attestationId`. Evidence helpers append hash-only events by default, inspect individual events, verify the root hash chain, and export the current local ledger. diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 3bd4c42..37c5913 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -3,6 +3,7 @@ import { type ApprovalDecision, type ApprovalRequest, type CapabilityControl, + type IdentityTrustAnchor, type IncidentRecordV2, type KillSwitchRule, type PrincipalIdentity, @@ -15,6 +16,7 @@ import { type ErrorEnvelope, type InvocationRequest, type InvocationResult, + type RuntimeAttestation, type SessionGrantV2, type SignedSessionGrantV2, type SignedInvocationRequest, @@ -215,6 +217,39 @@ export interface FidesWalletAttestationRequest extends FidesIdentityAttestationR address: string } +export interface FidesIdentityAttestation { + id: string + schema_version: 'fides.identity_attestation.v1' + identity: string + trust_anchor: IdentityTrustAnchor + issued_at: string + mode: 'local_mock' + [key: string]: unknown +} + +export interface FidesIdentityAttestationResponse { + attestation: FidesIdentityAttestation + identity: FidesIdentityResponse + evidenceRefs?: string[] + authorityGranted: false + [key: string]: unknown +} + +export interface FidesRuntimeAttestationResponse { + attestation: RuntimeAttestation + evidenceRefs?: string[] + authorityGranted?: false + [key: string]: unknown +} + +export interface FidesRuntimeAttestationVerificationResponse extends FidesRuntimeAttestationResponse { + id: string + valid: boolean + error?: string +} + +export type FidesAttestationResponse = FidesIdentityAttestationResponse | FidesRuntimeAttestationResponse + export interface FidesInvocationResponse { authorityGranted: boolean session: SessionGrantV2 @@ -487,14 +522,30 @@ export class FidesClient { } readonly attestations = { - create: (body: Record) => this.post('/attestations', body), - github: (body: FidesGithubAttestationRequest) => this.post('/attestations', { type: 'github', ...body }), - email: (body: FidesEmailAttestationRequest) => this.post('/attestations', { type: 'email', ...body }), - domain: (body: FidesDomainAttestationRequest) => this.post('/attestations', { type: 'domain', ...body }), - package: (body: FidesPackageAttestationRequest) => this.post('/attestations', { type: 'package', ...body }), - wallet: (body: FidesWalletAttestationRequest) => this.post('/attestations', { type: 'wallet', ...body }), - get: (attestationId: string) => this.get(`/attestations/${encodeURIComponent(attestationId)}`), - verify: (attestationId: string) => this.post(`/attestations/${encodeURIComponent(attestationId)}/verify`, {}), + create: (body: Record): Promise => ( + this.post('/attestations', body) as Promise + ), + github: (body: FidesGithubAttestationRequest): Promise => ( + this.post('/attestations', { type: 'github', ...body }) as Promise + ), + email: (body: FidesEmailAttestationRequest): Promise => ( + this.post('/attestations', { type: 'email', ...body }) as Promise + ), + domain: (body: FidesDomainAttestationRequest): Promise => ( + this.post('/attestations', { type: 'domain', ...body }) as Promise + ), + package: (body: FidesPackageAttestationRequest): Promise => ( + this.post('/attestations', { type: 'package', ...body }) as Promise + ), + wallet: (body: FidesWalletAttestationRequest): Promise => ( + this.post('/attestations', { type: 'wallet', ...body }) as Promise + ), + get: (attestationId: string): Promise => ( + this.get(`/attestations/${encodeURIComponent(attestationId)}`) as Promise + ), + verify: (attestationId: string): Promise => ( + this.post(`/attestations/${encodeURIComponent(attestationId)}/verify`, {}) as Promise + ), } readonly sessions = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 36c2fc9..a7a2ff3 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -660,7 +660,26 @@ describe('FidesClient', () => { vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { calls.push({ url: String(url), init }) return new Response(JSON.stringify({ - attestation: { id: 'att_identity_1' }, + attestation: { + id: 'att_identity_1', + schema_version: 'fides.identity_attestation.v1', + identity: 'did:fides:publisher', + trust_anchor: { + type: 'github', + value: 'fides-dev', + verified: true, + verifiedAt: '2026-05-30T00:00:00.000Z', + }, + issued_at: '2026-05-30T00:00:00.000Z', + mode: 'local_mock', + }, + identity: { + type: 'publisher', + did: 'did:fides:publisher', + publicKeyHex: '00'.repeat(32), + createdAt: '2026-05-30T00:00:00.000Z', + identity: { did: 'did:fides:publisher', type: 'publisher' }, + }, evidenceRefs: ['evt_1'], authorityGranted: false, }), { @@ -671,7 +690,7 @@ describe('FidesClient', () => { const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) - await client.attestations.github({ identity: 'did:fides:publisher', handle: 'fides-dev' }) + const github = await client.attestations.github({ identity: 'did:fides:publisher', handle: 'fides-dev' }) await client.attestations.email({ identity: 'did:fides:publisher', email: 'dev@example.com' }) await client.attestations.domain({ identity: 'did:fides:publisher', domain: 'example.com' }) await client.attestations.package({ @@ -681,6 +700,8 @@ describe('FidesClient', () => { }) await client.attestations.wallet({ identity: 'did:fides:publisher', address: '0xabc' }) + expect(github.attestation.trust_anchor.type).toBe('github') + expect(github.authorityGranted).toBe(false) expect(calls.map(call => call.url)).toEqual([ 'http://localhost:7345/attestations', 'http://localhost:7345/attestations', @@ -702,6 +723,65 @@ describe('FidesClient', () => { ]) }) + it('types runtime attestation issue and verification responses', async () => { + const attestation = { + schema_version: 'fides.runtime_attestation.v1', + id: 'att_runtime_1', + issuer: 'mock-tee', + subject: 'did:fides:agent', + attestation_id: 'att_runtime_1', + agent_id: 'did:fides:agent', + provider: 'mock-tee', + code_hash: 'sha256:code', + runtime_hash: 'sha256:runtime', + policy_hash: 'sha256:policy', + enclave_measurement: 'sha256:measurement', + issued_at: '2026-05-30T00:00:00.000Z', + expires_at: '2026-05-30T01:00:00.000Z', + payload_hash: 'sha256:payload', + signature: 'local-mock-signature', + } + + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/attestations/att_runtime_1/verify')) { + return new Response(JSON.stringify({ + id: 'att_runtime_1', + valid: true, + attestation, + evidenceRefs: ['evt_attestation_verified'], + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/attestations/att_runtime_1') && init?.method === 'GET') { + return new Response(JSON.stringify({ attestation }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(JSON.stringify({ + attestation, + evidenceRefs: ['evt_attestation_issued'], + authorityGranted: false, + }), { status: 201, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const issued = await client.attestations.create({ + agentId: 'did:fides:agent', + codeHash: 'sha256:code', + runtimeHash: 'sha256:runtime', + policyHash: 'sha256:policy', + }) + expect(issued.attestation.schema_version).toBe('fides.runtime_attestation.v1') + + const fetched = await client.attestations.get('att_runtime_1') + expect(fetched.attestation.provider).toBe('mock-tee') + + const verified = await client.attestations.verify('att_runtime_1') + expect(verified.valid).toBe(true) + expect(verified.authorityGranted).toBe(false) + }) + it('creates and submits signed invocation requests from a session grant', async () => { const requester = await createAgentIdentity() const sessionGrant: SessionGrantV2 = { From b5fc29653018630308116ef19b3d52d3b0ab7c71 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:37:13 +0300 Subject: [PATCH 184/282] docs(api): schema local evidence responses --- docs/api/agentd.yaml | 203 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 198 insertions(+), 5 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 69ab074..a585490 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -536,14 +536,22 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Evidence event appended to the local hash chain + content: + application/json: + schema: + $ref: "#/components/schemas/LocalEvidenceAppendResponse" get: operationId: listLocalEvidence tags: [Evidence] summary: List local evidence events responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local evidence ledger state + content: + application/json: + schema: + $ref: "#/components/schemas/LocalEvidenceListResponse" /evidence/{id}: get: @@ -554,7 +562,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local evidence event + content: + application/json: + schema: + $ref: "#/components/schemas/LocalEvidenceEventResponse" "404": $ref: "#/components/responses/Error" @@ -567,7 +579,11 @@ paths: - ApiKeyAuth: [] responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local evidence hash-chain verification result + content: + application/json: + schema: + $ref: "#/components/schemas/LocalEvidenceVerificationResponse" /evidence/export: post: @@ -580,7 +596,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Privacy-aware local evidence export + content: + application/json: + schema: + $ref: "#/components/schemas/LocalEvidenceExportResponse" /revocations: post: @@ -2533,6 +2553,179 @@ components: items: $ref: "#/components/schemas/IncidentRecordV2" + EvidenceEventV2: + type: object + required: + - schema_version + - id + - event_id + - issuer + - type + - actor + - privacy_mode + - issued_at + - timestamp + - prev_event_hash + - payload_hash + - event_hash + - signature + properties: + schema_version: + type: string + enum: [fides.evidence_event.v1] + id: + type: string + event_id: + type: string + issuer: + type: string + type: + type: string + enum: + - agent.registered + - agent.updated + - agent.revoked + - discovery.performed + - trust.computed + - policy.evaluated + - approval.requested + - approval.granted + - approval.denied + - session.requested + - session.granted + - session.denied + - capability.invoked + - capability.completed + - capability.failed + - attestation.issued + - attestation.verified + - attestation.failed + - revocation.recorded + - incident.reported + - kill_switch.triggered + actor: + type: string + subject: + type: string + principal: + type: string + capability: + type: string + input_hash: + type: string + output_hash: + type: string + policy_hash: + type: string + decision: + type: string + risk_level: + type: string + enum: [low, medium, high, critical] + privacy_mode: + type: string + enum: [public, private, redacted, hash_only] + issued_at: + type: string + format: date-time + timestamp: + type: string + format: date-time + prev_event_hash: + type: string + payload_hash: + type: string + event_hash: + type: string + signature: + type: string + metadata: + type: object + additionalProperties: true + + LocalEvidenceAppendResponse: + type: object + required: [accepted, event, authorityGranted] + properties: + accepted: + type: boolean + event: + $ref: "#/components/schemas/EvidenceEventV2" + authorityGranted: + type: boolean + enum: [false] + + LocalEvidenceEventResponse: + type: object + required: [event, authorityGranted] + properties: + event: + $ref: "#/components/schemas/EvidenceEventV2" + authorityGranted: + type: boolean + enum: [false] + + LocalEvidenceListResponse: + type: object + required: [events, count, valid, lastHash, authorityGranted] + properties: + events: + type: array + items: + $ref: "#/components/schemas/EvidenceEventV2" + count: + type: integer + valid: + type: boolean + lastHash: + type: string + nullable: true + authorityGranted: + type: boolean + enum: [false] + + LocalEvidenceVerificationResponse: + type: object + required: [valid, count, lastHash, scope, checkedAt] + properties: + valid: + type: boolean + count: + type: integer + lastHash: + type: string + nullable: true + scope: + type: string + enum: [root-local-evidence-ledger] + checkedAt: + type: string + format: date-time + + LocalEvidenceExportResponse: + type: object + required: [format, exportedAt, valid, count, privacyMode, includeMetadata, events] + properties: + format: + type: string + enum: [json] + exportedAt: + type: string + format: date-time + valid: + type: boolean + count: + type: integer + privacyMode: + type: string + includeMetadata: + type: boolean + nullable: true + events: + type: array + items: + $ref: "#/components/schemas/EvidenceEventV2" + EvidenceSubmitRequest: type: object required: [actor, action] From 082dabf612e0da8d33bff4c853730b14e69878df Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:38:35 +0300 Subject: [PATCH 185/282] fix(sdk): type evidence responses --- packages/sdk/src/fides-client.ts | 110 +++++++++++++++++++++++-- packages/sdk/test/fides-client.test.ts | 94 +++++++++++++++++++++ 2 files changed, 199 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 37c5913..2c364ae 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -192,6 +192,98 @@ export interface FidesEvidenceExportRequest { include_metadata?: boolean } +export type FidesEvidenceEventType = + | 'agent.registered' + | 'agent.updated' + | 'agent.revoked' + | 'discovery.performed' + | 'trust.computed' + | 'policy.evaluated' + | 'approval.requested' + | 'approval.granted' + | 'approval.denied' + | 'session.requested' + | 'session.granted' + | 'session.denied' + | 'capability.invoked' + | 'capability.completed' + | 'capability.failed' + | 'attestation.issued' + | 'attestation.verified' + | 'attestation.failed' + | 'revocation.recorded' + | 'incident.reported' + | 'kill_switch.triggered' + +export type FidesEvidencePrivacyMode = 'public' | 'private' | 'redacted' | 'hash_only' + +export interface FidesEvidenceEventV2 { + schema_version: 'fides.evidence_event.v1' + id: string + event_id: string + issuer: string + type: FidesEvidenceEventType + actor: string + subject?: string + principal?: string + capability?: string + input_hash?: string + output_hash?: string + policy_hash?: string + decision?: string + risk_level?: 'low' | 'medium' | 'high' | 'critical' + privacy_mode: FidesEvidencePrivacyMode + issued_at: string + timestamp: string + prev_event_hash: string + payload_hash: string + event_hash: string + signature: string + metadata?: Record +} + +export interface FidesEvidenceAppendResponse { + accepted: boolean + event: FidesEvidenceEventV2 + authorityGranted: false + [key: string]: unknown +} + +export interface FidesEvidenceEventResponse { + event: FidesEvidenceEventV2 + authorityGranted: false + [key: string]: unknown +} + +export interface FidesEvidenceListResponse { + events: FidesEvidenceEventV2[] + count: number + valid: boolean + lastHash: string | null + authorityGranted: false + [key: string]: unknown +} + +export interface FidesEvidenceVerificationResponse { + valid: boolean + count: number + lastHash: string | null + scope: 'root-local-evidence-ledger' + checkedAt: string + [key: string]: unknown +} + +export interface FidesEvidenceExportResponse { + format: 'json' + exportedAt: string + valid: boolean + count: number + privacyMode: FidesEvidencePrivacyMode | 'event_default' + includeMetadata: boolean | null + events: FidesEvidenceEventV2[] + [key: string]: unknown +} + export interface FidesIdentityAttestationRequest { identity: string } @@ -586,11 +678,19 @@ export class FidesClient { } readonly evidence = { - append: (body: Record) => this.post('/evidence', body), - list: () => this.get('/evidence'), - inspect: (eventId: string) => this.get(`/evidence/${encodeURIComponent(eventId)}`), - verify: () => this.post('/evidence/verify', {}), - export: (body: FidesEvidenceExportRequest = {}) => this.post('/evidence/export', body), + append: (body: Record): Promise => ( + this.post('/evidence', body) as Promise + ), + list: (): Promise => this.get('/evidence') as Promise, + inspect: (eventId: string): Promise => ( + this.get(`/evidence/${encodeURIComponent(eventId)}`) as Promise + ), + verify: (): Promise => ( + this.post('/evidence/verify', {}) as Promise + ), + export: (body: FidesEvidenceExportRequest = {}): Promise => ( + this.post('/evidence/export', body) as Promise + ), } readonly demo = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index a7a2ff3..8612822 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -782,6 +782,100 @@ describe('FidesClient', () => { expect(verified.authorityGranted).toBe(false) }) + it('types root evidence ledger responses as non-authorizing audit records', async () => { + const event = { + schema_version: 'fides.evidence_event.v1', + id: 'evt_1', + event_id: 'evt_1', + issuer: 'did:fides:agentd:local', + type: 'capability.invoked', + actor: 'did:fides:agent', + subject: 'did:fides:target', + capability: 'invoice.reconcile', + input_hash: 'sha256:input', + output_hash: 'sha256:output', + decision: 'dry_run', + risk_level: 'medium', + privacy_mode: 'hash_only', + issued_at: '2026-05-30T00:00:00.000Z', + timestamp: '2026-05-30T00:00:00.000Z', + prev_event_hash: '0', + payload_hash: 'sha256:payload', + event_hash: 'sha256:event', + signature: 'local-evidence-signature', + metadata: { source: 'test' }, + } + + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/evidence/export')) { + return new Response(JSON.stringify({ + format: 'json', + exportedAt: '2026-05-30T00:01:00.000Z', + valid: true, + count: 1, + privacyMode: 'hash_only', + includeMetadata: false, + events: [event], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/evidence/verify')) { + return new Response(JSON.stringify({ + valid: true, + count: 1, + lastHash: 'sha256:event', + scope: 'root-local-evidence-ledger', + checkedAt: '2026-05-30T00:01:00.000Z', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/evidence/evt_1')) { + return new Response(JSON.stringify({ + event, + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/evidence') && init?.method === 'GET') { + return new Response(JSON.stringify({ + events: [event], + count: 1, + valid: true, + lastHash: 'sha256:event', + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + accepted: true, + event, + authorityGranted: false, + }), { status: 201, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const appended = await client.evidence.append({ + type: 'capability.invoked', + actor: 'did:fides:agent', + privacyMode: 'hash_only', + }) + expect(appended.accepted).toBe(true) + expect(appended.authorityGranted).toBe(false) + expect(appended.event.privacy_mode).toBe('hash_only') + + const listed = await client.evidence.list() + expect(listed.valid).toBe(true) + expect(listed.events[0]?.event_hash).toBe('sha256:event') + + const inspected = await client.evidence.inspect('evt_1') + expect(inspected.event.type).toBe('capability.invoked') + expect(inspected.authorityGranted).toBe(false) + + const verified = await client.evidence.verify() + expect(verified.scope).toBe('root-local-evidence-ledger') + expect(verified.lastHash).toBe('sha256:event') + + const exported = await client.evidence.export({ privacy_mode: 'hash_only', include_metadata: false }) + expect(exported.privacyMode).toBe('hash_only') + expect(exported.events).toHaveLength(1) + }) + it('creates and submits signed invocation requests from a session grant', async () => { const requester = await createAgentIdentity() const sessionGrant: SessionGrantV2 = { From 297e2d439552ba8d7f85eff51c74128f7a8eb0e8 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:42:24 +0300 Subject: [PATCH 186/282] docs(api): schema trust reputation delegation responses --- docs/api/agentd.yaml | 248 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 243 insertions(+), 5 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index a585490..5e81a7e 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -332,7 +332,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Capability-scoped trust signal; never grants invocation authority + content: + application/json: + schema: + $ref: "#/components/schemas/LocalTrustEvaluationResponse" /trust/{id}: get: @@ -343,7 +347,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local trust results for an agent + content: + application/json: + schema: + $ref: "#/components/schemas/LocalTrustListResponse" /reputation/update: post: @@ -356,7 +364,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Capability-specific reputation signal; never grants invocation authority + content: + application/json: + schema: + $ref: "#/components/schemas/LocalReputationUpdateResponse" /reputation/{id}: get: @@ -367,7 +379,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local capability-specific reputation records for an agent + content: + application/json: + schema: + $ref: "#/components/schemas/LocalReputationListResponse" /policy/evaluate: post: @@ -463,7 +479,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: DelegationToken created; still requires policy-checked SessionGrant before invocation + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDelegationResponse" /sessions: post: @@ -1945,6 +1965,224 @@ components: type: object nullable: true + TrustReason: + type: object + required: [component, value, weight, description] + properties: + component: + type: string + enum: + - IdentityScore + - PublisherScore + - TrustAnchorScore + - CapabilityFitScore + - EvidenceScore + - PolicyComplianceScore + - RuntimeSafetyScore + - PeerAttestationScore + - IncidentPenalty + - NoveltyPenalty + - ContextBoundaryPenalty + value: + type: number + weight: + type: number + description: + type: string + + TrustResult: + type: object + required: + - schema_version + - id + - issuer + - subject + - agent_id + - capability + - score + - band + - reasons + - risk_flags + - evidence_refs + - required_controls + - computed_at + - payload_hash + properties: + schema_version: + type: string + enum: [fides.trust.result.v1] + id: + type: string + issuer: + type: string + subject: + type: string + agent_id: + type: string + capability: + type: string + score: + type: number + band: + type: string + enum: [unknown, low, medium, high, verified] + reasons: + type: array + items: + $ref: "#/components/schemas/TrustReason" + risk_flags: + type: array + items: + type: string + evidence_refs: + type: array + items: + type: string + required_controls: + type: array + items: + type: string + enum: [dry_run, human_approval, policy_proof, runtime_attestation, scope_limit, rate_limit] + computed_at: + type: string + format: date-time + payload_hash: + type: string + + LocalTrustEvaluationResponse: + type: object + required: [trust, authorityGranted, explanation] + properties: + trust: + $ref: "#/components/schemas/TrustResult" + authorityGranted: + type: boolean + enum: [false] + explanation: + type: string + + LocalTrustListResponse: + type: object + required: [agentId, trust, authorityGranted] + properties: + agentId: + type: string + trust: + type: array + items: + $ref: "#/components/schemas/TrustResult" + authorityGranted: + type: boolean + enum: [false] + + ReputationReason: + type: object + required: [factor, value, description] + properties: + factor: + type: string + enum: [success_rate, volume_confidence, publisher_weight, incident_penalty, context_boundary_penalty] + value: + type: number + description: + type: string + + ReputationRecord: + type: object + required: + - schema_version + - id + - issuer + - subject + - agent_id + - capability + - score + - successful_invocations + - failed_invocations + - incident_count + - publisher_weight + - context_boundary_penalty + - reasons + - computed_at + - payload_hash + properties: + schema_version: + type: string + enum: [fides.reputation.record.v1] + id: + type: string + issuer: + type: string + subject: + type: string + agent_id: + type: string + publisher_id: + type: string + principal_id: + type: string + capability: + type: string + score: + type: number + successful_invocations: + type: integer + failed_invocations: + type: integer + incident_count: + type: integer + publisher_weight: + type: number + context_boundary_penalty: + type: number + reasons: + type: array + items: + $ref: "#/components/schemas/ReputationReason" + computed_at: + type: string + format: date-time + payload_hash: + type: string + + LocalReputationUpdateResponse: + type: object + required: [reputation, authorityGranted] + properties: + reputation: + $ref: "#/components/schemas/ReputationRecord" + authorityGranted: + type: boolean + enum: [false] + + LocalReputationListResponse: + type: object + required: [agentId, reputations, authorityGranted] + properties: + agentId: + type: string + reputations: + type: array + items: + $ref: "#/components/schemas/ReputationRecord" + authorityGranted: + type: boolean + enum: [false] + + LocalDelegationResponse: + type: object + required: [token, signed, authorityGranted, explanation] + properties: + token: + $ref: "#/components/schemas/DelegationToken" + signed: + type: boolean + authorityGranted: + type: boolean + enum: [false] + explanation: + type: string + LocalSessionResponse: type: object required: [session, authorityGranted] From 9bd5fd207155dad892e6da792377b6076832df47 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:44:21 +0300 Subject: [PATCH 187/282] fix(sdk): type trust reputation delegation responses --- packages/sdk/src/fides-client.ts | 57 +++++++++++-- packages/sdk/test/fides-client.test.ts | 113 +++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 2c364ae..a0884a0 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -3,12 +3,14 @@ import { type ApprovalDecision, type ApprovalRequest, type CapabilityControl, + type DelegationToken, type IdentityTrustAnchor, type IncidentRecordV2, type KillSwitchRule, type PrincipalIdentity, type PublisherIdentity, type RevocationRecordV2, + type ReputationRecord, type TrustResult, createInvocationRequest, isErrorEnvelope, @@ -391,6 +393,41 @@ export interface FidesPolicyEvaluationResponse { explanation: string } +export interface FidesTrustEvaluationResponse { + trust: TrustResult + authorityGranted: false + explanation: string + [key: string]: unknown +} + +export interface FidesTrustListResponse { + agentId: string + trust: TrustResult[] + authorityGranted: false + [key: string]: unknown +} + +export interface FidesReputationUpdateResponse { + reputation: ReputationRecord + authorityGranted: false + [key: string]: unknown +} + +export interface FidesReputationListResponse { + agentId: string + reputations: ReputationRecord[] + authorityGranted: false + [key: string]: unknown +} + +export interface FidesDelegationResponse { + token: DelegationToken + signed: boolean + authorityGranted: false + explanation: string + [key: string]: unknown +} + export interface FidesApprovalRequestResponse { approval: ApprovalRequest evidenceRefs: string[] @@ -548,13 +585,21 @@ export class FidesClient { } readonly trust = { - evaluate: (body: Record) => this.post('/trust/evaluate', body), - get: (agentId: string) => this.get(`/trust/${encodeURIComponent(agentId)}`), + evaluate: (body: Record): Promise => ( + this.post('/trust/evaluate', body) as Promise + ), + get: (agentId: string): Promise => ( + this.get(`/trust/${encodeURIComponent(agentId)}`) as Promise + ), } readonly reputation = { - update: (body: Record) => this.post('/reputation/update', body), - get: (agentId: string) => this.get(`/reputation/${encodeURIComponent(agentId)}`), + update: (body: Record): Promise => ( + this.post('/reputation/update', body) as Promise + ), + get: (agentId: string): Promise => ( + this.get(`/reputation/${encodeURIComponent(agentId)}`) as Promise + ), } readonly policy = { @@ -564,7 +609,9 @@ export class FidesClient { } readonly delegations = { - create: (body: Record) => this.post('/delegations', body), + create: (body: Record): Promise => ( + this.post('/delegations', body) as Promise + ), } readonly approvals = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 8612822..44353d4 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -876,6 +876,119 @@ describe('FidesClient', () => { expect(exported.events).toHaveLength(1) }) + it('types trust, reputation, and delegation responses as non-authorizing signals', async () => { + const trust = { + schema_version: 'fides.trust.result.v1', + id: 'trust_1', + issuer: 'fides.trust-engine', + subject: 'did:fides:agent', + agent_id: 'did:fides:agent', + capability: 'invoice.reconcile', + score: 0.72, + band: 'high', + reasons: [ + { component: 'IdentityScore', value: 1, weight: 0.14, description: 'identity verified' }, + ], + risk_flags: [], + evidence_refs: ['evt_trust'], + required_controls: [], + computed_at: '2026-05-30T00:00:00.000Z', + payload_hash: 'sha256:trust', + } + const reputation = { + schema_version: 'fides.reputation.record.v1', + id: 'rep_1', + issuer: 'fides.reputation-engine', + subject: 'did:fides:agent', + agent_id: 'did:fides:agent', + capability: 'invoice.reconcile', + score: 0.81, + successful_invocations: 12, + failed_invocations: 1, + incident_count: 0, + publisher_weight: 0.8, + context_boundary_penalty: 0, + reasons: [ + { factor: 'success_rate', value: 0.9231, description: 'capability success rate' }, + ], + computed_at: '2026-05-30T00:00:00.000Z', + payload_hash: 'sha256:reputation', + } + const token = { + id: 'del_1', + delegator: 'did:fides:principal', + delegatee: 'did:fides:requester', + capabilities: ['invoice.reconcile'], + constraints: { maxActions: 1 }, + issuedAt: '2026-05-30T00:00:00.000Z', + expiresAt: '2026-05-30T01:00:00.000Z', + nonce: 'nonce_1', + audience: ['did:fides:agent'], + signature: 'local-delegation-signature', + } + + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/trust/evaluate')) { + return new Response(JSON.stringify({ + trust, + authorityGranted: false, + explanation: 'Trust is a signal only. Policy evaluation and scoped session grants are required before invocation.', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/trust/did%3Afides%3Aagent')) { + return new Response(JSON.stringify({ + agentId: 'did:fides:agent', + trust: [trust], + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/reputation/update')) { + return new Response(JSON.stringify({ + reputation, + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/reputation/did%3Afides%3Aagent')) { + return new Response(JSON.stringify({ + agentId: 'did:fides:agent', + reputations: [reputation], + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + token, + signed: true, + authorityGranted: false, + explanation: 'Delegation records signed scoped authorization intent. It must still be converted into a policy-checked SessionGrant before invocation.', + }), { status: 201, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const trustResult = await client.trust.evaluate({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + expect(trustResult.trust.band).toBe('high') + expect(trustResult.authorityGranted).toBe(false) + + const trustList = await client.trust.get('did:fides:agent') + expect(trustList.trust[0]?.capability).toBe('invoice.reconcile') + expect(trustList.authorityGranted).toBe(false) + + const reputationResult = await client.reputation.update({ agentId: 'did:fides:agent', capability: 'invoice.reconcile' }) + expect(reputationResult.reputation.score).toBe(0.81) + expect(reputationResult.authorityGranted).toBe(false) + + const reputationList = await client.reputation.get('did:fides:agent') + expect(reputationList.reputations[0]?.capability).toBe('invoice.reconcile') + expect(reputationList.authorityGranted).toBe(false) + + const delegation = await client.delegations.create({ + delegator: 'did:fides:principal', + delegatee: 'did:fides:requester', + capabilities: ['invoice.reconcile'], + }) + expect(delegation.token.capabilities).toEqual(['invoice.reconcile']) + expect(delegation.authorityGranted).toBe(false) + }) + it('creates and submits signed invocation requests from a session grant', async () => { const requester = await createAgentIdentity() const sessionGrant: SessionGrantV2 = { From 6903be28fec9221f0d515d1faf421f78001d4d68 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:46:14 +0300 Subject: [PATCH 188/282] docs(api): schema local agent card responses --- docs/api/agentd.yaml | 160 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 4 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 5e81a7e..ddd1f77 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -165,7 +165,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Local AgentCard created and validated + content: + application/json: + schema: + $ref: "#/components/schemas/LocalAgentCardCreateResponse" /agent-cards/{id}: get: @@ -176,7 +180,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local AgentCard and optional signed canonical object + content: + application/json: + schema: + $ref: "#/components/schemas/LocalAgentCardResponse" "404": $ref: "#/components/responses/Error" @@ -193,7 +201,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Identity-bound signed AgentCard + content: + application/json: + schema: + $ref: "#/components/schemas/LocalAgentCardSignResponse" /agent-cards/{id}/verify: post: @@ -206,7 +218,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local AgentCard verification result + content: + application/json: + schema: + $ref: "#/components/schemas/LocalAgentCardVerifyResponse" /agents/register: post: @@ -1674,6 +1690,142 @@ components: items: $ref: "#/components/schemas/LocalIdentitySummary" + AgentCardValidation: + type: object + required: [valid, errors] + properties: + valid: + type: boolean + errors: + type: array + items: + type: string + + AgentCard: + type: object + required: [id, identity, capabilities, endpoints, policies, createdAt, updatedAt] + properties: + schema_version: + type: string + enum: [fides.agent_card.v1] + id: + type: string + agent_id: + type: string + identity: + type: object + additionalProperties: true + publisher: + type: object + additionalProperties: true + capabilities: + type: array + items: + type: object + additionalProperties: true + endpoints: + type: array + items: + type: object + additionalProperties: true + transports: + type: array + items: + type: object + additionalProperties: true + policies: + type: array + items: + type: object + additionalProperties: true + publicKeys: + type: array + items: + type: object + additionalProperties: true + trustAnchors: + type: array + items: + $ref: "#/components/schemas/IdentityTrustAnchor" + runtimeAttestations: + type: array + items: + $ref: "#/components/schemas/RuntimeAttestationV2" + protocolVersions: + type: array + items: + type: string + revocationUrl: + type: string + revocationRef: + type: string + expiresAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + SignedAgentCard: + type: object + required: [payload, proof] + properties: + payload: + $ref: "#/components/schemas/AgentCard" + proof: + type: object + additionalProperties: true + + LocalAgentCardCreateResponse: + type: object + required: [card, validation] + properties: + card: + $ref: "#/components/schemas/AgentCard" + validation: + $ref: "#/components/schemas/AgentCardValidation" + + LocalAgentCardResponse: + type: object + required: [card, signed] + properties: + card: + $ref: "#/components/schemas/AgentCard" + signed: + nullable: true + oneOf: + - $ref: "#/components/schemas/SignedAgentCard" + - type: "null" + + LocalAgentCardSignResponse: + type: object + required: [signed] + properties: + signed: + $ref: "#/components/schemas/SignedAgentCard" + + LocalAgentCardVerifyResponse: + type: object + required: [valid, signed] + properties: + valid: + type: boolean + signed: + type: boolean + canonicalValid: + type: boolean + identityBound: + type: boolean + validation: + $ref: "#/components/schemas/AgentCardValidation" + error: + type: string + id: + type: string + IdentityTrustAnchor: type: object required: [type, value, verified] From cd02701a4d6b1bfe85748c219c9d44dfa86cf30b Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:47:42 +0300 Subject: [PATCH 189/282] fix(sdk): type local agent card responses --- packages/sdk/src/fides-client.ts | 51 ++++++++++++++++++++++++-- packages/sdk/test/fides-client.test.ts | 50 +++++++++++++++++++++---- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index a0884a0..2f84c40 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -1,5 +1,6 @@ import { type AgentIdentity, + type AgentCard, type ApprovalDecision, type ApprovalRequest, type CapabilityControl, @@ -20,6 +21,7 @@ import { type InvocationResult, type RuntimeAttestation, type SessionGrantV2, + type SignedAgentCard, type SignedSessionGrantV2, type SignedInvocationRequest, type SignedInvocationResult, @@ -151,6 +153,39 @@ export interface FidesLocalAgentDetailResponse extends FidesLocalAgentRegistrati signedCard: Record | null } +export interface FidesAgentCardValidation { + valid: boolean + errors: string[] +} + +export interface FidesAgentCardCreateResponse { + card: AgentCard + validation: FidesAgentCardValidation + [key: string]: unknown +} + +export interface FidesAgentCardResponse { + card: AgentCard + signed: SignedAgentCard | null + [key: string]: unknown +} + +export interface FidesAgentCardSignResponse { + signed: SignedAgentCard + [key: string]: unknown +} + +export interface FidesAgentCardVerifyResponse { + valid: boolean + signed: boolean + canonicalValid?: boolean + identityBound?: boolean + validation?: FidesAgentCardValidation + error?: string + id?: string + [key: string]: unknown +} + export interface FidesRegistryPublishRequest { agentCardId: string mode?: 'public' | 'private' @@ -554,10 +589,18 @@ export class FidesClient { } readonly cards = { - create: (body: Record) => this.post('/agent-cards', body), - sign: (card: { id?: string } & Record) => this.post(`/agent-cards/${encodeURIComponent(String(card.id))}/sign`, card), - verify: (id: string) => this.post(`/agent-cards/${encodeURIComponent(id)}/verify`, {}), - get: (id: string) => this.get(`/agent-cards/${encodeURIComponent(id)}`), + create: (body: Record): Promise => ( + this.post('/agent-cards', body) as Promise + ), + sign: (card: { id?: string } & Record): Promise => ( + this.post(`/agent-cards/${encodeURIComponent(String(card.id))}/sign`, card) as Promise + ), + verify: (id: string): Promise => ( + this.post(`/agent-cards/${encodeURIComponent(id)}/verify`, {}) as Promise + ), + get: (id: string): Promise => ( + this.get(`/agent-cards/${encodeURIComponent(id)}`) as Promise + ), } readonly agents = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 44353d4..d875e65 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -1066,31 +1066,67 @@ describe('FidesClient', () => { }) it('uses the root AgentCard API served by local agentd', async () => { + const card = { + schema_version: 'fides.agent_card.v1', + id: 'card_1', + agent_id: 'did:fides:agent', + identity: { did: 'did:fides:agent', publicKey: new Uint8Array(32) }, + capabilities: [], + endpoints: [], + policies: [{ requiresRuntimeAttestation: false, requiresApproval: false }], + protocolVersions: ['fides.v2.0'], + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + } + const signed = { + payload: card, + proof: { + type: 'Ed25519Signature2020', + verificationMethod: 'did:fides:agent', + proofPurpose: 'assertionMethod', + created: '2026-05-30T00:00:00.000Z', + signature: 'local-agent-card-signature', + }, + } const calls: Array<{ url: string; init?: RequestInit }> = [] vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { calls.push({ url: String(url), init }) if (String(url).endsWith('/agent-cards/card_1/sign')) { - return new Response(JSON.stringify({ signed: { payload: { id: 'card_1' }, proof: {} } }), { status: 200 }) + return new Response(JSON.stringify({ signed }), { status: 200 }) } if (String(url).endsWith('/agent-cards/card_1/verify')) { - return new Response(JSON.stringify({ valid: true }), { status: 200 }) + return new Response(JSON.stringify({ + valid: true, + signed: true, + canonicalValid: true, + identityBound: true, + }), { status: 200 }) } if (String(url).endsWith('/agent-cards/card_1')) { - return new Response(JSON.stringify({ card: { id: 'card_1' } }), { status: 200 }) + return new Response(JSON.stringify({ card, signed }), { status: 200 }) } - return new Response(JSON.stringify({ card: { id: 'card_1' }, validation: { valid: true } }), { status: 201 }) + return new Response(JSON.stringify({ card, validation: { valid: true, errors: [] } }), { status: 201 }) })) const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) await expect(client.cards.create({ identity: { did: 'did:fides:agent' }, capabilities: [] })).resolves.toMatchObject({ - card: { id: 'card_1' }, + card: { id: 'card_1', schema_version: 'fides.agent_card.v1' }, + validation: { valid: true, errors: [] }, }) await expect(client.cards.sign({ id: 'card_1' })).resolves.toMatchObject({ + signed: { payload: { id: 'card_1' }, proof: { verificationMethod: 'did:fides:agent' } }, + }) + await expect(client.cards.verify('card_1')).resolves.toMatchObject({ + valid: true, + signed: true, + canonicalValid: true, + identityBound: true, + }) + await expect(client.cards.get('card_1')).resolves.toMatchObject({ + card: { id: 'card_1' }, signed: { payload: { id: 'card_1' } }, }) - await expect(client.cards.verify('card_1')).resolves.toMatchObject({ valid: true }) - await expect(client.cards.get('card_1')).resolves.toMatchObject({ card: { id: 'card_1' } }) expect(calls.map(call => call.url)).toEqual([ 'http://localhost:7345/agent-cards', From 1851fad02392c2411609c97d8d823e780f84125a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:49:53 +0300 Subject: [PATCH 190/282] docs(api): schema local discovery infrastructure responses --- docs/api/agentd.yaml | 245 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 233 insertions(+), 12 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index ddd1f77..2740ddc 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -801,7 +801,11 @@ paths: - ApiKeyAuth: [] responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local in-memory DHT simulator status + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDhtStartResponse" /dht/publish: post: @@ -814,7 +818,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Local DHT pointer published; pointers are not trust sources + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDhtPublishResponse" /dht/find: get: @@ -829,7 +837,11 @@ paths: type: string responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local DHT pointer lookup result + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDhtFindResponse" post: operationId: postFindLocalDhtPointers tags: [Discovery] @@ -838,7 +850,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local DHT pointer lookup result + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDhtFindResponse" /registry/start: post: @@ -849,7 +865,11 @@ paths: - ApiKeyAuth: [] responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local registry simulator status + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRegistryStartResponse" /registry/publish: post: @@ -862,7 +882,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Local registry record published + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRegistryPublishResponse" /registry/search: post: @@ -882,7 +906,11 @@ paths: summary: Read the local registry index responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local registry index; registry records are candidates only + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRegistryIndexResponse" /relay/start: post: @@ -893,7 +921,11 @@ paths: - ApiKeyAuth: [] responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local relay simulator status + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRelayStartResponse" /relay/register: post: @@ -906,7 +938,11 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - $ref: "#/components/responses/JsonObject" + description: Local relay presence registered; relay presence is not authority + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRelayRegisterResponse" /relay/discover: post: @@ -926,7 +962,11 @@ paths: summary: Read the local FIDES well-known document responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local FIDES well-known discovery document + content: + application/json: + schema: + $ref: "#/components/schemas/WellKnownFidesResponse" /.well-known/agents.json: get: @@ -935,7 +975,11 @@ paths: summary: Read local well-known agent index responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local well-known agent index + content: + application/json: + schema: + $ref: "#/components/schemas/WellKnownAgentsResponse" /.well-known/agents/{id}.json: get: @@ -946,7 +990,11 @@ paths: - $ref: "#/components/parameters/IdParam" responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local well-known AgentCard document + content: + application/json: + schema: + $ref: "#/components/schemas/WellKnownAgentResponse" /demo/run: post: @@ -1826,6 +1874,179 @@ components: id: type: string + LocalDiscoveryRecord: + type: object + additionalProperties: true + + LocalInfrastructureStartResponse: + type: object + required: [started, mode] + properties: + started: + type: boolean + mode: + type: string + records: + type: integer + pointers: + type: integer + authorityGranted: + type: boolean + enum: [false] + + LocalDhtStartResponse: + allOf: + - $ref: "#/components/schemas/LocalInfrastructureStartResponse" + - type: object + properties: + mode: + type: string + enum: [in_memory_simulator] + + LocalDhtPublishResponse: + type: object + required: [accepted, pointer] + properties: + accepted: + type: boolean + pointer: + $ref: "#/components/schemas/LocalDiscoveryRecord" + + LocalDhtFindResponse: + type: object + required: [capability, pointers, rejectedPointers] + properties: + capability: + type: string + nullable: true + pointers: + type: array + items: + $ref: "#/components/schemas/LocalDiscoveryRecord" + rejectedPointers: + type: array + items: + $ref: "#/components/schemas/LocalDiscoveryRecord" + + LocalRegistryStartResponse: + allOf: + - $ref: "#/components/schemas/LocalInfrastructureStartResponse" + - type: object + properties: + mode: + type: string + enum: [local_mock_registry] + + LocalRegistryPublishResponse: + type: object + required: [accepted, record] + properties: + accepted: + type: boolean + record: + $ref: "#/components/schemas/LocalDiscoveryRecord" + + LocalRegistryIndexResponse: + type: object + required: [mode, records, rejectedRecords, authorityGranted] + properties: + mode: + type: string + enum: [local_mock_registry] + records: + type: array + items: + $ref: "#/components/schemas/LocalDiscoveryRecord" + rejectedRecords: + type: array + items: + $ref: "#/components/schemas/LocalDiscoveryRecord" + authorityGranted: + type: boolean + enum: [false] + + LocalRelayStartResponse: + allOf: + - $ref: "#/components/schemas/LocalInfrastructureStartResponse" + - type: object + properties: + mode: + type: string + enum: [local_mock_relay] + + LocalRelayRegisterResponse: + type: object + required: [accepted, record] + properties: + accepted: + type: boolean + record: + $ref: "#/components/schemas/LocalDiscoveryRecord" + + WellKnownFidesResponse: + type: object + required: [schema_version, protocol, supported_versions, endpoints] + properties: + schema_version: + type: string + enum: [fides.well_known.v1] + protocol: + type: string + enum: [fides.v2] + supported_versions: + type: array + items: + type: string + endpoints: + type: object + additionalProperties: + type: string + + WellKnownAgentSummary: + type: object + required: [agentId, cardId, signed, cardUrl, authorityGranted] + properties: + agentId: + type: string + cardId: + type: string + signed: + type: boolean + cardUrl: + type: string + authorityGranted: + type: boolean + enum: [false] + + WellKnownAgentsResponse: + type: object + required: [schema_version, agents] + properties: + schema_version: + type: string + enum: [fides.well_known.agents.v1] + agents: + type: array + items: + $ref: "#/components/schemas/WellKnownAgentSummary" + + WellKnownAgentResponse: + type: object + required: [agentId, card, authorityGranted] + properties: + agentId: + type: string + card: + $ref: "#/components/schemas/AgentCard" + signed: + nullable: true + oneOf: + - $ref: "#/components/schemas/SignedAgentCard" + - type: "null" + authorityGranted: + type: boolean + enum: [false] + IdentityTrustAnchor: type: object required: [type, value, verified] From 38d327510bf5f47bcbcc7ece2289020bdf111cd0 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:51:34 +0300 Subject: [PATCH 191/282] fix(sdk): type discovery infrastructure responses --- packages/sdk/src/fides-client.ts | 104 +++++++++++++++-- packages/sdk/test/fides-client.test.ts | 154 +++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 2f84c40..8351e54 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -205,6 +205,68 @@ export interface FidesDhtPublishRequest { expiresAt?: string } +export interface FidesDiscoveryInfrastructureStartResponse { + started: boolean + mode: string + records?: number + pointers?: number + authorityGranted?: false + [key: string]: unknown +} + +export interface FidesDiscoveryInfrastructurePublishResponse { + accepted: boolean + record?: FidesProviderRecord + pointer?: FidesProviderRecord + [key: string]: unknown +} + +export interface FidesRegistryIndexResponse { + mode: 'local_mock_registry' | string + records: FidesProviderRecord[] + rejectedRecords: FidesProviderRecord[] + authorityGranted: false + [key: string]: unknown +} + +export interface FidesDhtFindResponse { + capability: string | null + pointers: FidesProviderRecord[] + rejectedPointers: FidesProviderRecord[] + [key: string]: unknown +} + +export interface FidesWellKnownFidesResponse { + schema_version: 'fides.well_known.v1' + protocol: 'fides.v2' + supported_versions: string[] + endpoints: Record + [key: string]: unknown +} + +export interface FidesWellKnownAgentSummary { + agentId: string + cardId: string + signed: boolean + cardUrl: string + authorityGranted: false + [key: string]: unknown +} + +export interface FidesWellKnownAgentsResponse { + schema_version: 'fides.well_known.agents.v1' + agents: FidesWellKnownAgentSummary[] + [key: string]: unknown +} + +export interface FidesWellKnownAgentResponse { + agentId: string + card: AgentCard + signed?: SignedAgentCard | null + authorityGranted: false + [key: string]: unknown +} + export interface FidesInvocationRequest { sessionId?: string session_id?: string @@ -743,28 +805,48 @@ export class FidesClient { } readonly registry = { - start: () => this.post('/registry/start', {}), - publish: (body: FidesRegistryPublishRequest) => this.post('/registry/publish', body), + start: (): Promise => ( + this.post('/registry/start', {}) as Promise + ), + publish: (body: FidesRegistryPublishRequest): Promise => ( + this.post('/registry/publish', body) as Promise + ), search: (body: FidesDiscoveryQuery): Promise => this.post('/registry/search', body) as Promise, - index: () => this.get('/registry/index'), + index: (): Promise => this.get('/registry/index') as Promise, } readonly relay = { - start: () => this.post('/relay/start', {}), - register: (body: FidesRelayRegisterRequest) => this.post('/relay/register', body), + start: (): Promise => ( + this.post('/relay/start', {}) as Promise + ), + register: (body: FidesRelayRegisterRequest): Promise => ( + this.post('/relay/register', body) as Promise + ), discover: (body: FidesDiscoveryQuery): Promise => this.post('/relay/discover', body) as Promise, } readonly dht = { - start: () => this.post('/dht/start', {}), - publish: (body: FidesDhtPublishRequest) => this.post('/dht/publish', body), - find: (body: Pick) => this.post('/dht/find', body), + start: (): Promise => ( + this.post('/dht/start', {}) as Promise + ), + publish: (body: FidesDhtPublishRequest): Promise => ( + this.post('/dht/publish', body) as Promise + ), + find: (body: Pick): Promise => ( + this.post('/dht/find', body) as Promise + ), } readonly wellKnown = { - fides: () => this.get('/.well-known/fides.json'), - agents: () => this.get('/.well-known/agents.json'), - agent: (agentId: string) => this.get(`/.well-known/agents/${encodeURIComponent(agentId)}.json`), + fides: (): Promise => ( + this.get('/.well-known/fides.json') as Promise + ), + agents: (): Promise => ( + this.get('/.well-known/agents.json') as Promise + ), + agent: (agentId: string): Promise => ( + this.get(`/.well-known/agents/${encodeURIComponent(agentId)}.json`) as Promise + ), } readonly evidence = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index d875e65..00f9b5b 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -989,6 +989,160 @@ describe('FidesClient', () => { expect(delegation.authorityGranted).toBe(false) }) + it('types local discovery infrastructure responses as candidate-only records', async () => { + const record = { + id: 'reg_1', + agentId: 'did:fides:agent', + cardId: 'card_1', + capabilities: ['invoice.reconcile'], + authorityGranted: false, + reasons: ['registry_record_candidate_only'], + } + const pointer = { + id: 'ptr_1', + capability: 'invoice.reconcile', + agentId: 'did:fides:agent', + agentCardUrl: 'local://agent-cards/card_1', + signed: true, + authorityGranted: false, + } + const card = { + schema_version: 'fides.agent_card.v1', + id: 'card_1', + agent_id: 'did:fides:agent', + identity: { did: 'did:fides:agent', publicKey: new Uint8Array(32) }, + capabilities: [], + endpoints: [], + policies: [{ requiresRuntimeAttestation: false, requiresApproval: false }], + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + } + + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request) => { + const target = String(url) + if (target.endsWith('/registry/start')) { + return new Response(JSON.stringify({ + started: true, + mode: 'local_mock_registry', + records: 1, + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (target.endsWith('/registry/publish')) { + return new Response(JSON.stringify({ accepted: true, record }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (target.endsWith('/registry/index')) { + return new Response(JSON.stringify({ + mode: 'local_mock_registry', + records: [record], + rejectedRecords: [], + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (target.endsWith('/relay/start')) { + return new Response(JSON.stringify({ + started: true, + mode: 'local_mock_relay', + records: 1, + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (target.endsWith('/relay/register')) { + return new Response(JSON.stringify({ accepted: true, record }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (target.endsWith('/dht/start')) { + return new Response(JSON.stringify({ + started: true, + mode: 'in_memory_simulator', + pointers: 1, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (target.endsWith('/dht/publish')) { + return new Response(JSON.stringify({ accepted: true, pointer }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (target.endsWith('/dht/find')) { + return new Response(JSON.stringify({ + capability: 'invoice.reconcile', + pointers: [pointer], + rejectedPointers: [], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (target.endsWith('/.well-known/fides.json')) { + return new Response(JSON.stringify({ + schema_version: 'fides.well_known.v1', + protocol: 'fides.v2', + supported_versions: ['fides.v2.0'], + endpoints: { agents: '/.well-known/agents.json' }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (target.endsWith('/.well-known/agents.json')) { + return new Response(JSON.stringify({ + schema_version: 'fides.well_known.agents.v1', + agents: [{ + agentId: 'did:fides:agent', + cardId: 'card_1', + signed: true, + cardUrl: '/.well-known/agents/did%3Afides%3Aagent.json', + authorityGranted: false, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + agentId: 'did:fides:agent', + card, + signed: null, + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const registryStarted = await client.registry.start() + expect(registryStarted.mode).toBe('local_mock_registry') + expect(registryStarted.authorityGranted).toBe(false) + + const registryPublished = await client.registry.publish({ agentCardId: 'card_1' }) + expect(registryPublished.accepted).toBe(true) + expect(registryPublished.record?.authorityGranted).toBe(false) + + const registryIndex = await client.registry.index() + expect(registryIndex.records[0]?.agentId).toBe('did:fides:agent') + expect(registryIndex.authorityGranted).toBe(false) + + const relayStarted = await client.relay.start() + expect(relayStarted.mode).toBe('local_mock_relay') + expect(relayStarted.authorityGranted).toBe(false) + + const relayRegistered = await client.relay.register({ agentId: 'did:fides:agent' }) + expect(relayRegistered.record?.authorityGranted).toBe(false) + + const dhtStarted = await client.dht.start() + expect(dhtStarted.mode).toBe('in_memory_simulator') + + const dhtPublished = await client.dht.publish({ capability: 'invoice.reconcile', agentId: 'did:fides:agent' }) + expect(dhtPublished.pointer?.authorityGranted).toBe(false) + + const dhtFind = await client.dht.find({ capability: 'invoice.reconcile' }) + expect(dhtFind.pointers[0]?.capability).toBe('invoice.reconcile') + + const fidesWellKnown = await client.wellKnown.fides() + expect(fidesWellKnown.protocol).toBe('fides.v2') + + const agentsWellKnown = await client.wellKnown.agents() + expect(agentsWellKnown.agents[0]?.authorityGranted).toBe(false) + + const agentWellKnown = await client.wellKnown.agent('did:fides:agent') + expect(agentWellKnown.authorityGranted).toBe(false) + }) + it('creates and submits signed invocation requests from a session grant', async () => { const requester = await createAgentIdentity() const sessionGrant: SessionGrantV2 = { From cc9b6a72da5c7f0d01e9fa8eec16b27cb879d14f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:53:42 +0300 Subject: [PATCH 192/282] docs(api): schema demo and simulation responses --- docs/api/agentd.yaml | 166 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 2 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 2740ddc..0f0ff94 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -1005,7 +1005,11 @@ paths: - ApiKeyAuth: [] responses: "200": - $ref: "#/components/responses/JsonObject" + description: Full local demo result with trust, policy, invocation, and evidence summary + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDemoRunResponse" /simulate/adversarial: post: @@ -1016,7 +1020,11 @@ paths: - ApiKeyAuth: [] responses: "200": - $ref: "#/components/responses/JsonObject" + description: Local adversarial simulation detection result + content: + application/json: + schema: + $ref: "#/components/schemas/LocalAdversarialSimulationResponse" /v1/identities/{did}: get: @@ -3337,6 +3345,160 @@ components: items: $ref: "#/components/schemas/EvidenceEventV2" + LocalDemoAuthoritySummary: + type: object + required: [discoveryGrantsAuthority, policyBeforeExecution, evidenceProduced] + properties: + discoveryGrantsAuthority: + type: boolean + enum: [false] + identityEqualsTrust: + type: boolean + enum: [false] + trustScoreEqualsPermission: + type: boolean + enum: [false] + policyBeforeExecution: + type: boolean + evidenceProduced: + type: boolean + + LocalDemoRunResponse: + type: object + required: [status, mode, steps, identities, discovery, verification, authority, surfaces, limitations] + properties: + status: + type: string + enum: [executed] + mode: + type: string + enum: [local-first] + steps: + type: array + items: + type: string + identities: + type: object + additionalProperties: + type: string + discovery: + type: object + additionalProperties: true + verification: + type: object + required: [agentCardsVerified, evidenceHashChainValid, evidenceEventCount, evidenceExport] + properties: + agentCardsVerified: + type: boolean + evidenceHashChainValid: + type: boolean + evidenceEventCount: + type: integer + evidenceExport: + type: object + additionalProperties: true + trust: + type: object + additionalProperties: true + reputation: + type: object + additionalProperties: true + policy: + type: object + additionalProperties: true + sessions: + type: object + additionalProperties: true + invocation: + type: object + additionalProperties: true + governance: + type: object + additionalProperties: true + authority: + $ref: "#/components/schemas/LocalDemoAuthoritySummary" + surfaces: + type: object + additionalProperties: true + limitations: + type: array + items: + type: string + + LocalAdversarialScenario: + type: object + required: [name, detected, outcome, evidenceRef] + properties: + name: + type: string + detected: + type: boolean + outcome: + type: string + evidenceRef: + type: string + policy: + type: object + additionalProperties: true + trust: + type: object + additionalProperties: true + reputation: + type: object + additionalProperties: true + errors: + type: array + items: + type: string + + LocalAdversarialSimulationResponse: + type: object + required: [status, mode, detections, scenarios, evidence, authority, limitations] + properties: + status: + type: string + enum: [detected, partial] + mode: + type: string + enum: [local-first] + detections: + type: array + items: + type: string + scenarios: + type: array + items: + $ref: "#/components/schemas/LocalAdversarialScenario" + incident: + $ref: "#/components/schemas/IncidentRecordV2" + revocation: + $ref: "#/components/schemas/RevocationRecordV2" + preflight: + type: object + additionalProperties: true + evidence: + type: object + required: [scenarioEvents, incidentEvidenceRef, rootChainValid, rootEventCount, brokenEvidenceChainValid] + properties: + scenarioEvents: + type: object + additionalProperties: + type: string + incidentEvidenceRef: + type: string + rootChainValid: + type: boolean + rootEventCount: + type: integer + brokenEvidenceChainValid: + type: boolean + authority: + $ref: "#/components/schemas/LocalDemoAuthoritySummary" + limitations: + type: array + items: + type: string + EvidenceSubmitRequest: type: object required: [actor, action] From 01101625dfe93688d8b583bd23c3928232c6d134 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:54:57 +0300 Subject: [PATCH 193/282] fix(sdk): type demo and simulation responses --- packages/sdk/src/fides-client.ts | 73 ++++++++++++++++++++- packages/sdk/test/fides-client.test.ts | 87 ++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 8351e54..7112486 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -383,6 +383,73 @@ export interface FidesEvidenceExportResponse { [key: string]: unknown } +export interface FidesDemoAuthoritySummary { + discoveryGrantsAuthority: false + identityEqualsTrust?: false + trustScoreEqualsPermission?: false + policyBeforeExecution: boolean + evidenceProduced: boolean + [key: string]: unknown +} + +export interface FidesDemoRunResponse { + status: 'executed' + mode: 'local-first' + steps: string[] + identities: Record + discovery: Record + verification: { + agentCardsVerified: boolean + evidenceHashChainValid: boolean + evidenceEventCount: number + evidenceExport: Record + [key: string]: unknown + } + trust?: Record + reputation?: Record + policy?: Record + sessions?: Record + invocation?: Record + governance?: Record + authority: FidesDemoAuthoritySummary + surfaces: Record + limitations: string[] + [key: string]: unknown +} + +export interface FidesAdversarialScenario { + name: string + detected: boolean + outcome: string + evidenceRef: string + policy?: Record + trust?: Record + reputation?: Record + errors?: string[] + [key: string]: unknown +} + +export interface FidesAdversarialSimulationResponse { + status: 'detected' | 'partial' + mode: 'local-first' + detections: string[] + scenarios: FidesAdversarialScenario[] + incident?: IncidentRecordV2 + revocation?: RevocationRecordV2 + preflight?: Record + evidence: { + scenarioEvents: Record + incidentEvidenceRef: string + rootChainValid: boolean + rootEventCount: number + brokenEvidenceChainValid: boolean + [key: string]: unknown + } + authority: FidesDemoAuthoritySummary + limitations: string[] + [key: string]: unknown +} + export interface FidesIdentityAttestationRequest { identity: string } @@ -866,11 +933,13 @@ export class FidesClient { } readonly demo = { - run: () => this.post('/demo/run', {}), + run: (): Promise => this.post('/demo/run', {}) as Promise, } readonly simulate = { - adversarial: () => this.post('/simulate/adversarial', {}), + adversarial: (): Promise => ( + this.post('/simulate/adversarial', {}) as Promise + ), } constructor(private readonly options: FidesClientOptions) {} diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 00f9b5b..24cc5e8 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -1143,6 +1143,93 @@ describe('FidesClient', () => { expect(agentWellKnown.authorityGranted).toBe(false) }) + it('types demo and adversarial simulation responses with authority invariants', async () => { + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request) => { + if (String(url).endsWith('/simulate/adversarial')) { + return new Response(JSON.stringify({ + status: 'detected', + mode: 'local-first', + detections: ['fake_agent', 'broken_evidence_chain'], + scenarios: [ + { + name: 'fake_agent', + detected: true, + outcome: 'policy_limited', + evidenceRef: 'evt_fake_agent', + }, + { + name: 'broken_evidence_chain', + detected: true, + outcome: 'evidence_verification_failed', + evidenceRef: 'evt_broken_chain', + }, + ], + evidence: { + scenarioEvents: { + fake_agent: 'evt_fake_agent', + broken_evidence_chain: 'evt_broken_chain', + }, + incidentEvidenceRef: 'evt_incident', + rootChainValid: true, + rootEventCount: 3, + brokenEvidenceChainValid: false, + }, + authority: { + discoveryGrantsAuthority: false, + policyBeforeExecution: true, + evidenceProduced: true, + }, + limitations: ['local mock simulation'], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + status: 'executed', + mode: 'local-first', + steps: ['Create principal identity', 'Verify evidence hash chain'], + identities: { + principal: 'did:fides:principal', + invoice: 'did:fides:invoice', + }, + discovery: { + registry: { authorityGranted: false }, + dht: { authorityGranted: false }, + }, + verification: { + agentCardsVerified: true, + evidenceHashChainValid: true, + evidenceEventCount: 12, + evidenceExport: { format: 'json', lastHash: 'sha256:last' }, + }, + authority: { + discoveryGrantsAuthority: false, + identityEqualsTrust: false, + trustScoreEqualsPermission: false, + policyBeforeExecution: true, + evidenceProduced: true, + }, + surfaces: { + registry: 'local_mock', + dht: 'in_memory_pointer_records', + payments: 'dry_run_only', + }, + limitations: ['local mock demo'], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const demo = await client.demo.run() + expect(demo.status).toBe('executed') + expect(demo.verification.evidenceHashChainValid).toBe(true) + expect(demo.authority.discoveryGrantsAuthority).toBe(false) + expect(demo.authority.policyBeforeExecution).toBe(true) + + const simulation = await client.simulate.adversarial() + expect(simulation.status).toBe('detected') + expect(simulation.scenarios.every(scenario => scenario.detected)).toBe(true) + expect(simulation.evidence.brokenEvidenceChainValid).toBe(false) + expect(simulation.authority.discoveryGrantsAuthority).toBe(false) + }) + it('creates and submits signed invocation requests from a session grant', async () => { const requester = await createAgentIdentity() const sessionGrant: SessionGrantV2 = { From 49542c80ba60d92fc25f0bb5c432c7053554387e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 17:59:28 +0300 Subject: [PATCH 194/282] docs: refresh getting started for fides v2 --- docs/getting-started.md | 653 +++++++++++++--------------------------- 1 file changed, 216 insertions(+), 437 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index b668523..a8353e7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,578 +1,357 @@ -# Getting Started with FIDES +# Getting Started with FIDES v2 -This guide will walk you through setting up FIDES, creating your first agent identity, and performing basic trust operations. +FIDES v2 is a local-first Agent Trust Fabric. It resolves capabilities to +verified agent candidates, then evaluates identity, trust, policy, delegation, +runtime attestation, revocation, incidents, and evidence before any invocation +authority is granted. -## Prerequisites +Discovery is not authority. Identity is not trust. Trust is not permission. +Policy and scoped session grants are the authority path. -Before you begin, ensure you have the following installed: +## Prerequisites -- **Node.js 22+** -- **pnpm** (package manager): `npm install -g pnpm` -- **Docker** (for PostgreSQL): [Install Docker](https://docs.docker.com/get-docker/) -- **Git** (for cloning the repository) +- Node.js 22+ +- pnpm +- Git -**System Requirements:** -- Operating System: macOS, Linux, or Windows with WSL2 -- RAM: 4GB minimum, 8GB recommended -- Disk Space: 2GB for dependencies and databases +Optional: -## Installation +- Docker/PostgreSQL if you want to exercise legacy backing services or external + authority-store modes. -### 1. Clone the Repository +## Install And Build ```bash git clone https://github.com/EfeDurmaz16/fides.git cd fides -``` - -### 2. Install Dependencies - -```bash pnpm install +pnpm build ``` -This will install all dependencies for the monorepo, including: -- `@fides/sdk` (protocol implementation) -- `@fides/cli` (command-line interface) -- Discovery service dependencies -- Trust graph service dependencies - -### 3. Start PostgreSQL - -Start the PostgreSQL database using Docker Compose: - -```bash -docker compose up -d -``` - -This will start: -- PostgreSQL 16 on port 5432 -- Database name: `fides` -- Username: `fides` -- Password: `fides` +The examples below assume the `agentd` binary is on your `PATH`. From a fresh +checkout, you can run the same commands through the workspace package: -**Verify PostgreSQL is running:** ```bash -docker ps +pnpm --filter @fides/cli agentd -- ``` -You should see a container named `fides-postgres-1` in the running state. +## Start The Local Daemon -### 4. Build All Packages +The root v2 API is served by `agentd` on `http://localhost:7345`. ```bash -pnpm build +pnpm --filter @fides/agentd dev ``` -This compiles TypeScript to JavaScript for all packages and services. - -### 5. Start Development Servers - -Start all services in development mode: - -```bash -pnpm dev -``` +In another shell: -This starts: -- **Discovery Service** on `http://localhost:3100` -- **Trust Graph Service** on `http://localhost:3200` -- **Policy Engine Service** on `http://localhost:3300` -- **Registry Service** on `http://localhost:7346` -- **Relay Service** on `http://localhost:7347` -- **Agent Daemon** on `http://localhost:7345` -- **Platform API** on `http://localhost:3600` - -The local Agent Daemon stores root v2 prototype state in -`~/.fides/fides.sqlite` by default outside tests. Set -`AGENTD_SQLITE_PATH=/path/to/fides.sqlite` to use a different file, or -`AGENTD_LOCAL_STATE=memory` for an ephemeral daemon run. - -**Verify services are running:** ```bash -curl http://localhost:3100/.well-known/fides.json -curl http://localhost:3200/health -curl http://localhost:3300/health -curl http://localhost:7346/health -curl http://localhost:7347/health curl http://localhost:7345/health -curl http://localhost:3600/health ``` -## Quick Start Tutorial +Local daemon state is stored in `~/.fides/fides.sqlite` by default. Use +`AGENTD_SQLITE_PATH=/path/to/fides.sqlite` for a specific database file or +`AGENTD_LOCAL_STATE=memory` for an ephemeral run. -### 1. Create Your First Identity +## Create Local Identities -Create a new agent identity using the CLI: +Create a principal, publisher, requester agent, and target agent. ```bash -fides init --name "My First Agent" -``` - -**Interactive prompts:** -- Enter a password to encrypt your private key -- Confirm password - -**Output:** -``` -✓ Generated Ed25519 keypair -✓ Created DID: did:fides:5XqKCvJHVqQ8pBbNvCFz8JpqhP6ZcJW3M7FqHGxWJQYd -✓ Encrypted and stored private key -✓ Registered identity with discovery service - -Identity created successfully! -DID: did:fides:5XqKCvJHVqQ8pBbNvCFz8JpqhP6ZcJW3M7FqHGxWJQYd +agentd identity create --type principal --name "Demo Principal" --agentd-url http://localhost:7345 +agentd identity create --type publisher --name "Demo Publisher" --agentd-url http://localhost:7345 +agentd identity create --type agent --name "Requester Agent" --agentd-url http://localhost:7345 +agentd identity create --type agent --name "Invoice Agent" --agentd-url http://localhost:7345 ``` -**Where are my keys stored?** -- Location: `~/.fides/keys/.json` -- Format: AES-256-GCM encrypted private key -- **Important:** Keep your password safe! Lost password = lost identity (no recovery in MVP) - -### 2. Check Your Identity Status - -View your local identity: +List identities: ```bash -fides status +agentd identity list --agentd-url http://localhost:7345 ``` -**Output:** -``` -Agent Status -──────────── -DID: did:fides:5XqKCvJHVqQ8pBbNvCFz8JpqhP6ZcJW3M7FqHGxWJQYd -Name: My First Agent -Created: 2026-02-08T12:00:00Z -Key File: ~/.fides/keys/did:fides:5XqKCvJHVqQ8pBbNvCFz8JpqhP6ZcJW3M7FqHGxWJQYd.json -``` +Private keys are not returned by the daemon API. +Replace the placeholder DIDs below with the values returned by these commands. -### 3. Sign an HTTP Request +## Add Trust Anchors -Sign an HTTP request to authenticate as your agent: +Domains are optional. A publisher can use domainless anchors such as GitHub, +email, package registry, wallet, passkey, organization invitation, runtime +attestation, build attestation, or peer attestation. ```bash -fides sign https://api.example.com/data --method GET +agentd attest github --identity did:fides:publisher --handle fides-dev --agentd-url http://localhost:7345 +agentd attest email --identity did:fides:publisher --email dev@example.com --agentd-url http://localhost:7345 +agentd attest package --identity did:fides:publisher --registry npm --package @fides/example-agent --agentd-url http://localhost:7345 ``` -**Interactive prompt:** -- Enter your password to unlock private key - -**Output:** -``` -Signed Request -────────────── -Method: GET -URL: https://api.example.com/data +Attestations add evidence and trust signals. They do not grant invocation +authority. -Headers: - Signature-Input: sig1=("@method" "@target-uri" "@authority" "content-type");created=1707350400;expires=1707350700;keyid="did:fides:5XqKCvJHVqQ8pBbNvCFz8JpqhP6ZcJW3M7FqHGxWJQYd";alg="ed25519" - Signature: sig1=:K2qGKOhSeP48aJPVBzE3k0V/9t0GCuXsXWqNrBUzpwQeZdq0SnN0Kt7UKQR6e9+flKABcTJL32FWnvBx/2DhBw==: +## Create And Sign An AgentCard -Copy these headers to your HTTP client or use them in your application. -``` +Create an AgentCard for a capability. -**Usage in curl:** ```bash -curl -H "Signature-Input: sig1=..." \ - -H "Signature: sig1=:...:" \ - https://api.example.com/data +agentd card create \ + --did did:fides:invoice-agent \ + --name "Invoice Agent" \ + --capabilities '[{"id":"invoice.reconcile","riskLevel":"medium","requiredScopes":["invoice:read"],"supportedControls":["dry_run","policy_proof"],"supportsDryRun":true,"supportsPolicyProof":true}]' \ + --agentd-url http://localhost:7345 ``` -### 4. Verify a Signed Request - -Verify a request you received from another agent: +Sign and verify the card: ```bash -fides verify \ - --method GET \ - --url https://api.example.com/data \ - --signature-input 'sig1=("@method" "@target-uri" "@authority");created=1707350400;expires=1707350700;keyid="did:fides:ABC...";alg="ed25519"' \ - --signature 'sig1=:K2qGKOhSeP48aJP...==' -``` - -**Output:** +agentd card sign did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd card verify did:fides:invoice-agent --agentd-url http://localhost:7345 ``` -✓ Signature verified successfully -✓ Issuer: did:fides:ABC... -✓ Timestamp valid (not expired) -Request is authentic. -``` +All signed protocol objects use the shared canonical signing model. -### 5. Trust Another Agent +## Register For Discovery -Issue a trust attestation for another agent: +Register the signed AgentCard as a local discovery candidate. ```bash -fides trust did:fides:7nK9fV3hP8xRqW2mTgJvCz4YpLnH5QbM3kD6sF2gR9Xa --level high +agentd register did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd agents list --agentd-url http://localhost:7345 ``` -**Trust Levels:** -- `low`: 25 -- `medium`: 50 -- `high`: 75 -- `max`: 100 -- Or specify a number: `--level 85` +Registration only makes the agent discoverable. It does not grant authority. -**Interactive prompt:** -- Enter your password to sign the attestation +## Discover By Capability -**Output:** -``` -✓ Created trust attestation -✓ Signed with your private key -✓ Submitted to trust graph service - -Trust attestation created! -Issuer: did:fides:5XqKCvJHVqQ8pBbNvCFz8JpqhP6ZcJW3M7FqHGxWJQYd -Subject: did:fides:7nK9fV3hP8xRqW2mTgJvCz4YpLnH5QbM3kD6sF2gR9Xa -Level: 75 -``` - -### 6. Discover an Agent and Check Reputation - -Look up another agent's identity and reputation: +Local discovery: ```bash -fides discover did:fides:7nK9fV3hP8xRqW2mTgJvCz4YpLnH5QbM3kD6sF2gR9Xa -``` - -**Output:** -``` -Agent Information -───────────────── -DID: did:fides:7nK9fV3hP8xRqW2mTgJvCz4YpLnH5QbM3kD6sF2gR9Xa -Name: Another Agent -Description: Demo agent for testing -Public Key: - -Reputation Score -──────────────── -Overall: 68.5 -Direct: 75.0 (1 attestation) -Transitive: 60.0 (3 paths) -Paths: 4 total - -Trust Paths: - 1. You → Another Agent (direct, trust: 75.0) - 2. You → Alice → Another Agent (2 hops, trust: 51.0) - 3. You → Bob → Charlie → Another Agent (3 hops, trust: 34.2) +agentd discover --capability invoice.reconcile --provider local --agentd-url http://localhost:7345 ``` -## Using the SDK - -For programmatic access, use the `@fides/sdk` package in your TypeScript/JavaScript applications. - -### Installation +Registry, relay, DHT, and federation-ready discovery: ```bash -npm install @fides/sdk -# or -pnpm add @fides/sdk -``` +agentd registry start --agentd-url http://localhost:7345 +agentd registry publish did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd registry search --capability invoice.reconcile --supported-versions fides.v2.0 --agentd-url http://localhost:7345 -### Basic Example - -```typescript -import { Fides } from '@fides/sdk' +agentd relay start --agentd-url http://localhost:7345 +agentd relay register did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd relay discover --capability invoice.reconcile --supported-versions fides.v2.0 --agentd-url http://localhost:7345 -// Initialize FIDES client -const fides = new Fides({ - discoveryUrl: 'http://localhost:3100', - trustUrl: 'http://localhost:3200', -}) +agentd dht start --agentd-url http://localhost:7345 +agentd dht publish --capability invoice.reconcile --agent-id did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd dht find --capability invoice.reconcile --agentd-url http://localhost:7345 +``` -// Create a new identity -const { did, publicKey } = await fides.createIdentity({ - name: 'My SDK Agent', - password: 'secure-password-123', -}) -console.log(`Identity created: ${did}`) - -// Sign an HTTP request -const signedRequest = await fides.signRequest({ - method: 'GET', - url: 'https://api.example.com/data', - headers: { - 'Content-Type': 'application/json', - }, - password: 'secure-password-123', -}) +DHT records are signed pointers only. They are not trust sources. -console.log('Signed headers:', signedRequest.headers) +## Evaluate Trust And Reputation -// Issue a trust attestation -await fides.trust({ - subjectDid: 'did:fides:7nK9fV3hP8xRqW2mTgJvCz4YpLnH5QbM3kD6sF2gR9Xa', - trustLevel: 75, - password: 'secure-password-123', -}) +Trust is capability-specific. -// Get reputation score -const reputation = await fides.getReputation( - 'did:fides:7nK9fV3hP8xRqW2mTgJvCz4YpLnH5QbM3kD6sF2gR9Xa' -) -console.log(`Reputation score: ${reputation.score}`) -console.log(`Trust paths: ${reputation.pathCount}`) +```bash +agentd trust did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 ``` -### Verifying Requests +Reputation is also capability-specific and can be principal/publisher-aware. -```typescript -import { Fides } from '@fides/sdk' - -const fides = new Fides({ - discoveryUrl: 'http://localhost:3100', - trustUrl: 'http://localhost:3200', -}) - -// Verify a signed request -const isValid = await fides.verifyRequest({ - method: 'POST', - url: 'https://api.example.com/data', - headers: { - 'Signature-Input': 'sig1=...', - 'Signature': 'sig1=:...=', - }, -}) +```bash +agentd reputation update \ + --agent did:fides:invoice-agent \ + --capability invoice.reconcile \ + --successful-invocations 8 \ + --failed-invocations 1 \ + --agentd-url http://localhost:7345 -if (isValid) { - console.log('Request is authentic!') -} else { - console.log('Invalid signature or expired request') -} +agentd reputation get did:fides:invoice-agent --agentd-url http://localhost:7345 ``` -### Discovering Identities +Trust and reputation are signals. Policy is the authority. -```typescript -import { Fides } from '@fides/sdk' - -const fides = new Fides({ - discoveryUrl: 'http://localhost:3100', - trustUrl: 'http://localhost:3200', -}) +## Evaluate Policy -// Resolve a DID to identity information -const identity = await fides.discover('did:fides:7nK9fV3h...') -console.log(`Name: ${identity.metadata.name}`) -console.log(`Public Key: ${identity.publicKey}`) -console.log(`Created: ${identity.createdAt}`) +```bash +agentd policy evaluate \ + --agent did:fides:invoice-agent \ + --capability invoice.reconcile \ + --requested-scopes invoice:read \ + --agentd-url http://localhost:7345 ``` -### Building Trust Relationships +Policy decisions include machine-readable reasons, human-readable reasons, +required controls, and evidence refs. They are never just booleans. -```typescript -import { Fides } from '@fides/sdk' +## Request A Session Grant -const fides = new Fides({ - discoveryUrl: 'http://localhost:3100', - trustUrl: 'http://localhost:3200', -}) +```bash +agentd session request did:fides:invoice-agent \ + --capability invoice.reconcile \ + --requested-scopes invoice:read \ + --agentd-url http://localhost:7345 -// Trust multiple agents -const agentsToTrust = [ - { did: 'did:fides:Alice...', level: 80 }, - { did: 'did:fides:Bob...', level: 65 }, - { did: 'did:fides:Charlie...', level: 90 }, -] - -for (const agent of agentsToTrust) { - await fides.trust({ - subjectDid: agent.did, - trustLevel: agent.level, - password: 'secure-password-123', - }) - console.log(`Trusted ${agent.did} at level ${agent.level}`) -} - -// Compute reputation for all trusted agents -for (const agent of agentsToTrust) { - const rep = await fides.getReputation(agent.did) - console.log(`${agent.did}: ${rep.score} (${rep.pathCount} paths)`) -} +agentd session verify sess_... --agentd-url http://localhost:7345 ``` -## Advanced Usage - -### Custom Discovery Service +A `SessionGrant` is scoped by requester, target, principal, capability, scopes, +constraints, audience, expiry, nonce, policy hash, and trust-result hash. -Run your own discovery service on a different port: +## Invoke A Capability ```bash -cd services/discovery -PORT=4000 pnpm dev +agentd invoke --session-id sess_... --input invoice.json --agentd-url http://localhost:7345 ``` -Update your CLI or SDK configuration: - -```typescript -const fides = new Fides({ - discoveryUrl: 'http://localhost:4000', - trustUrl: 'http://localhost:3200', -}) -``` +For high-risk actions, request dry-run or approval-gated behavior: -### Self-Hosted Identity (.well-known) - -Host your identity document at `https://yourdomain.com/.well-known/fides.json`: - -```json -{ - "did": "did:fides:5XqKCvJHVqQ8pBbNvCFz8JpqhP6ZcJW3M7FqHGxWJQYd", - "publicKey": "base64-encoded-public-key", - "metadata": { - "name": "My Self-Hosted Agent", - "description": "Autonomous agent with self-sovereign identity", - "endpoints": { - "api": "https://yourdomain.com/api", - "trust": "https://yourdomain.com/trust" - } - } -} +```bash +agentd invoke --dry-run did:fides:payment-agent \ + --capability payments.prepare \ + --input payment.json \ + --requested-scopes payments:prepare \ + --agentd-url http://localhost:7345 ``` -FIDES will resolve your DID from `.well-known` before falling back to the central discovery service. +Generic FIDES keeps payment execution dry-run only. Payment-specific execution +belongs in Sardis. -### Domain Verification +## Runtime Attestation -Bind a domain to a FIDES DID with a DNS TXT record: +High-risk capabilities can require runtime attestation or approval. ```bash -fides identity domain challenge yourdomain.com did:fides:5XqKCvJHVqQ8pBbNvCFz8JpqhP6ZcJW3M7FqHGxWJQYd -``` - -Publish the returned TXT record, then verify it: +agentd attest runtime \ + --agent did:fides:payment-agent \ + --code-hash sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --runtime-hash sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ + --policy-hash sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc \ + --agentd-url http://localhost:7345 -```bash -fides identity domain verify yourdomain.com did:fides:5XqKCvJHVqQ8pBbNvCFz8JpqhP6ZcJW3M7FqHGxWJQYd +agentd attest verify att_... --agentd-url http://localhost:7345 ``` -The CLI checks `_fides.yourdomain.com` for an exact `fides-did=` TXT token. +MockTEE is local and adapter-ready. Production TEE providers are future +adapters. -### Running Tests +## Evidence -Run the test suite to verify your installation: +Evidence defaults to hash-only or redacted handling for sensitive inputs and +outputs. ```bash -pnpm test +agentd evidence verify --agentd-url http://localhost:7345 +agentd evidence export --privacy-mode hash_only --no-metadata --agentd-url http://localhost:7345 ``` -**Test coverage:** -- Identity generation and DID creation -- Ed25519 signing and verification -- RFC 9421 HTTP message signatures -- Trust attestation creation and verification -- BFS trust graph traversal -- Reputation scoring +Evidence is append-only and hash-chained. -### Database Management +## Revocations, Incidents, And Kill Switches -**View database contents:** ```bash -docker exec -it fides-postgres-1 psql -U fides -d fides +agentd revoke agent did:fides:invoice-agent --reason "compromised key" --agentd-url http://localhost:7345 +agentd incident report did:fides:invoice-agent --severity high --category unauthorized_action --description "policy bypass" --agentd-url http://localhost:7345 +agentd killswitch enable --capability payments.prepare --reason "incident response" --agentd-url http://localhost:7345 ``` -**Useful SQL queries:** -```sql --- List all identities -SELECT did, metadata->>'name' as name, created_at FROM identities; +Active revocations and kill switches override normal trust and policy +evaluation. --- List all trust attestations -SELECT issuer_did, subject_did, trust_level, issued_at FROM trust_attestations; +## Run The Full Demo --- Count attestations per agent -SELECT subject_did, COUNT(*) as attestation_count -FROM trust_attestations -GROUP BY subject_did -ORDER BY attestation_count DESC; +```bash +agentd demo run --agentd-url http://localhost:7345 ``` -**Reset database:** +The demo creates identities, signs AgentCards, registers agents, publishes +registry/relay/DHT records, evaluates trust and policy, creates sessions, +invokes dry-run and allowed capabilities, emits evidence, reports an incident, +records a revocation, and verifies the evidence hash chain. + +## Run The Adversarial Simulation + ```bash -docker compose down -v -docker compose up -d -pnpm build -pnpm dev +agentd simulate adversarial --agentd-url http://localhost:7345 ``` -## Troubleshooting +The simulation covers fake agents, fake publishers, malicious DHT pointers, +tampered AgentCards, expired runtime attestations, revoked agents, collusive +trust attestations, context laundering, high-risk capability abuse, and broken +evidence chains. -### PostgreSQL Connection Issues +## TypeScript SDK -**Error:** `ECONNREFUSED` or `Connection refused` +Use `FidesClient` for the Promise-based v2 SDK surface. -**Solution:** -1. Check Docker is running: `docker ps` -2. Restart PostgreSQL: `docker compose restart` -3. Check database URL in `.env` files +```typescript +import { FidesClient } from '@fides/sdk' + +const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + +const identity = await client.identity.createAgent({ name: 'Invoice Agent' }) + +const card = await client.cards.create({ + agentId: identity.identity.did, + name: 'Invoice Agent', + capabilities: [ + { + id: 'invoice.reconcile', + riskLevel: 'medium', + requiredScopes: ['invoice:read'], + supportedControls: ['dry_run', 'policy_proof'], + supportsDryRun: true, + supportsPolicyProof: true, + }, + ], +}) -### CLI Not Found +await client.cards.sign({ id: card.card.id }) +await client.agents.register({ agentCardId: card.card.id }) -**Error:** `fides: command not found` +const discovered = await client.discovery.local({ capability: 'invoice.reconcile' }) +console.log(discovered.authorityGranted) // false -**Solution:** -1. Build packages: `pnpm build` -2. Link CLI globally: `cd packages/cli && pnpm link --global` -3. Or use via pnpm: `pnpm -C packages/cli start init --name "Agent"` +const trust = await client.trust.evaluate({ + agentId: identity.identity.did, + capability: 'invoice.reconcile', +}) -### Signature Verification Fails +const policy = await client.policy.evaluate({ + agentId: identity.identity.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], +}) -**Error:** `Invalid signature` +const session = await client.sessions.request({ + agentId: identity.identity.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], +}) -**Possible causes:** -1. **Clock drift:** Ensure system clocks are synchronized (use NTP) -2. **Expired signature:** Signatures valid for 300 seconds only -3. **Wrong keyid:** Verify DID matches the signing agent -4. **Tampered request:** Any modification invalidates signature +const result = await client.invoke({ + sessionId: session.session.session_id, + input: { invoiceId: 'inv_123' }, +}) -**Debug:** -```bash -fides verify --debug ... +console.log({ trust: trust.trust.band, policy: policy.policy.decision, result }) ``` -### Trust Graph Empty +## Verification Commands -**Error:** `No trust paths found` +Useful checks while developing: -**Explanation:** No trust attestations exist yet. You need to create trust relationships first. - -**Solution:** ```bash -fides trust did:fides:targetAgent --level high +pnpm --filter @fides/core test +pnpm --filter @fides/sdk test +pnpm --filter @fides/sdk build +pnpm --filter @fides/sdk lint +pnpm --filter @fides/agentd test ``` ## Next Steps -Now that you have FIDES running, explore: - -1. **Architecture**: Read [docs/architecture.md](./architecture.md) to understand system design -2. **Protocol Spec**: Review [docs/protocol/fides-v2-spec.md](./protocol/fides-v2-spec.md) for detailed protocol documentation -3. **Build an Agent**: Create an autonomous agent using the SDK -4. **Trust Network**: Build a network of trusted agents -5. **Integrate Services**: Use HTTP message signatures to authenticate service requests - -## Getting Help - -- **Issues**: Open an issue on GitHub -- **Discussions**: Join the community discussions -- **Documentation**: See `docs/` directory for detailed guides -- **Examples**: Check `examples/` directory for sample code - -## Security Best Practices - -1. **Protect Your Password**: Your private key is encrypted with your password. Lost password = lost identity. -2. **Use Strong Passwords**: Minimum 12 characters, mix of letters/numbers/symbols -3. **Backup Keys**: Copy `~/.fides/keys/` to secure backup location -4. **HTTPS Only**: Always use HTTPS for production services -5. **Verify Signatures**: Always verify request signatures before processing -6. **Monitor Trust**: Regularly review incoming trust attestations -7. **Keep Software Updated**: Update FIDES packages regularly for security patches - -## What's Next? - -- Explore the [Architecture documentation](./architecture.md) -- Dive into the [Protocol Specification](./protocol/fides-v2-spec.md) -- Build your first autonomous agent application -- Join the FIDES community and contribute! +- Read `docs/architecture/fides-v2-agent-trust-fabric.md`. +- Read `docs/protocol/canonical-object-signing.md`. +- Read `docs/protocol/discovery.md`. +- Read `docs/protocol/evidence-ledger.md`. +- Read `docs/cli-reference.md`. +- Read `docs/sdk-reference.md`. From 1208ccddc2ba5a98bb8270d9b5fd747182a42293 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:00:58 +0300 Subject: [PATCH 195/282] docs(sdk): lead with fides client quickstart --- packages/sdk/README.md | 118 ++++++++++++++++++++++------------------- 1 file changed, 64 insertions(+), 54 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 4280181..697d0e2 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1,43 +1,81 @@ # @fides/sdk -Decentralized trust and authentication protocol for autonomous AI agents. +Promise-based TypeScript SDK for the FIDES v2 Agent Trust Fabric. + +FIDES v2 resolves capabilities to verified agent candidates, then runs trust, +policy, delegation, session, invocation, and evidence workflows through the +local `agentd` authority path. Discovery is candidate discovery only; it never +grants invocation authority by itself. ## Installation ```bash npm install @fides/sdk +# or +pnpm add @fides/sdk ``` ## Quick Start ```typescript -import { Fides, TrustLevel } from '@fides/sdk' +import { FidesClient } from '@fides/sdk' -const fides = new Fides({ - discoveryUrl: 'http://localhost:3100', - trustUrl: 'http://localhost:3200', - apiKey: process.env.FIDES_API_KEY, +const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + +const principal = await client.identity.createPrincipal({ name: 'Demo Principal' }) +const requester = await client.identity.createAgent({ name: 'Requester Agent' }) +const target = await client.identity.createAgent({ name: 'Invoice Agent' }) + +const card = await client.cards.create({ + agentId: target.did, + name: 'Invoice Agent', + capabilities: [ + { + id: 'invoice.reconcile', + riskLevel: 'medium', + requiredScopes: ['invoice:read'], + supportedControls: ['dry_run', 'policy_proof'], + supportsDryRun: true, + supportsPolicyProof: true, + }, + ], }) -// Create identity -const { did } = await fides.createIdentity({ name: 'My Agent' }) +await client.cards.sign({ id: card.card.id }) +await client.agents.register({ agentCardId: card.card.id }) -// Sign HTTP requests (with automatic Content-Digest for body integrity) -const signed = await fides.signRequest({ - method: 'POST', - url: 'https://example.com/api', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ data: 'hello' }), +const discovery = await client.discovery.local({ capability: 'invoice.reconcile' }) +console.log(discovery.authorityGranted) // false + +const trust = await client.trust.evaluate({ + agentId: target.did, + capability: 'invoice.reconcile', }) -// Verify requests -const result = await fides.verifyRequest(incomingRequest) +const policy = await client.policy.evaluate({ + principalId: principal.did, + requesterAgentId: requester.did, + agentId: target.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], +}) + +const session = await client.sessions.request({ + principalId: principal.did, + requesterAgentId: requester.did, + agentId: target.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], +}) + +const result = await client.invoke({ + sessionId: session.session.session_id, + input: { invoiceId: 'inv_123' }, +}) -// Trust attestations -await fides.trust('did:fides:...', TrustLevel.HIGH) +await client.evidence.verify() -// Reputation scores -const score = await fides.getReputation('did:fides:...') +console.log({ trust: trust.trust.band, policy: policy.policy.decision, result }) ``` ## agentd Client @@ -267,41 +305,13 @@ const pending = await agentd.listPendingPropagations(25) const retry = await agentd.retryPropagations(25) ``` -## Discovery Clients - -```typescript -import { AgentDiscoveryClient, DiscoveryClient } from '@fides/sdk' - -const identities = new DiscoveryClient({ - baseUrl: 'http://localhost:3100', - apiKey: process.env.FIDES_API_KEY, -}) +## Legacy Discovery Clients -await identities.register({ - did: 'did:fides:agent', - name: 'Payment Agent', - publicKey: '00'.repeat(32), -}) - -await identities.verifyDomain('did:fides:agent', 'agent.example.com') - -const agents = new AgentDiscoveryClient({ - baseUrl: 'http://localhost:3100', - apiKey: process.env.FIDES_API_KEY, -}) - -await agents.registerAgent({ - did: 'did:fides:agent', - name: 'Payment Agent', - description: 'Executes approved payment workflows', - skills: [{ id: 'payments.prepare', name: 'Prepare payment dry-runs' }], -}) - -// URL-less registration is supported. The discovery service stores a -// local://agents/ transport hint and returns authorityGranted: false. - -await agents.heartbeat('did:fides:agent') -``` +`DiscoveryClient` and `AgentDiscoveryClient` remain exported for compatibility +with the older standalone discovery service. New FIDES v2 code should prefer +`FidesClient.discovery.*` through local `agentd`, because that path preserves +AgentCard verification, protocol negotiation, trust/policy explainability, +candidate-only discovery, and evidence recording. ## Registry Client From f99721d16995a732d8229be820d8c362f44be73e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:02:09 +0300 Subject: [PATCH 196/282] docs(cli): document agentd authority workflow --- packages/cli/README.md | 48 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 21ccc67..1dc355d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,8 +1,15 @@ # @fides/cli -Command-line tools for the FIDES trust protocol. +Command-line tools for the FIDES v2 Agent Trust Fabric. -The CLI wraps identity initialization, request signing and verification, discovery, AgentCard publishing, agentd authority operations, relay operations, and local daemon lifecycle commands. +The CLI exposes local-first `agentd` workflows for identity, attestations, +AgentCards, discovery, trust, reputation, policy, approvals, kill switches, +delegation, sessions, invocation, evidence, revocation, incidents, registry, +relay, DHT, demos, and adversarial simulation. + +Discovery commands return candidate records only. They do not grant invocation +authority. Capability execution must go through policy evaluation and a scoped +`SessionGrant`. ## Installation @@ -10,21 +17,44 @@ The CLI wraps identity initialization, request signing and verification, discove npm install -g @fides/cli ``` +From the monorepo checkout: + +```bash +pnpm --filter @fides/cli agentd -- +``` + ## Usage ```bash -fides init --name payment-agent -fides identity create --type agent --name invoice-agent -fides identity list -fides identity show did:fides:... -fides sign https://api.example.com/data --method GET -fides card publish agent-card.json --registry-url http://localhost:7346 +agentd identity create --type principal --name "Demo Principal" --agentd-url http://localhost:7345 +agentd identity create --type publisher --name "Demo Publisher" --agentd-url http://localhost:7345 +agentd identity create --type agent --name "Invoice Agent" --agentd-url http://localhost:7345 +agentd identity list --agentd-url http://localhost:7345 + +agentd attest github --identity did:fides:publisher --handle fides-dev --agentd-url http://localhost:7345 + +agentd card create --did did:fides:invoice-agent --name "Invoice Agent" --capabilities '[{"id":"invoice.reconcile","riskLevel":"medium","requiredScopes":["invoice:read"]}]' --agentd-url http://localhost:7345 +agentd card sign did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd register did:fides:invoice-agent --agentd-url http://localhost:7345 + +agentd discover --capability invoice.reconcile --provider local --agentd-url http://localhost:7345 +agentd dht publish --capability invoice.reconcile --agent-id did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd dht find --capability invoice.reconcile --agentd-url http://localhost:7345 + +agentd trust did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 +agentd policy evaluate --agent did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 +agentd session request did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 +agentd invoke --session-id sess_... --input invoice.json --agentd-url http://localhost:7345 + +agentd evidence verify --agentd-url http://localhost:7345 agentd demo run --agentd-url http://localhost:7345 agentd simulate adversarial --agentd-url http://localhost:7345 agentd daemon status --agentd-url http://localhost:7345 ``` -Use `fides --help`, `agentd --help`, and command-specific `--help` output for the full command surface. Both binaries point to the same CLI; `agentd` is the preferred name for local authority, daemon, demo, and simulation workflows. +Use `fides --help`, `agentd --help`, and command-specific `--help` output for +the full command surface. Both binaries point to the same CLI; `agentd` is the +preferred name for local authority, daemon, demo, and simulation workflows. ## License From f956dc28760903c08d5a60e31b1d5439eb3ee7af Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:03:19 +0300 Subject: [PATCH 197/282] docs: align top-level architecture with fides v2 --- docs/architecture.md | 383 +++++++++++++------------------------------ 1 file changed, 118 insertions(+), 265 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2c06433..1b9073d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,267 +1,120 @@ # FIDES Architecture -## System Overview - -FIDES is a decentralized trust and authentication protocol for autonomous AI agents. It provides cryptographic identity management, HTTP message signing, distributed trust attestations, and reputation scoring to enable secure agent-to-agent interactions. - -The system consists of: -- TypeScript SDK with Ed25519 identity and RFC 9421 HTTP message signatures -- Command-line interface for agent operations -- Discovery service for identity registration and resolution -- Trust graph service for managing trust relationships and computing reputation scores - -## Architecture Diagram - -``` -┌─────────────┐ ┌─────────────────┐ ┌──────────────────┐ -│ @fides/cli │────▶│ @fides/sdk │────▶│ @fides/shared │ -└─────────────┘ └────────┬────────┘ └──────────────────┘ - │ - ┌────────┴────────┐ - ▼ ▼ - ┌─────────────┐ ┌──────────────┐ - │ Discovery │ │ Trust Graph │ - │ Service │ │ Service │ - └──────┬───────┘ └──────┬───────┘ - │ │ - └───────┬───────────┘ - ▼ - ┌──────────┐ - │PostgreSQL│ - └──────────┘ -``` - -## Components - -### @fides/shared -Shared types, error classes, and constants used across all packages. - -**Key exports:** -- `Identity` type: DID, public key, metadata -- `TrustAttestation` type: Trust statement schema -- `FidesError` hierarchy: Base error classes -- Constants: Default trust levels, signature parameters - -**Dependencies:** None (pure types and constants) - -### @fides/sdk -Core library providing the complete FIDES protocol implementation. - -**Key modules:** -- **Identity**: Ed25519 keypair generation, DID creation (`did:fides:`), secure key storage with AES-256-GCM encryption -- **Signing**: RFC 9421 HTTP Message Signatures with ed25519 algorithm -- **Discovery Client**: Identity registration and resolution against discovery service -- **Trust Client**: Trust attestation creation, verification, and retrieval -- **Fides Class**: High-level API combining all capabilities - -**Dependencies:** -- `@noble/ed25519` (cryptography) -- `@fides/shared` (types) - -### @fides/cli -Command-line interface for FIDES operations. - -**Commands:** -- `fides init`: Create new agent identity -- `fides sign`: Sign HTTP requests -- `fides verify`: Verify signed requests -- `fides trust`: Issue trust attestations -- `fides discover`: Resolve identities and check reputation -- `fides status`: Show local agent status - -**Dependencies:** -- `commander` (CLI framework) -- `chalk` (terminal colors) -- `ora` (spinners) -- `@fides/sdk` (protocol implementation) - -### Discovery Service -Identity registration and resolution service using Hono and PostgreSQL. - -**Endpoints:** -- `POST /identities`: Register new identity -- `GET /identities/:did`: Resolve identity -- `GET /.well-known/fides.json`: Well-known identity document - -**Database schema:** -- `identities` table: did (PK), public_key, metadata, created_at, updated_at - -**Features:** -- DID-based identity lookup -- Metadata storage (name, description, endpoints) -- `.well-known` protocol support for decentralized resolution - -**Dependencies:** Hono, Drizzle ORM, PostgreSQL - -### Trust Graph Service -Trust relationship management and reputation scoring service. - -**Endpoints:** -- `POST /trust`: Create trust attestation -- `GET /trust/:did`: Get trust attestations for a DID -- `POST /trust/verify`: Verify trust attestation signature -- `GET /reputation/:did`: Compute reputation score - -**Database schema:** -- `trust_attestations` table: id (PK), issuer_did, subject_did, trust_level, issued_at, expires_at, signature, payload - -**Features:** -- BFS graph traversal for trust path finding -- Exponential decay: 0.85 per hop, max depth 6 -- Reputation aggregation from direct and transitive trust -- Trust attestation verification - -**Dependencies:** Hono, Drizzle ORM, PostgreSQL - -## Data Flow - -### Identity Creation -1. Agent runs `fides init --name "My Agent"` -2. SDK generates Ed25519 keypair -3. SDK creates DID from public key: `did:fides:` -4. SDK encrypts private key with AES-256-GCM (user password) -5. SDK stores encrypted key locally -6. SDK registers identity with discovery service -7. Discovery service stores DID + metadata in PostgreSQL - -### Signed HTTP Request -1. Agent prepares HTTP request (method, URL, headers, body) -2. SDK creates signature base string per RFC 9421 -3. SDK signs with Ed25519 private key -4. SDK adds `Signature-Input` and `Signature` headers -5. Recipient receives request -6. Recipient extracts DID from `keyid` parameter -7. Recipient resolves DID via discovery service -8. Recipient verifies signature with public key -9. Recipient checks timestamp replay protection - -### Trust Attestation -1. Issuer agent trusts subject agent -2. SDK creates trust attestation payload (issuer DID, subject DID, trust level, timestamps) -3. SDK signs payload with issuer's private key -4. SDK sends attestation to trust graph service -5. Trust graph service verifies signature -6. Trust graph service stores attestation in PostgreSQL -7. Subject can query attestations for their DID - -### Reputation Scoring -1. Agent queries reputation for target DID -2. Trust graph service performs BFS from query DID -3. For each hop: trust_score *= 0.85 (exponential decay) -4. Maximum depth: 6 hops -5. Aggregate direct trust (depth 1) and transitive trust (depth 2-6) -6. Return reputation score + trust paths - -## Key Design Decisions - -### TypeScript-Only for MVP -- **Decision**: Use TypeScript for all components (no Go, no Rust) -- **Rationale**: Faster development, unified tooling, strong ecosystem -- **Trade-offs**: Performance vs. velocity (optimize later if needed) - -### Ed25519 via @noble/ed25519 -- **Decision**: Use @noble/ed25519 library for cryptography -- **Rationale**: Audited, pure JavaScript, no native dependencies -- **Trade-offs**: Slightly slower than native code, but secure and portable - -### RFC 9421 HTTP Message Signatures -- **Decision**: Implement RFC 9421 for request authentication -- **Rationale**: Modern, standardized, flexible signing framework -- **Signed Components**: `@method`, `@target-uri`, `@authority`, `content-type` -- **Trade-offs**: More complex than custom JWT, but interoperable - -### BFS Trust Traversal with Exponential Decay -- **Decision**: 0.85 decay per hop, max depth 6 -- **Rationale**: Balance between trust propagation and limiting spam -- **Trade-offs**: Tunable parameters (may need adjustment based on usage) - -### PostgreSQL as Single Database -- **Decision**: Use PostgreSQL for both discovery and trust graph services -- **Rationale**: Simple deployment, ACID guarantees, good graph query support -- **Trade-offs**: Not specialized graph DB, but sufficient for MVP scale - -### Hono Framework -- **Decision**: Use Hono for HTTP services instead of Express/Fastify -- **Rationale**: Lightweight, TypeScript-first, edge-ready -- **Trade-offs**: Smaller ecosystem than Express, but modern and fast - -### DID Format Simplification -- **Decision**: `did:fides:` (not W3C DID Core compliant) -- **Rationale**: Simplified for AI agents, avoid DID document complexity -- **Trade-offs**: Not interoperable with W3C DID ecosystem, but easier to implement - -### AES-256-GCM Key Storage -- **Decision**: Encrypt private keys with AES-256-GCM using PBKDF2-derived keys -- **Rationale**: Strong encryption, password-based protection -- **Parameters**: PBKDF2 with SHA-256, 600k iterations -- **Trade-offs**: User must remember password (no recovery mechanism in MVP) - -## Security Considerations - -### Replay Protection -- HTTP signatures include `created` and `expires` timestamps -- 300-second validity window -- **Limitation**: No nonce tracking in MVP (deferred to v2) - -### Clock Drift -- **Limitation**: No clock drift tolerance in MVP -- Services reject signatures with invalid timestamps -- Agents must have synchronized clocks - -### Key Management -- Private keys stored encrypted on disk -- No key rotation in MVP (deferred to v2) -- No HSM support in MVP - -### Trust Spam -- No rate limiting on trust attestations in MVP -- Exponential decay limits impact of distant trust -- **Future**: Add reputation-weighted trust scoring - -## Deployment Architecture - -### Development -``` -docker compose up -d # PostgreSQL -pnpm dev # All services in parallel -``` - -### Production (Future) -- Containerize services with Docker -- Deploy to Kubernetes or cloud platform -- Separate PostgreSQL instances per service -- API gateway for rate limiting and authentication -- Horizontal scaling for discovery and trust graph services - -## Performance Characteristics - -### Identity Resolution -- O(1) database lookup by DID -- Caching via `.well-known` for self-hosted identities - -### Trust Graph Traversal -- BFS: O(V + E) where V = vertices (agents), E = edges (trust attestations) -- Depth limit (6) bounds complexity -- Database indexes on issuer_did and subject_did - -### Signature Verification -- O(1) cryptographic verification -- Ed25519 ~50k verifications/sec on modern CPU - -## Future Extensions - -### Planned -- Rust SDK for performance-critical agents -- Policy engine for automated trust decisions -- Platform API for multi-agent coordination -- Web dashboard for trust graph visualization -- Key rotation and revocation -- Nonce-based replay protection -- WebAuthn integration for human agents - -### Under Consideration -- Zero-knowledge proofs for private trust attestations -- Threshold signatures for multi-agent identities -- Cross-chain identity anchoring -- Reputation decay over time -- Trust attestation types (beyond numeric levels) +FIDES v2 is a local-first Agent Trust Fabric for autonomous agent systems. It +is not an agent app store, a naive directory, or a global popularity graph. + +The current architecture is defined in +[`docs/architecture/fides-v2-agent-trust-fabric.md`](./architecture/fides-v2-agent-trust-fabric.md). + +## Core Invariants + +- Discovery never equals authority. +- Identity never equals trust. +- Trust score never equals permission. +- Domain ownership is optional and is only one trust anchor. +- Reputation is capability-specific. +- Policy is evaluated before execution. +- Authority is scoped through delegation tokens and session grants. +- Evidence is append-only, hash-chained, and privacy-aware. +- Signed protocol objects use one canonical signing model. +- Public SDK APIs are Promise-based. + +## Layer Model + +FIDES v2 is organized into these layers: + +1. Identity +2. Attestation +3. Agent metadata +4. Discovery +5. Trust +6. Reputation +7. Policy +8. Delegation +9. Invocation +10. Evidence +11. Revocation +12. Incident +13. Registry +14. Transport +15. Runtime +16. Developer +17. Interop + +## Local Authority Path + +The main local authority path is: + +1. Create agent, publisher, and principal identities. +2. Add optional trust anchors such as GitHub, email, package registry, domain, + wallet, passkey, organization invitation, runtime attestation, build + attestation, or peer attestation. +3. Create and sign an AgentCard with capability descriptors. +4. Register or publish the AgentCard through local, well-known, registry, relay, + DHT, or federation-ready discovery providers. +5. Treat discovered agents as candidates only. +6. Verify signed records and AgentCards. +7. Negotiate protocol versions. +8. Evaluate capability-specific trust and reputation. +9. Evaluate policy with revocations, incidents, kill switches, runtime + attestations, scopes, constraints, and requested capability. +10. Issue a scoped SessionGrant only when policy allows it. +11. Invoke through the session authority path. +12. Emit privacy-aware EvidenceEvents and verify the evidence hash chain. + +## Primary Runtime Surface + +`services/agentd` is the primary local HTTP API for the v2 prototype. It exposes +identity, attestations, AgentCards, agent registration, discovery, trust, +reputation, policy, approvals, delegation, sessions, invocation, evidence, +revocation, incidents, kill switch, registry, relay, DHT, demo, and adversarial +simulation endpoints. + +The CLI and SDK should prefer the v2 `agentd` surface: + +- CLI: `agentd ...` +- SDK: `FidesClient({ daemonUrl: "http://localhost:7345" })` + +Older standalone discovery and trust graph services may remain as compatibility +or migration surfaces, but they are not the v2 authority model. Discovery +providers return candidates; policy and SessionGrants grant authority. + +## Package Map + +| Package | Role | +|---------|------| +| `@fides/core` | Protocol objects, canonical signing, identities, capabilities, sessions, errors, version negotiation | +| `@fides/crypto` | Ed25519, hashing, canonical JSON, signatures | +| `@fides/identity` | Identity v2 types and trust anchors | +| `@fides/attestations` | Attestation provider interfaces and local/mock attestations | +| `@fides/cards` | AgentCards, capability descriptors, ontology, risk taxonomy | +| `@fides/discovery` | Provider interfaces and discovery orchestration | +| `@fides/dht` | DHT pointer records, simulator, adapter boundary | +| `@fides/relay` | Relay client/server protocol and NAT-hidden discovery hints | +| `@fides/registry` | Registry records, public/private modes, federation-ready peering | +| `@fides/trust` | Trust graph, trust scoring, trust explanations | +| `@fides/reputation` | Capability-specific reputation | +| `@fides/policy` | Policy-before-execution evaluator, approvals, guardrails, kill switch rules | +| `@fides/delegation` | DelegationToken and SessionGrant authority primitives | +| `@fides/invocation` | Capability invocation, validation, dry-run and approval-gated execution | +| `@fides/evidence` | Hash-chained EvidenceEvents, verification, redaction, export | +| `@fides/runtime-effect` | Optional internal Effect orchestration; protocol objects remain framework-agnostic | +| `@fides/sdk` | Promise-based TypeScript SDK | +| `@fides/cli` | `agentd` CLI | + +Some package boundaries are still being consolidated in the current monorepo. +The target structure is tracked in +[`docs/architecture/implementation-plan.md`](./architecture/implementation-plan.md). + +## Further Reading + +- [`docs/getting-started.md`](./getting-started.md) +- [`docs/architecture/fides-v2-agent-trust-fabric.md`](./architecture/fides-v2-agent-trust-fabric.md) +- [`docs/architecture/gap-analysis.md`](./architecture/gap-analysis.md) +- [`docs/protocol/canonical-object-signing.md`](./protocol/canonical-object-signing.md) +- [`docs/protocol/discovery.md`](./protocol/discovery.md) +- [`docs/protocol/policy-engine.md`](./protocol/policy-engine.md) +- [`docs/protocol/delegation-and-sessions.md`](./protocol/delegation-and-sessions.md) +- [`docs/protocol/evidence-ledger.md`](./protocol/evidence-ledger.md) +- [`docs/cli-reference.md`](./cli-reference.md) +- [`docs/sdk-reference.md`](./sdk-reference.md) From 1dae3db509900dfdcda0c00d5420caa7eb59fee2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:05:17 +0300 Subject: [PATCH 198/282] docs: refresh readme for fides v2 authority path --- README.md | 203 +++++++++++++++++++++++++++--------------------------- 1 file changed, 101 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index a019c96..7cda407 100644 --- a/README.md +++ b/README.md @@ -53,71 +53,81 @@ pnpm install pnpm build ``` -### Basic Usage +### Start agentd + +```bash +pnpm --filter @fides/agentd dev +curl http://localhost:7345/health +``` + +### CLI authority path + +The examples below assume the `agentd` binary is on your `PATH`. From the +monorepo, use `pnpm --filter @fides/cli agentd -- `. +Replace placeholder DIDs with the IDs returned by `identity create`. + +```bash +agentd identity create --type principal --name "Demo Principal" --agentd-url http://localhost:7345 +agentd identity create --type publisher --name "Demo Publisher" --agentd-url http://localhost:7345 +agentd identity create --type agent --name "Invoice Agent" --agentd-url http://localhost:7345 + +agentd card create --did did:fides:invoice-agent --name "Invoice Agent" --capabilities '[{"id":"invoice.reconcile","riskLevel":"medium","requiredScopes":["invoice:read"]}]' --agentd-url http://localhost:7345 +agentd card sign did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd register did:fides:invoice-agent --agentd-url http://localhost:7345 + +agentd discover --capability invoice.reconcile --provider local --agentd-url http://localhost:7345 +agentd trust did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 +agentd policy evaluate --agent did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 +agentd session request did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 +agentd invoke --session-id sess_... --input invoice.json --agentd-url http://localhost:7345 +agentd evidence verify --agentd-url http://localhost:7345 +``` + +Discovery returns candidates only. Policy and scoped SessionGrants are the +authority path. + +### TypeScript SDK ```typescript -import { - createAgentIdentity, - createPrincipalIdentity, - classifyCapabilityRisk, - createDelegationToken, - signDelegationToken, -} from '@fides/core' -import { evaluatePolicy } from '@fides/policy' -import { evaluateGuard, createTrustContext } from '@fides/guard' -import { createEvidenceChain, appendEvidenceEvent, hashEvidenceValue } from '@fides/evidence' -import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' - -// Create agent identities -const { identity: alice } = await createAgentIdentity() -alice.metadata = { name: 'Alice Assistant' } -const { identity: charlie, privateKey: charliePrivateKey } = await createPrincipalIdentity({ - type: 'individual', - displayName: 'Charlie User', +import { FidesClient } from '@fides/sdk' + +const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + +const principal = await client.identity.createPrincipal({ name: 'Demo Principal' }) +const requester = await client.identity.createAgent({ name: 'Requester Agent' }) +const target = await client.identity.createAgent({ name: 'Invoice Agent' }) + +const card = await client.cards.create({ + agentId: target.did, + name: 'Invoice Agent', + capabilities: [{ id: 'invoice.reconcile', riskLevel: 'medium', requiredScopes: ['invoice:read'] }], }) -// Classify capability risk -const risk = classifyCapabilityRisk('email:send') // 'high' - -// Delegate capabilities with constraints -const token = await signDelegationToken(createDelegationToken({ - delegator: charlie.did, - delegatee: alice.did, - capabilities: ['email:send', 'calendar:create'], - constraints: { maxActions: 10, maxSpend: '10.00', allowedContexts: ['work'] }, - expiresAt: new Date(Date.now() + 3600000).toISOString(), -}), charliePrivateKey) - -// Evaluate policy -const policy = { - id: 'default', version: '1.0.0', - rules: [ - { id: 'trust', condition: { operator: 'gte', field: 'reputationScore', value: 0.8 }, action: 'allow', explanation: 'High trust' }, - ], - defaultAction: 'deny', -} -const result = evaluatePolicy(policy, { reputationScore: 0.9 }) - -// Build evidence chain -let chain = createEvidenceChain() -const event = { - id: 'e1', type: 'invoke', timestamp: new Date().toISOString(), - actor: alice.did, action: 'email:send', payload: {}, - privacy: { level: 'redacted' }, -} -chain = appendEvidenceEvent(chain, event, `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}`) - -// Run guard decision -const trust = createTrustContext({ - reputationScore: 0.9, capabilityScore: 0.95, - attestation: await new MockTEEProvider().attest(alice.did), - evidenceChain: chain, killSwitchEngaged: false, recentIncidents: 0, +await client.cards.sign({ id: card.card.id }) +await client.agents.register({ agentCardId: card.card.id }) + +const candidates = await client.discovery.local({ capability: 'invoice.reconcile' }) +console.log(candidates.authorityGranted) // false + +await client.trust.evaluate({ agentId: target.did, capability: 'invoice.reconcile' }) +await client.policy.evaluate({ + principalId: principal.did, + requesterAgentId: requester.did, + agentId: target.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], }) -const decision = await evaluateGuard({ - agentDid: alice.did, capabilityId: 'email:send', - policy, context: { requestCount: 10 }, trust, + +const session = await client.sessions.request({ + principalId: principal.did, + requesterAgentId: requester.did, + agentId: target.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], }) -// decision.decision → 'allow' | 'deny' | 'approve-required' | 'dry-run' + +await client.invoke({ sessionId: session.session.session_id, input: { invoiceId: 'inv_123' } }) +await client.evidence.verify() ``` --- @@ -125,49 +135,38 @@ const decision = await evaluateGuard({ ## Architecture ``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ AI Agent │ -│ │ -│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────────────────┐ │ -│ │ @fides/ │ │ @fides/ │ │ @fides/ │ │ @fides/ │ │ -│ │ core │ │ policy │ │ guard │ │ evidence │ │ -│ │ │ │ │ │ │ │ │ │ -│ │ Identity │ │ Policy │ │ Decision │ │ Hash-chain │ │ -│ │ Signing │ │ Rules │ │ Pipeline │ │ Merkle root │ │ -│ │ AgentCard │ │ Expressions│ │ KillSwitch │ │ Privacy levels │ │ -│ │ DelegationToken evaluation │ │ Attestation│ │ Event log │ │ -│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └──────────┬───────────┘ │ -│ │ │ │ │ │ -│ ┌─────┴──────────────┴──────────────┴───────────────────┴──────────┐ │ -│ │ @fides/discovery │ │ -│ │ well-known · registry · relay · DHT · local │ │ -│ └──────────────────────────────┬────────────────────────────────────┘ │ -│ ┌──────────────────────────────┴────────────────────────────────────┐ │ -│ │ @fides/runtime │ │ -│ │ TEE Attestation · Kill Switch · Runtime verification │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ┌───────────────────────┼───────────────────────┐ - ▼ ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ @fides/ │ │ @fides/ │ │ @fides/ │ -│ discovery-svc │ │ trust-graph │ │ registry-svc │ -│ │ │ │ │ │ -│ AgentCard │ │ Trust edges │ │ Agent │ -│ resolution │ │ Reputation │ │ registration │ -│ .well-known │ │ BFS scoring │ │ Capability pub │ -└─────────────────┘ └────────┬────────┘ └────────┬────────┘ - │ │ - ┌──────────┴─────────────────────┴──────────┐ - ▼ ▼ ▼ - ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ - │ @fides/ │ │ @fides/ │ │ @fides/ │ - │ relay-svc │ │ agentd │ │ platform-api │ - │ │ │ │ │ │ - │ NAT traversal │ │ Agent daemon │ │ REST/gRPC │ - │ Message relay │ │ Lifecycle mgmt │ │ Admin API │ - └─────────────────┘ └─────────────────┘ └─────────────────┘ +intent/capability + constraints + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Discovery providers │ +│ local · well-known · registry · relay · DHT · federation │ +│ Output: verified candidates only, never authority │ +└──────────────────────────────┬───────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Verification and scoring │ +│ signed AgentCards · protocol versions · trust anchors │ +│ capability-specific trust · reputation · incidents │ +│ revocations · runtime attestations │ +└──────────────────────────────┬───────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Policy-before-execution │ +│ allow · deny · require_approval · dry_run_only │ +│ scope_limit · risk_limit · kill switch override │ +└──────────────────────────────┬───────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Scoped authority │ +│ DelegationToken · SessionGrant · nonce · expiry · audience │ +└──────────────────────────────┬───────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Invocation and evidence │ +│ validate input/output · execute or dry-run · signed result │ +│ hash-chained EvidenceEvents · redacted/hash-only by default │ +└──────────────────────────────────────────────────────────────┘ ``` --- From f054a69475badf5a4ac95d6995a7831b21b81c7d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:08:29 +0300 Subject: [PATCH 199/282] docs: clarify agentd as fides v2 deployment surface --- docs/api/discovery.yaml | 6 ++++++ docs/api/trust-graph.yaml | 6 ++++++ docs/deployment.md | 30 +++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/api/discovery.yaml b/docs/api/discovery.yaml index 12caf7b..eb400cb 100644 --- a/docs/api/discovery.yaml +++ b/docs/api/discovery.yaml @@ -6,6 +6,12 @@ info: Identity registration, resolution, and agent discovery for the FIDES v2 trust fabric. Supports DID-based identity persistence, agent capability registry, and well-known discovery endpoints. + + This OpenAPI document describes the standalone discovery service. The + primary local FIDES v2 developer surface is agentd on + http://localhost:7345. Discovery records are candidates only and do not + grant invocation authority. Authority requires policy evaluation and a + scoped SessionGrant through agentd. contact: name: FIDES Protocol url: https://github.com/EfeDurmaz16/fides diff --git a/docs/api/trust-graph.yaml b/docs/api/trust-graph.yaml index 50308bf..7123bcb 100644 --- a/docs/api/trust-graph.yaml +++ b/docs/api/trust-graph.yaml @@ -5,6 +5,12 @@ info: description: | Trust edge management, reputation scoring, capability-level trust computation, and incident tracking for the FIDES v2 trust fabric. + + This OpenAPI document describes the standalone trust-graph service. The + primary local FIDES v2 developer surface is agentd on + http://localhost:7345. Trust and reputation are capability-specific signals; + they do not grant permission. Authority requires policy evaluation and a + scoped SessionGrant through agentd. contact: name: FIDES Protocol url: https://github.com/EfeDurmaz16/fides diff --git a/docs/deployment.md b/docs/deployment.md index 47cf89f..d883373 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,14 +1,42 @@ # FIDES Deployment Guide +## FIDES v2 Deployment Status + +The primary FIDES v2 runtime surface is the local-first `agentd` API on port +`7345`. It hosts the v2 trust-fabric path for identity, AgentCards, discovery, +trust, reputation, policy, approvals, delegation, sessions, invocation, +evidence, revocation, incidents, kill switch, registry, relay, DHT, demos, and +adversarial simulation. + +For local FIDES v2 development, Postgres is optional. `agentd` persists local +v2 state to SQLite at `~/.fides/fides.sqlite` by default and can run with: + +```bash +pnpm --filter @fides/agentd dev +curl http://localhost:7345/health +``` + +The standalone discovery, trust-graph, registry, relay, policy-engine, and +platform-api services remain useful for compatibility, hosted deployments, +durable backing stores, and migration work. They are not the v2 authority model +by themselves. Discovery services return candidates; policy evaluation and +scoped SessionGrants grant authority. + ## Prerequisites - **Node.js** 22 or later - **pnpm** 10 (enabled via `corepack enable`) - **Docker** 24+ (for containerized deployment) -- **PostgreSQL** 16 (for discovery, trust-graph, production registry storage, and production `agentd` authority storage) +- **PostgreSQL** 16 for standalone services, hosted registry storage, or + production `agentd` authority storage. It is not required for the default + local v2 SQLite run. ## Services Overview +`agentd` is the primary FIDES v2 local authority surface. The other services are +standalone compatibility or hosted backing services unless a deployment +explicitly composes them behind `agentd`. + | Service | Port | Database | Description | | ------------ | ----- | -------------- | ---------------------------------------- | | `discovery` | 3100 | PostgreSQL | DID resolution, identity registry | From e0554ebe3f4bf6dd8ee085583098105e793e58a6 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:13:42 +0300 Subject: [PATCH 200/282] docs(cli): fix workspace agentd invocation --- README.md | 2 +- docs/getting-started.md | 2 +- packages/cli/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7cda407..207fe88 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ curl http://localhost:7345/health ### CLI authority path The examples below assume the `agentd` binary is on your `PATH`. From the -monorepo, use `pnpm --filter @fides/cli agentd -- `. +monorepo, use `pnpm --filter @fides/cli agentd `. Replace placeholder DIDs with the IDs returned by `identity create`. ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index a8353e7..2cb65fe 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -32,7 +32,7 @@ The examples below assume the `agentd` binary is on your `PATH`. From a fresh checkout, you can run the same commands through the workspace package: ```bash -pnpm --filter @fides/cli agentd -- +pnpm --filter @fides/cli agentd ``` ## Start The Local Daemon diff --git a/packages/cli/README.md b/packages/cli/README.md index 1dc355d..d5079df 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -20,7 +20,7 @@ npm install -g @fides/cli From the monorepo checkout: ```bash -pnpm --filter @fides/cli agentd -- +pnpm --filter @fides/cli agentd ``` ## Usage From 57fd72c9277f9ccae819d8875050159c804d7c87 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:15:33 +0300 Subject: [PATCH 201/282] chore(cli): add root agentd scripts --- README.md | 4 ++-- docs/getting-started.md | 6 +++--- package.json | 2 ++ packages/cli/README.md | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 207fe88..ef7daa5 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,14 @@ pnpm build ### Start agentd ```bash -pnpm --filter @fides/agentd dev +pnpm agentd:dev curl http://localhost:7345/health ``` ### CLI authority path The examples below assume the `agentd` binary is on your `PATH`. From the -monorepo, use `pnpm --filter @fides/cli agentd `. +monorepo, use `pnpm agentd `. Replace placeholder DIDs with the IDs returned by `identity create`. ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index 2cb65fe..f1c6bcd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -29,10 +29,10 @@ pnpm build ``` The examples below assume the `agentd` binary is on your `PATH`. From a fresh -checkout, you can run the same commands through the workspace package: +checkout, you can run the same commands through the root workspace script: ```bash -pnpm --filter @fides/cli agentd +pnpm agentd ``` ## Start The Local Daemon @@ -40,7 +40,7 @@ pnpm --filter @fides/cli agentd The root v2 API is served by `agentd` on `http://localhost:7345`. ```bash -pnpm --filter @fides/agentd dev +pnpm agentd:dev ``` In another shell: diff --git a/package.json b/package.json index 3cf0510..4b77819 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "verify:quick": "pnpm lint && pnpm test", "ci:local": "pnpm verify", "dev": "turbo run dev --parallel", + "agentd": "pnpm --filter @fides/cli agentd", + "agentd:dev": "pnpm --filter @fides/agentd dev", "clean": "turbo run clean", "demo": "tsx examples/demo.ts", "demo:authority": "tsx scripts/authority-path-demo.ts", diff --git a/packages/cli/README.md b/packages/cli/README.md index d5079df..b895f3b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -20,7 +20,7 @@ npm install -g @fides/cli From the monorepo checkout: ```bash -pnpm --filter @fides/cli agentd +pnpm agentd ``` ## Usage From 416de6c826b293edde74c04492ddb447d677434d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:17:55 +0300 Subject: [PATCH 202/282] docs(cli): note silent json mode for pnpm agentd --- README.md | 2 ++ docs/getting-started.md | 3 +++ packages/cli/README.md | 3 +++ 3 files changed, 8 insertions(+) diff --git a/README.md b/README.md index ef7daa5..c7eda4a 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ curl http://localhost:7345/health The examples below assume the `agentd` binary is on your `PATH`. From the monorepo, use `pnpm agentd `. Replace placeholder DIDs with the IDs returned by `identity create`. +Use `pnpm --silent agentd ... --json` when piping JSON output to another tool, +because pnpm prints script banners by default. ```bash agentd identity create --type principal --name "Demo Principal" --agentd-url http://localhost:7345 diff --git a/docs/getting-started.md b/docs/getting-started.md index f1c6bcd..70cc2da 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -35,6 +35,9 @@ checkout, you can run the same commands through the root workspace script: pnpm agentd ``` +Use `pnpm --silent agentd ... --json` when piping JSON output to another tool, +because pnpm prints script banners by default. + ## Start The Local Daemon The root v2 API is served by `agentd` on `http://localhost:7345`. diff --git a/packages/cli/README.md b/packages/cli/README.md index b895f3b..b63b3ad 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -23,6 +23,9 @@ From the monorepo checkout: pnpm agentd ``` +Use `pnpm --silent agentd ... --json` when piping JSON output to another tool, +because pnpm prints script banners by default. + ## Usage ```bash From f23015346fcf656ef322ddc09a12206390a4372a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:20:07 +0300 Subject: [PATCH 203/282] docs: add fides v2 implementation status --- README.md | 2 + docs/getting-started.md | 1 + docs/status/fides-v2-implementation-status.md | 268 ++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 docs/status/fides-v2-implementation-status.md diff --git a/README.md b/README.md index c7eda4a..d88b155 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ FIDES solves these problems with a layered trust protocol built specifically for - **Ed25519 Identity** — DID-based identities with canonical JSON signing - **Trust Graph** — Weighted, capability-specific reputation with transitive trust scoring +**Current implementation status:** [docs/status/fides-v2-implementation-status.md](docs/status/fides-v2-implementation-status.md) + --- ## Quick Start diff --git a/docs/getting-started.md b/docs/getting-started.md index 70cc2da..c3d3aba 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -352,6 +352,7 @@ pnpm --filter @fides/agentd test ## Next Steps +- Read `docs/status/fides-v2-implementation-status.md`. - Read `docs/architecture/fides-v2-agent-trust-fabric.md`. - Read `docs/protocol/canonical-object-signing.md`. - Read `docs/protocol/discovery.md`. diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md new file mode 100644 index 0000000..2d009a9 --- /dev/null +++ b/docs/status/fides-v2-implementation-status.md @@ -0,0 +1,268 @@ +# FIDES v2 Implementation Status + +This document records the current implementation status for the FIDES v2 Agent +Trust Fabric. It is not a completion claim for the full project pivot. It is a +grounded status snapshot for local development, manual DX, and open-source +readiness work. + +Last verified locally: 2026-05-30. + +## What Was Implemented + +- Local-first `agentd` HTTP API for identity, attestations, AgentCards, agent + registration, discovery, trust, reputation, policy, approvals, delegation, + sessions, invocation, evidence, revocation, incidents, kill switch, registry, + relay, DHT, demo, and adversarial simulation. +- Promise-based `FidesClient` SDK surface for the root v2 `agentd` API. +- `agentd` CLI command surface, plus root workspace scripts: + - `pnpm agentd ` + - `pnpm agentd:dev` +- Canonical signing model for signed protocol objects. +- Typed error envelopes on important session and invocation failure paths. +- Signed AgentCards and capability descriptors. +- Candidate-only discovery across local, well-known, registry, relay, DHT, and + federation-ready surfaces. +- Signed registry index records, signed relay AgentCard references, and signed + DHT pointer records. +- Capability-specific trust and reputation scoring with explainability. +- Policy-before-execution with approval, dry-run, revocation, incident, runtime + attestation, and kill switch inputs. +- Scoped SessionGrants and invocation preflight. +- Hash-chained EvidenceEvents with verification and export. +- Runtime attestation schema and local MockTEE provider. +- Local SQLite daemon snapshot store for v2 local state. +- Full local demo and adversarial simulation endpoints. +- Public docs refreshed around `agentd`, `FidesClient`, candidate-only + discovery, and authority-via-policy/session. + +## Production-Like + +- Canonical object signing and verification primitives. +- Typed error vocabulary and `ErrorEnvelope` response shape. +- `agentd` scoped API key enforcement on protected mutation routes. +- Postgres authority-store migration and health-check path for `agentd`. +- Revocation, incident, kill switch, session, and evidence policy hooks. +- SDK type coverage for the main root v2 API responses. +- OpenAPI schemas for root `agentd` demo and simulation responses. + +## Working Prototype + +- Local `agentd` v2 authority path. +- Identity creation and local key-backed signing for prototype flows. +- AgentCard create/sign/verify/register/discover. +- Capability-specific trust and reputation. +- Policy evaluation and session request. +- Invocation with dry-run and denial modes. +- Evidence append, inspect, verify, and export. +- Full demo scenario. +- Adversarial simulation harness. + +## Local Mock + +- Registry discovery uses local mock registry records. +- Relay discovery uses local mock presence and endpoint hints. +- DHT discovery uses local in-memory pointer records. +- Federation discovery uses local mock federation provider behavior. +- MockTEE is the local runtime attestation provider. +- Generic FIDES payment flow is dry-run only; payment execution remains + Sardis-specific. + +## Adapter-Ready + +- AGIT/Rust bridge for canonical JSON, hashing, hash chain, Merkle, and DAG + primitives. +- libp2p/Kademlia DHT adapter boundary. +- Relay transport adapter boundary. +- Real TEE providers: AWS Nitro, Intel SGX, AMD SEV. +- Container image and reproducible build attestation providers. +- MCP, A2A, OAPS, OSP, AP2, x402, and Sardis adapter interfaces. +- Federation peering and propagation interfaces. + +## Spec-Complete Or Documentation-First + +- OAPS concepts are mapped into FIDES-owned runtime types and docs; FIDES does + not depend on `@oaps/core` at runtime. +- Sardis contributes generic authority/trust/evidence patterns only; payment + domain remains Sardis-specific. +- Effect is documented as an internal orchestration option only; protocol + objects and SDK APIs remain framework-agnostic. +- Public protocol docs exist for identity, AgentCards, discovery, DHT, relay, + registry/federation, trust, reputation, policy, delegation/sessions, + evidence, runtime attestation, revocation, incidents, approvals, kill switch, + interop adapters, version negotiation, privacy, and error vocabulary. + +## Package Overview + +| Area | Current location | +|------|------------------| +| Protocol objects and signing | `packages/core` | +| Evidence ledger | `packages/evidence` | +| Policy evaluator | `packages/policy` | +| Guard decision pipeline | `packages/guard` | +| Runtime attestation and kill switch | `packages/runtime` | +| Discovery providers | `packages/discovery` | +| SDK | `packages/sdk` | +| CLI | `packages/cli` | +| Local daemon/API | `services/agentd` | +| Adapters | `packages/adapters` | + +Some target package boundaries from the v2 architecture remain consolidated in +existing packages. See `docs/architecture/implementation-plan.md` for the target +package structure. + +## CLI Command Overview + +Primary local commands: + +```bash +pnpm agentd identity create --type agent +pnpm agentd card create +pnpm agentd card sign +pnpm agentd register +pnpm agentd discover --capability invoice.reconcile +pnpm agentd trust --capability invoice.reconcile +pnpm agentd policy evaluate --agent --capability invoice.reconcile +pnpm agentd session request --capability invoice.reconcile +pnpm agentd invoke --session-id --input invoice.json +pnpm agentd evidence verify +pnpm agentd demo run +pnpm agentd simulate adversarial +``` + +Use `pnpm --silent agentd ... --json` when piping JSON output. + +## API Endpoint Overview + +Primary root v2 API: + +- `GET /health` +- `POST /identities` +- `POST /attestations` +- `POST /agent-cards` +- `POST /agents/register` +- `POST /discover` +- `POST /trust/evaluate` +- `POST /reputation/update` +- `POST /policy/evaluate` +- `POST /approvals` +- `POST /delegations` +- `POST /sessions` +- `POST /invoke` +- `GET /evidence` +- `POST /evidence/verify` +- `POST /revocations` +- `POST /incidents` +- `POST /killswitch` +- `POST /registry/publish` +- `POST /relay/register` +- `POST /dht/publish` +- `POST /demo/run` +- `POST /simulate/adversarial` + +See `docs/api/agentd.yaml` and `docs/api-reference.md` for the complete API +surface. + +## SDK Example + +```typescript +import { FidesClient } from '@fides/sdk' + +const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + +const identity = await client.identity.createAgent({ name: 'Invoice Agent' }) +const card = await client.cards.create({ + agentId: identity.did, + name: 'Invoice Agent', + capabilities: [{ id: 'invoice.reconcile', riskLevel: 'medium' }], +}) + +await client.cards.sign({ id: card.card.id }) +await client.agents.register({ agentCardId: card.card.id }) + +const candidates = await client.discovery.local({ capability: 'invoice.reconcile' }) +const trust = await client.trust.evaluate({ agentId: identity.did, capability: 'invoice.reconcile' }) +const session = await client.sessions.request({ + agentId: identity.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], +}) +const result = await client.invoke({ + sessionId: session.session.session_id, + input: { invoiceId: 'inv_123' }, +}) + +console.log({ authorityGrantedByDiscovery: candidates.authorityGranted, trust, result }) +``` + +## Verification Run + +Recently verified commands: + +```bash +pnpm --filter @fides/sdk build +pnpm --filter @fides/sdk test +pnpm --filter @fides/cli lint +pnpm --filter @fides/agentd test +pnpm --filter @fides/cli build +pnpm package:hygiene +``` + +Manual DX smoke: + +```bash +AGENTD_LOCAL_STATE=memory AGENTD_PORT=7486 pnpm agentd:dev +pnpm --silent agentd demo run --agentd-url http://localhost:7486 --json +pnpm --silent agentd simulate adversarial --agentd-url http://localhost:7486 --json +``` + +Observed manual smoke results: + +- demo returned `status: "executed"`. +- demo returned `mode: "local-first"`. +- demo returned `evidenceHashChainValid: true`. +- demo returned `discoveryGrantsAuthority: false`. +- demo returned `payments: "dry_run_only"`. +- adversarial simulation returned `status: "detected"`. +- adversarial simulation detected 10 scenarios. +- adversarial simulation returned `rootChainValid: true`. +- adversarial simulation returned `brokenEvidenceChainValid: false`. + +## Known Limitations + +- The full pivot is not complete. +- The target package structure is not fully split into every final package. +- DHT, relay, registry, and federation are local mock/simulator surfaces rather + than production networks. +- Real TEE providers are adapter-ready but not implemented. +- Real payment execution is intentionally not implemented in FIDES; it belongs + in Sardis. +- Some legacy standalone service docs remain for compatibility and deployment + reference. +- Full `pnpm verify` was not run in the latest local verification pass. +- The branch has not been pushed in the current session. + +## Future Hardening Steps + +- Run full `pnpm verify` before release. +- Push `fides-v2-agent-trust-fabric` and open/update a PR. +- Normalize target package boundaries where the current monorepo is still + consolidated. +- Add real DHT, relay, registry, and federation adapters. +- Add production TEE/build/container attestation providers. +- Harden local key storage beyond prototype snapshot material. +- Expand CLI end-to-end tests around root `pnpm agentd` scripts. +- Add full release notes and contribution guidance for external OSS users. + +## Commit History Summary + +Recent v2 status/DX commits: + +- `416de6c docs(cli): note silent json mode for pnpm agentd` +- `57fd72c chore(cli): add root agentd scripts` +- `e0554eb docs(cli): fix workspace agentd invocation` +- `f054a69 docs: clarify agentd as fides v2 deployment surface` +- `1dae3db docs: refresh readme for fides v2 authority path` +- `f956dc2 docs: align top-level architecture with fides v2` +- `f99721d docs(cli): document agentd authority workflow` +- `1208ccd docs(sdk): lead with fides client quickstart` +- `49542c8 docs: refresh getting started for fides v2` From edf453c64bf903cc2d314d39164432ccfa415967 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:23:13 +0300 Subject: [PATCH 204/282] docs: record full verification status --- docs/status/fides-v2-implementation-status.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 2d009a9..6ac47e7 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -199,6 +199,7 @@ console.log({ authorityGrantedByDiscovery: candidates.authorityGranted, trust, r Recently verified commands: ```bash +pnpm verify pnpm --filter @fides/sdk build pnpm --filter @fides/sdk test pnpm --filter @fides/cli lint @@ -238,12 +239,13 @@ Observed manual smoke results: in Sardis. - Some legacy standalone service docs remain for compatibility and deployment reference. -- Full `pnpm verify` was not run in the latest local verification pass. +- Full verification has passed locally, but remote CI has not been checked in + the current session. - The branch has not been pushed in the current session. ## Future Hardening Steps -- Run full `pnpm verify` before release. +- Keep full `pnpm verify` green before release. - Push `fides-v2-agent-trust-fabric` and open/update a PR. - Normalize target package boundaries where the current monorepo is still consolidated. From 69686b579ef5e3144d646c73f071dc2ec5813661 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:24:22 +0300 Subject: [PATCH 205/282] test(cli): cover workspace agentd scripts --- .../cli/test/workspace-agentd-script.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/cli/test/workspace-agentd-script.test.ts diff --git a/packages/cli/test/workspace-agentd-script.test.ts b/packages/cli/test/workspace-agentd-script.test.ts new file mode 100644 index 0000000..f9a5d99 --- /dev/null +++ b/packages/cli/test/workspace-agentd-script.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' + +function readPackageJson(path: URL): Record { + return JSON.parse(readFileSync(path, 'utf-8')) +} + +describe('workspace agentd script contract', () => { + it('keeps the root pnpm agentd script wired to the cli agentd entrypoint', () => { + const rootPackage = readPackageJson(new URL('../../../package.json', import.meta.url)) + const cliPackage = readPackageJson(new URL('../package.json', import.meta.url)) + + expect(rootPackage.scripts.agentd).toBe('pnpm --filter @fides/cli agentd') + expect(rootPackage.scripts['agentd:dev']).toBe('pnpm --filter @fides/agentd dev') + expect(cliPackage.scripts.agentd).toBe('node dist/index.js') + expect(cliPackage.bin.agentd).toBe('./dist/index.js') + }) +}) From d57135a46bc16d6727697d428bcec4c40155356a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:26:40 +0300 Subject: [PATCH 206/282] docs: expand agentd status api overview --- docs/status/fides-v2-implementation-status.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 6ac47e7..2f89ce5 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -137,25 +137,68 @@ Primary root v2 API: - `GET /health` - `POST /identities` +- `GET /identities` +- `GET /identities/:id` - `POST /attestations` +- `GET /attestations/:id` +- `POST /attestations/:id/verify` - `POST /agent-cards` +- `POST /agent-cards/:id/sign` +- `POST /agent-cards/:id/verify` +- `GET /agent-cards/:id` - `POST /agents/register` +- `GET /agents` +- `GET /agents/:id` - `POST /discover` +- `POST /discover/local` +- `POST /discover/well-known` +- `POST /discover/registry` +- `POST /discover/relay` +- `POST /discover/dht` +- `POST /discover/federation` - `POST /trust/evaluate` +- `GET /trust/:agentId` - `POST /reputation/update` +- `GET /reputation/:agentId` - `POST /policy/evaluate` - `POST /approvals` +- `GET /approvals` +- `POST /approvals/:id/approve` +- `POST /approvals/:id/deny` - `POST /delegations` - `POST /sessions` +- `GET /sessions/:id` +- `POST /sessions/:id/verify` - `POST /invoke` +- `POST /evidence` - `GET /evidence` +- `GET /evidence/:eventId` - `POST /evidence/verify` +- `POST /evidence/export` - `POST /revocations` +- `GET /revocations` +- `GET /revocations/:id` - `POST /incidents` +- `GET /incidents` +- `GET /incidents/:id` +- `POST /incidents/:id/resolve` - `POST /killswitch` +- `GET /killswitch` +- `DELETE /killswitch/:id` +- `POST /registry/start` - `POST /registry/publish` +- `POST /registry/search` +- `GET /registry/index` +- `POST /relay/start` - `POST /relay/register` +- `POST /relay/discover` +- `POST /dht/start` - `POST /dht/publish` +- `GET /dht/find` +- `POST /dht/find` +- `GET /.well-known/fides.json` +- `GET /.well-known/agents.json` +- `GET /.well-known/agents/:id.json` - `POST /demo/run` - `POST /simulate/adversarial` From c2df90409ec24e237ddccb60c03adf85bc8d88b5 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:27:20 +0300 Subject: [PATCH 207/282] docs(examples): align full demo workspace commands --- examples/full-demo/README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/full-demo/README.md b/examples/full-demo/README.md index 49d3be2..a75f3fd 100644 --- a/examples/full-demo/README.md +++ b/examples/full-demo/README.md @@ -11,14 +11,17 @@ pnpm exec tsx examples/full-demo/run.ts Run the local daemon endpoint: ```bash -agentd demo run +pnpm agentd demo run --agentd-url http://localhost:7345 ``` -`agentd demo run` creates local demo identities, signs and registers AgentCards, -publishes candidates through local registry/relay/DHT surfaces, runs discovery, -computes trust and reputation, evaluates policy, issues scoped sessions, invokes -the invoice and payment dry-run paths, records incident and revocation state, and -verifies the local EvidenceEvent hash chain. +Start the local daemon first with `pnpm agentd:dev`. In an installed package +context, the same command is available as `agentd demo run`. + +`pnpm agentd demo run` creates local demo identities, signs and registers +AgentCards, publishes candidates through local registry/relay/DHT surfaces, runs +discovery, computes trust and reputation, evaluates policy, issues scoped +sessions, invokes the invoice and payment dry-run paths, records incident and +revocation state, and verifies the local EvidenceEvent hash chain. The demo also exercises the signed provider metadata surfaces: From 966ea7ae3fe3a5b8d1bfb750760eefc7ab3e76f3 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:29:42 +0300 Subject: [PATCH 208/282] feat(sdk): expose fides client health check --- docs/sdk-reference.md | 5 +++++ packages/sdk/README.md | 1 + packages/sdk/src/fides-client.ts | 7 +++++++ packages/sdk/src/index.ts | 1 + packages/sdk/test/fides-client.test.ts | 24 ++++++++++++++++++++++++ 5 files changed, 38 insertions(+) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 63c3e2d..1f45be6 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -14,6 +14,11 @@ import { FidesClient } from '@fides/sdk' const client = new FidesClient({ daemonUrl: 'http://localhost:4817' }) +const health = await client.health() +if (health.status !== 'healthy') { + console.warn('agentd is reachable but degraded', health.checks) +} + const identity = await client.identity.createAgent({ name: 'Invoice Agent' }) const identities = await client.identity.list() const sameIdentity = await client.identity.show(identity.identity.did) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 697d0e2..5b695aa 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -21,6 +21,7 @@ pnpm add @fides/sdk import { FidesClient } from '@fides/sdk' const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) +const health = await client.health() const principal = await client.identity.createPrincipal({ name: 'Demo Principal' }) const requester = await client.identity.createAgent({ name: 'Requester Agent' }) diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 7112486..2ce2c9b 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -26,6 +26,9 @@ import { type SignedInvocationRequest, type SignedInvocationResult, } from '@fides/core' +import type { AgentdHealthResponse } from './agentd/client.js' + +export type { AgentdHealthResponse as FidesHealthResponse } from './agentd/client.js' export interface FidesClientOptions { daemonUrl: string @@ -944,6 +947,10 @@ export class FidesClient { constructor(private readonly options: FidesClientOptions) {} + health(): Promise { + return this.get('/health') as Promise + } + invoke(body: FidesInvocationRequest): Promise { return this.post('/invoke', body) as Promise } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 047fa77..0145848 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -140,6 +140,7 @@ export { FidesClient, FidesClientError, type FidesClientOptions, + type FidesHealthResponse, type FidesDiscoveryQuery, type FidesDiscoveryResponse, type FidesProviderRecord, diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 24cc5e8..fa59622 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -7,6 +7,30 @@ afterEach(() => { }) describe('FidesClient', () => { + it('reads root daemon health from the public v2 facade', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + status: 'degraded', + service: 'agentd', + authorityStore: { kind: 'file', ok: true }, + localStateStore: { kind: 'sqlite', ok: true, path: '/tmp/fides.sqlite' }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }))) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const health = await client.health() + + expect(health.status).toBe('degraded') + expect(health.service).toBe('agentd') + expect(health.authorityStore?.kind).toBe('file') + expect(health.localStateStore?.kind).toBe('sqlite') + expect(fetch).toHaveBeenCalledWith('http://localhost:7345/health', { + method: 'GET', + headers: new Headers(), + }) + }) + it('types root policy evaluation responses with v2 decisions', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ policy: { From aec25739face1a8d7ec927634ff5bba53eeb73f3 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:30:28 +0300 Subject: [PATCH 209/282] docs(sdk): show health readiness check --- packages/sdk/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 5b695aa..077105d 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -22,6 +22,9 @@ import { FidesClient } from '@fides/sdk' const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) const health = await client.health() +if (health.status !== 'healthy') { + console.warn('agentd is reachable but degraded', health.checks) +} const principal = await client.identity.createPrincipal({ name: 'Demo Principal' }) const requester = await client.identity.createAgent({ name: 'Requester Agent' }) From df536900753435ed76e870ddd0a8c10e862ad7bf Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:32:47 +0300 Subject: [PATCH 210/282] feat(cli): add graph inspect command --- docs/cli-reference.md | 5 +++++ packages/cli/README.md | 1 + packages/cli/src/commands/graph.ts | 27 ++++++++++++++++++++++++ packages/cli/src/index.ts | 2 ++ packages/cli/test/commands.test.ts | 34 ++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+) create mode 100644 packages/cli/src/commands/graph.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e04fba8..9e8fd54 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -72,6 +72,7 @@ agentd invoke --dry-run did:fides:... --capability payments.prepare --input paym agentd trust did:fides:... --capability invoice.reconcile --agentd-url http://localhost:7345 agentd reputation update --agent did:fides:... --capability invoice.reconcile --successful-invocations 5 agentd reputation get did:fides:... +agentd graph inspect did:fides:... --agentd-url http://localhost:7345 agentd policy evaluate --agent did:fides:... --capability invoice.reconcile --requested-scopes read:invoices --agentd-url http://localhost:7345 agentd approval request --agent did:fides:... --capability payments.prepare --requested-scopes payments:prepare --risk-level high agentd approval list @@ -153,6 +154,10 @@ trust-attestation behavior. `reputation update/get` manages root v2 capability-specific reputation records. Trust and reputation remain signals; policy is the authority. +`graph inspect ` reads the local `agentd` trust graph view for an +agent candidate through `GET /trust/:agentId`. It is an inspection surface only; +it does not grant authority or replace policy/session issuance. + `policy evaluate --agentd-url` evaluates root v2 policy-before-execution through local agentd and returns a structured decision, trust context, required controls, and whether a `SessionGrant` is still required. Without `--agentd-url`, diff --git a/packages/cli/README.md b/packages/cli/README.md index b63b3ad..cdec735 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -45,6 +45,7 @@ agentd dht publish --capability invoice.reconcile --agent-id did:fides:invoice-a agentd dht find --capability invoice.reconcile --agentd-url http://localhost:7345 agentd trust did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 +agentd graph inspect did:fides:invoice-agent --agentd-url http://localhost:7345 agentd policy evaluate --agent did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 agentd session request did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 agentd invoke --session-id sess_... --input invoice.json --agentd-url http://localhost:7345 diff --git a/packages/cli/src/commands/graph.ts b/packages/cli/src/commands/graph.ts new file mode 100644 index 0000000..f34b70f --- /dev/null +++ b/packages/cli/src/commands/graph.ts @@ -0,0 +1,27 @@ +import { Command } from 'commander' +import { getJson, printResult } from './authority-utils.js' + +export function createGraphCommand(): Command { + const cmd = new Command('graph') + .description('Inspect local trust graph views') + + cmd.command('inspect') + .description('Inspect local trust graph state for an agent candidate') + .argument('', 'Agent DID to inspect') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (agentId, options) => { + const result = await getJson(`${baseUrl(options.agentdUrl)}/trust/${encodeURIComponent(agentId)}`) + printResult('Trust graph view:', { + agentId, + authorityGranted: false, + graphView: result, + }, options) + }) + + return cmd +} + +function baseUrl(url: string): string { + return url.replace(/\/$/, '') +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ab02123..40fff3f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { createInitCommand } from './commands/init.js'; import { createSignCommand } from './commands/sign.js'; import { createVerifyCommand } from './commands/verify.js'; import { createTrustCommand } from './commands/trust.js'; +import { createGraphCommand } from './commands/graph.js'; import { createReputationCommand } from './commands/reputation.js'; import { createDiscoverCommand } from './commands/discover.js'; import { createStatusCommand } from './commands/status.js'; @@ -45,6 +46,7 @@ program.addCommand(createInitCommand()); program.addCommand(createSignCommand()); program.addCommand(createVerifyCommand()); program.addCommand(createTrustCommand()); +program.addCommand(createGraphCommand()); program.addCommand(createReputationCommand()); program.addCommand(createDiscoverCommand()); program.addCommand(createStatusCommand()); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 2ba1dad..e0309ab 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -168,6 +168,7 @@ describe('CLI Commands', () => { const { createAgentsCommand, createRegisterCommand } = await import('../src/commands/agents.js'); const { createApprovalCommand } = await import('../src/commands/approval.js'); const { createReputationCommand } = await import('../src/commands/reputation.js'); + const { createGraphCommand } = await import('../src/commands/graph.js'); const { createDhtCommand } = await import('../src/commands/dht.js'); const { createEvidenceCommand } = await import('../src/commands/evidence.js'); const { createAttestCommand } = await import('../src/commands/attest.js'); @@ -180,6 +181,7 @@ describe('CLI Commands', () => { expect(createAgentsCommand().name()).toBe('agents'); expect(createApprovalCommand().name()).toBe('approval'); expect(createReputationCommand().name()).toBe('reputation'); + expect(createGraphCommand().name()).toBe('graph'); expect(createDhtCommand().name()).toBe('dht'); expect(createEvidenceCommand().name()).toBe('evidence'); expect(createAttestCommand().name()).toBe('attest'); @@ -187,6 +189,38 @@ describe('CLI Commands', () => { expect(createSimulateCommand().name()).toBe('simulate'); expect(createInvokeCommand().name()).toBe('invoke'); }); + + it('graph inspect reads the local trust graph view without granting authority', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + agentId: 'did:fides:agent', + trust: [{ capability: 'invoice.reconcile', score: 0.73, band: 'medium' }], + authorityGranted: false, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createGraphCommand } = await import('../src/commands/graph.js'); + const cmd = createGraphCommand(); + + await cmd.parseAsync([ + 'inspect', + 'did:fides:agent', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://agentd.test/trust/did%3Afides%3Aagent', + expect.objectContaining({ method: 'GET' }) + ); + const output = JSON.parse(vi.mocked(console.log).mock.calls.at(-1)?.[0] as string); + expect(output.agentId).toBe('did:fides:agent'); + expect(output.authorityGranted).toBe(false); + expect(output.graphView.trust[0].capability).toBe('invoice.reconcile'); + }); }); describe('invoke command', () => { From 4618484a65f0a51c7d3df799a7b4813b983268f1 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:35:58 +0300 Subject: [PATCH 211/282] feat(sdk): add graph inspection facade --- docs/sdk-reference.md | 13 ++++++++++--- packages/sdk/README.md | 3 +++ packages/sdk/src/fides-client.ts | 14 ++++++++++++++ packages/sdk/src/index.ts | 1 + packages/sdk/test/fides-client.test.ts | 25 +++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 1f45be6..cc79dff 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -58,6 +58,10 @@ const trust = await client.trust.evaluate({ agentId: identity.identity.did, capability: 'invoice.reconcile', }) +const graph = await client.graph.inspect(identity.identity.did) +if (graph.authorityGranted !== false) { + throw new Error('Graph inspection must not grant authority') +} const reputation = await client.reputation.update({ agentId: identity.identity.did, capability: 'invoice.reconcile', @@ -208,9 +212,12 @@ successful provider responses remain available. The aggregate response keeps `authorityGranted: false`; provider orchestration is still discovery, not authority. Trust and reputation APIs return capability-scoped signals, and policy evaluation explains the decision but still requires session grant issuance -before invocation. Delegation helpers create local DelegationToken intents; the -daemon signs them when the delegator identity is locally managed, but they still -do not grant invocation authority without policy and a scoped SessionGrant. +before invocation. `client.graph.inspect(agentId)` reads the local trust graph +view through `GET /trust/:agentId` and wraps it as an inspection-only response +with `authorityGranted: false`; it is not an authorization surface. Delegation +helpers create local DelegationToken intents; the daemon signs them when the +delegator identity is locally managed, but they still do not grant invocation +authority without policy and a scoped SessionGrant. Session request and invocation helpers use the same root local daemon API. Session responses preserve `authorityMode` and `allowedActions`; full sessions return `authorityGranted: true`, while diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 077105d..a2dfda8 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -55,6 +55,8 @@ const trust = await client.trust.evaluate({ agentId: target.did, capability: 'invoice.reconcile', }) +const graph = await client.graph.inspect(target.did) +console.log(graph.authorityGranted) // false const policy = await client.policy.evaluate({ principalId: principal.did, @@ -119,6 +121,7 @@ const trust = await client.trust.evaluate({ agentId: identity.identity.did, capability: 'invoice.reconcile', }) +const graph = await client.graph.inspect(identity.identity.did) const reputation = await client.reputation.update({ agentId: identity.identity.did, capability: 'invoice.reconcile', diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 2ce2c9b..fc3c7e5 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -574,6 +574,12 @@ export interface FidesTrustListResponse { [key: string]: unknown } +export interface FidesGraphInspectionResponse { + agentId: string + authorityGranted: false + graphView: FidesTrustListResponse +} + export interface FidesReputationUpdateResponse { reputation: ReputationRecord authorityGranted: false @@ -768,6 +774,14 @@ export class FidesClient { ), } + readonly graph = { + inspect: async (agentId: string): Promise => ({ + agentId, + authorityGranted: false, + graphView: await this.get(`/trust/${encodeURIComponent(agentId)}`) as FidesTrustListResponse, + }), + } + readonly reputation = { update: (body: Record): Promise => ( this.post('/reputation/update', body) as Promise diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0145848..0b52bb4 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -149,6 +149,7 @@ export { type FidesDhtPublishRequest, type FidesInvocationRequest, type FidesInvocationResponse, + type FidesGraphInspectionResponse, type FidesPolicyDecision, type FidesPolicyDecisionAction, type FidesPolicyEvaluationResponse, diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index fa59622..73cf9d6 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -392,6 +392,7 @@ describe('FidesClient', () => { await client.demo.run() await client.simulate.adversarial() await client.invoke({ sessionId: 'sess_1', input: { invoiceId: 'inv_123' } }) + await client.graph.inspect('did:fides:agent') expect(calls.map(call => call.url)).toEqual([ 'http://localhost:4817/identities', @@ -458,6 +459,7 @@ describe('FidesClient', () => { 'http://localhost:4817/demo/run', 'http://localhost:4817/simulate/adversarial', 'http://localhost:4817/invoke', + 'http://localhost:4817/trust/did%3Afides%3Aagent', ]) expect(calls.map(call => call.init?.method)).toEqual([ 'POST', @@ -524,6 +526,7 @@ describe('FidesClient', () => { 'POST', 'POST', 'POST', + 'GET', ]) expect(JSON.parse(calls[7].init?.body as string)).toEqual({ capability: 'invoice.reconcile', @@ -545,6 +548,28 @@ describe('FidesClient', () => { }) }) + it('inspects the local trust graph view without granting authority', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + agentId: 'did:fides:agent', + trust: [{ capability: 'invoice.reconcile', score: 0.73, band: 'medium' }], + authorityGranted: false, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }))) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const graph = await client.graph.inspect('did:fides:agent') + + expect(graph.agentId).toBe('did:fides:agent') + expect(graph.authorityGranted).toBe(false) + expect(graph.graphView.trust[0]?.capability).toBe('invoice.reconcile') + expect(fetch).toHaveBeenCalledWith('http://localhost:7345/trust/did%3Afides%3Aagent', { + method: 'GET', + headers: new Headers(), + }) + }) + it('uses the root identity API served by local agentd', async () => { const calls: Array<{ url: string; init?: RequestInit }> = [] vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { From 3ff79c6711e66c43121d33d91da439992c9c3076 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:37:42 +0300 Subject: [PATCH 212/282] docs: document graph inspection workflow --- README.md | 1 + docs/getting-started.md | 4 +++- docs/status/fides-v2-implementation-status.md | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d88b155..2e0f9bc 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ agentd register did:fides:invoice-agent --agentd-url http://localhost:7345 agentd discover --capability invoice.reconcile --provider local --agentd-url http://localhost:7345 agentd trust did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 +agentd graph inspect did:fides:invoice-agent --agentd-url http://localhost:7345 agentd policy evaluate --agent did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 agentd session request did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 agentd invoke --session-id sess_... --input invoice.json --agentd-url http://localhost:7345 diff --git a/docs/getting-started.md b/docs/getting-started.md index c3d3aba..cea2a27 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -155,6 +155,7 @@ Trust is capability-specific. ```bash agentd trust did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 +agentd graph inspect did:fides:invoice-agent --agentd-url http://localhost:7345 ``` Reputation is also capability-specific and can be principal/publisher-aware. @@ -170,7 +171,8 @@ agentd reputation update \ agentd reputation get did:fides:invoice-agent --agentd-url http://localhost:7345 ``` -Trust and reputation are signals. Policy is the authority. +Trust graph inspection, trust, and reputation are signals. Policy is the +authority. ## Evaluate Policy diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 2f89ce5..2bc8f60 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -121,6 +121,7 @@ pnpm agentd card sign pnpm agentd register pnpm agentd discover --capability invoice.reconcile pnpm agentd trust --capability invoice.reconcile +pnpm agentd graph inspect pnpm agentd policy evaluate --agent --capability invoice.reconcile pnpm agentd session request --capability invoice.reconcile pnpm agentd invoke --session-id --input invoice.json From c4e64db567d4f3cd25e6dba0c33b8ca5a8a61467 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:41:44 +0300 Subject: [PATCH 213/282] feat(cli): add reputation capability shortcut --- docs/cli-reference.md | 8 +++-- docs/getting-started.md | 1 + docs/status/fides-v2-implementation-status.md | 1 + packages/cli/README.md | 1 + packages/cli/src/commands/reputation.ts | 30 +++++++++++++++++++ packages/cli/test/commands.test.ts | 18 ++++++++++- 6 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9e8fd54..ff9a698 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -71,6 +71,7 @@ agentd invoke --session-id sess_... --input invoice.json agentd invoke --dry-run did:fides:... --capability payments.prepare --input payment.json agentd trust did:fides:... --capability invoice.reconcile --agentd-url http://localhost:7345 agentd reputation update --agent did:fides:... --capability invoice.reconcile --successful-invocations 5 +agentd reputation did:fides:... --capability invoice.reconcile agentd reputation get did:fides:... agentd graph inspect did:fides:... --agentd-url http://localhost:7345 agentd policy evaluate --agent did:fides:... --capability invoice.reconcile --requested-scopes read:invoices --agentd-url http://localhost:7345 @@ -150,9 +151,10 @@ authority by this command. `trust --capability --agentd-url` evaluates root v2 capability-specific trust. Without `--agentd-url`, `trust` keeps the legacy -trust-attestation behavior. `reputation update/get` manages root v2 -capability-specific reputation records. Trust and reputation remain signals; -policy is the authority. +trust-attestation behavior. `reputation --capability` inspects +capability-specific reputation through the required short form, while +`reputation update/get` remains available for explicit mutation and unfiltered +listing. Trust and reputation remain signals; policy is the authority. `graph inspect ` reads the local `agentd` trust graph view for an agent candidate through `GET /trust/:agentId`. It is an inspection surface only; diff --git a/docs/getting-started.md b/docs/getting-started.md index cea2a27..13a55b6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -169,6 +169,7 @@ agentd reputation update \ --agentd-url http://localhost:7345 agentd reputation get did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd reputation did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 ``` Trust graph inspection, trust, and reputation are signals. Policy is the diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 2bc8f60..1672bb8 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -122,6 +122,7 @@ pnpm agentd register pnpm agentd discover --capability invoice.reconcile pnpm agentd trust --capability invoice.reconcile pnpm agentd graph inspect +pnpm agentd reputation --capability invoice.reconcile pnpm agentd policy evaluate --agent --capability invoice.reconcile pnpm agentd session request --capability invoice.reconcile pnpm agentd invoke --session-id --input invoice.json diff --git a/packages/cli/README.md b/packages/cli/README.md index cdec735..543afe1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -46,6 +46,7 @@ agentd dht find --capability invoice.reconcile --agentd-url http://localhost:734 agentd trust did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 agentd graph inspect did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd reputation did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 agentd policy evaluate --agent did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 agentd session request did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 agentd invoke --session-id sess_... --input invoice.json --agentd-url http://localhost:7345 diff --git a/packages/cli/src/commands/reputation.ts b/packages/cli/src/commands/reputation.ts index 3fa0130..89e56d9 100644 --- a/packages/cli/src/commands/reputation.ts +++ b/packages/cli/src/commands/reputation.ts @@ -4,6 +4,22 @@ import { getJson, postJson, printResult } from './authority-utils.js' export function createReputationCommand(): Command { const cmd = new Command('reputation') .description('Capability-specific reputation records') + .enablePositionalOptions() + .argument('[agent-id]', 'Agent DID to inspect with --capability') + .option('--capability ', 'Capability ID for shortcut inspection') + .option('--agentd-url ', 'agentd base URL', process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345') + .option('--json', 'Print JSON only') + .action(async (agentId, options) => { + if (!agentId) { + cmd.help() + return + } + if (!options.capability) { + throw new Error('--capability is required when using reputation ') + } + const result = await getJson(`${baseUrl(options.agentdUrl)}/reputation/${encodeURIComponent(agentId)}`) + printResult('Reputation records:', filterReputationResult(result, options.capability), options) + }) cmd.command('update') .description('Update root v2 capability-specific reputation') @@ -49,3 +65,17 @@ export function createReputationCommand(): Command { function baseUrl(url: string): string { return url.replace(/\/+$/, '') } + +function filterReputationResult(result: unknown, capability: string): unknown { + if (!result || typeof result !== 'object') return result + const record = result as { reputations?: unknown[]; authorityGranted?: unknown } + if (!Array.isArray(record.reputations)) return result + return { + ...record, + capability, + authorityGranted: record.authorityGranted === false ? false : record.authorityGranted, + reputations: record.reputations.filter((item) => { + return item && typeof item === 'object' && (item as { capability?: unknown }).capability === capability + }), + } +} diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index e0309ab..90de236 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -1550,7 +1550,10 @@ describe('CLI Commands', () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ trust: { agent_id: 'did:fides:agent', capability: 'invoice.reconcile' }, reputation: { agent_id: 'did:fides:agent', capability: 'invoice.reconcile' }, - reputations: [], + reputations: [ + { agent_id: 'did:fides:agent', capability: 'invoice.reconcile' }, + { agent_id: 'did:fides:agent', capability: 'calendar.schedule' }, + ], authorityGranted: false, }), { status: 200, @@ -1563,6 +1566,7 @@ describe('CLI Commands', () => { const trustEvaluate = createTrustCommand(); const trustGet = createTrustCommand(); const reputation = createReputationCommand(); + const reputationShortcut = createReputationCommand(); await trustEvaluate.parseAsync([ 'did:fides:agent', @@ -1590,6 +1594,14 @@ describe('CLI Commands', () => { '--json', ], { from: 'user' }); await reputation.parseAsync(['get', 'did:fides:agent', '--agentd-url', 'http://agentd.test/', '--json'], { from: 'user' }); + await reputationShortcut.parseAsync([ + 'did:fides:agent', + '--capability', + 'invoice.reconcile', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); expect(mockFetch).toHaveBeenNthCalledWith( 1, @@ -1618,6 +1630,10 @@ describe('CLI Commands', () => { }) ); expect(mockFetch).toHaveBeenNthCalledWith(4, 'http://agentd.test/reputation/did%3Afides%3Aagent', expect.objectContaining({ method: 'GET' })); + expect(mockFetch).toHaveBeenNthCalledWith(5, 'http://agentd.test/reputation/did%3Afides%3Aagent', expect.objectContaining({ method: 'GET' })); + const shortcutOutput = JSON.parse(vi.mocked(console.log).mock.calls.at(-1)?.[0] as string); + expect(shortcutOutput.capability).toBe('invoice.reconcile'); + expect(shortcutOutput.reputations[0].capability).toBe('invoice.reconcile'); }); it('policy evaluate can use the root v2 policy API', async () => { From 7478c8d3f88a33a71480b582fa984988954f62ae Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:45:26 +0300 Subject: [PATCH 214/282] feat(sdk): add reputation inspection facade --- docs/sdk-reference.md | 4 ++++ packages/sdk/README.md | 1 + packages/sdk/src/fides-client.ts | 19 +++++++++++++++++++ packages/sdk/test/fides-client.test.ts | 10 ++++++++++ 4 files changed, 34 insertions(+) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index cc79dff..83d653a 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -67,6 +67,10 @@ const reputation = await client.reputation.update({ capability: 'invoice.reconcile', successfulInvocations: 3, }) +const reputationSignal = await client.reputation.inspect(identity.identity.did, 'invoice.reconcile') +if (reputationSignal.authorityGranted !== false) { + throw new Error('Reputation inspection must not grant authority') +} const policy = await client.policy.evaluate({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a2dfda8..1952cd6 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -127,6 +127,7 @@ const reputation = await client.reputation.update({ capability: 'invoice.reconcile', successfulInvocations: 3, }) +const reputationSignal = await client.reputation.inspect(identity.identity.did, 'invoice.reconcile') const policy = await client.policy.evaluate({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index fc3c7e5..f9992c5 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -593,6 +593,14 @@ export interface FidesReputationListResponse { [key: string]: unknown } +export interface FidesReputationInspectionResponse { + agentId: string + capability: string + reputation: ReputationRecord | null + reputationSignals: ReputationRecord[] + authorityGranted: false +} + export interface FidesDelegationResponse { token: DelegationToken signed: boolean @@ -789,6 +797,17 @@ export class FidesClient { get: (agentId: string): Promise => ( this.get(`/reputation/${encodeURIComponent(agentId)}`) as Promise ), + inspect: async (agentId: string, capability: string): Promise => { + const result = await this.reputation.get(agentId) + const reputationSignals = result.reputations.filter((record) => record.capability === capability) + return { + agentId, + capability, + reputation: reputationSignals[0] ?? null, + reputationSignals, + authorityGranted: false, + } + }, } readonly policy = { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 73cf9d6..edc61f3 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -1029,6 +1029,16 @@ describe('FidesClient', () => { expect(reputationList.reputations[0]?.capability).toBe('invoice.reconcile') expect(reputationList.authorityGranted).toBe(false) + const reputationInspection = await client.reputation.inspect('did:fides:agent', 'invoice.reconcile') + expect(reputationInspection.reputation?.score).toBe(0.81) + expect(reputationInspection.reputationSignals).toHaveLength(1) + expect(reputationInspection.authorityGranted).toBe(false) + + const missingCapabilityInspection = await client.reputation.inspect('did:fides:agent', 'payments.execute') + expect(missingCapabilityInspection.reputation).toBeNull() + expect(missingCapabilityInspection.reputationSignals).toEqual([]) + expect(missingCapabilityInspection.authorityGranted).toBe(false) + const delegation = await client.delegations.create({ delegator: 'did:fides:principal', delegatee: 'did:fides:requester', From 3f7968ca20f63b350ac2de67f955d2fb71c00eb2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:47:12 +0300 Subject: [PATCH 215/282] feat(cli): default trust evaluation to local agentd --- README.md | 2 +- docs/cli-reference.md | 9 ++++---- docs/getting-started.md | 2 +- packages/cli/README.md | 2 +- packages/cli/src/commands/trust.ts | 17 ++++++++------- packages/cli/test/commands.test.ts | 34 ++++++++++++++++++++++++++++++ 6 files changed, 51 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2e0f9bc..60fd9c9 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ agentd card sign did:fides:invoice-agent --agentd-url http://localhost:7345 agentd register did:fides:invoice-agent --agentd-url http://localhost:7345 agentd discover --capability invoice.reconcile --provider local --agentd-url http://localhost:7345 -agentd trust did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 +agentd trust did:fides:invoice-agent --capability invoice.reconcile agentd graph inspect did:fides:invoice-agent --agentd-url http://localhost:7345 agentd policy evaluate --agent did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 agentd session request did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ff9a698..72c1480 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -69,7 +69,7 @@ agentd dht find --capability invoice.reconcile agentd invoke did:fides:... --capability invoice.reconcile --input invoice.json --requested-scopes invoice:read agentd invoke --session-id sess_... --input invoice.json agentd invoke --dry-run did:fides:... --capability payments.prepare --input payment.json -agentd trust did:fides:... --capability invoice.reconcile --agentd-url http://localhost:7345 +agentd trust did:fides:... --capability invoice.reconcile agentd reputation update --agent did:fides:... --capability invoice.reconcile --successful-invocations 5 agentd reputation did:fides:... --capability invoice.reconcile agentd reputation get did:fides:... @@ -149,9 +149,10 @@ Input defaults to `{}` and can be supplied with `--input` or `--input-json`. Use `--dry-run` to request dry-run execution; discovery is never treated as authority by this command. -`trust --capability --agentd-url` evaluates root v2 -capability-specific trust. Without `--agentd-url`, `trust` keeps the legacy -trust-attestation behavior. `reputation --capability` inspects +`trust --capability` evaluates root v2 capability-specific trust +through local agentd, defaulting to `http://localhost:7345` or +`FIDES_AGENTD_URL`. Without `--capability`, `trust` keeps the legacy +trust-attestation behavior unless `--agentd-url` is supplied. `reputation --capability` inspects capability-specific reputation through the required short form, while `reputation update/get` remains available for explicit mutation and unfiltered listing. Trust and reputation remain signals; policy is the authority. diff --git a/docs/getting-started.md b/docs/getting-started.md index 13a55b6..628a966 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -154,7 +154,7 @@ DHT records are signed pointers only. They are not trust sources. Trust is capability-specific. ```bash -agentd trust did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 +agentd trust did:fides:invoice-agent --capability invoice.reconcile agentd graph inspect did:fides:invoice-agent --agentd-url http://localhost:7345 ``` diff --git a/packages/cli/README.md b/packages/cli/README.md index 543afe1..ebe79b9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -44,7 +44,7 @@ agentd discover --capability invoice.reconcile --provider local --agentd-url htt agentd dht publish --capability invoice.reconcile --agent-id did:fides:invoice-agent --agentd-url http://localhost:7345 agentd dht find --capability invoice.reconcile --agentd-url http://localhost:7345 -agentd trust did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 +agentd trust did:fides:invoice-agent --capability invoice.reconcile agentd graph inspect did:fides:invoice-agent --agentd-url http://localhost:7345 agentd reputation did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 agentd policy evaluate --agent did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 diff --git a/packages/cli/src/commands/trust.ts b/packages/cli/src/commands/trust.ts index 116105e..7bee7a4 100644 --- a/packages/cli/src/commands/trust.ts +++ b/packages/cli/src/commands/trust.ts @@ -16,15 +16,16 @@ export function createTrustCommand(): Command { .option('--json', 'Print JSON only') .action(async (agentDid, options) => { try { + if (options.capability) { + const agentdUrl = options.agentdUrl ?? process.env.FIDES_AGENTD_URL ?? 'http://localhost:7345' + const result = await postJson(`${baseUrl(agentdUrl)}/trust/evaluate`, { + agentId: agentDid, + capability: options.capability, + }) + printResult('Trust result:', result, options) + return + } if (options.agentdUrl) { - if (options.capability) { - const result = await postJson(`${baseUrl(options.agentdUrl)}/trust/evaluate`, { - agentId: agentDid, - capability: options.capability, - }) - printResult('Trust result:', result, options) - return - } const result = await getJson(`${baseUrl(options.agentdUrl)}/trust/${encodeURIComponent(agentDid)}`) printResult('Trust results:', result, options) return diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 90de236..695a364 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -1636,6 +1636,40 @@ describe('CLI Commands', () => { expect(shortcutOutput.reputations[0].capability).toBe('invoice.reconcile'); }); + it('trust command defaults capability evaluation to the local agentd root API', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + trust: { agent_id: 'did:fides:agent', capability: 'invoice.reconcile' }, + authorityGranted: false, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createTrustCommand } = await import('../src/commands/trust.js'); + const trustEvaluate = createTrustCommand(); + + await trustEvaluate.parseAsync([ + 'did:fides:agent', + '--capability', + 'invoice.reconcile', + '--json', + ], { from: 'user' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:7345/trust/evaluate', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + agentId: 'did:fides:agent', + capability: 'invoice.reconcile', + }), + }) + ); + const output = JSON.parse(vi.mocked(console.log).mock.calls.at(-1)?.[0] as string); + expect(output.authorityGranted).toBe(false); + }); + it('policy evaluate can use the root v2 policy API', async () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ policy: { decision: 'allow' }, From 83ffa2c95c6cd8cb415244fb33623f41e3688b53 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:50:11 +0300 Subject: [PATCH 216/282] test(examples): typecheck example agents in verify --- README.md | 1 + docs/status/fides-v2-implementation-status.md | 1 + examples/README.md | 6 ++++++ package.json | 3 ++- 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 60fd9c9..058bcac 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ pnpm build | `pnpm test` | Run test suite | | `pnpm lint` | Lint codebase | | `pnpm typecheck` | Type-check TypeScript | +| `pnpm examples:typecheck` | Type-check example agents and demo manifests | | `pnpm dev` | Start services in watch mode | | `pnpm clean` | Clean build artifacts | | `pnpm demo` | Run the primitive-level v2 demo | diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 1672bb8..ea18a94 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -245,6 +245,7 @@ Recently verified commands: ```bash pnpm verify +pnpm examples:typecheck pnpm --filter @fides/sdk build pnpm --filter @fides/sdk test pnpm --filter @fides/cli lint diff --git a/examples/README.md b/examples/README.md index 9956068..d15325e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,6 +26,12 @@ pnpm demo npx tsx examples/demo.ts ``` +Type-check every example agent and demo contract: + +```bash +pnpm examples:typecheck +``` + ## Example Agents Each example is a self-contained script that demonstrates specific FIDES concepts. diff --git a/package.json b/package.json index 4b77819..4743123 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,10 @@ "test": "turbo run test", "lint": "turbo run lint", "typecheck": "turbo run typecheck", + "examples:typecheck": "tsc -p examples/tsconfig.json", "package:hygiene": "node scripts/check-package-hygiene.mjs", "package:packcheck": "node scripts/check-public-package-packs.mjs", - "verify": "pnpm package:hygiene && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm test", + "verify": "pnpm package:hygiene && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", "verify:quick": "pnpm lint && pnpm test", "ci:local": "pnpm verify", "dev": "turbo run dev --parallel", From 7bae173447f5a49814a09ba3c92cca7ef7929092 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:51:47 +0300 Subject: [PATCH 217/282] feat(sdk): add runtime attestation helper --- docs/sdk-reference.md | 2 +- packages/sdk/README.md | 2 +- packages/sdk/src/fides-client.ts | 12 ++++++++++++ packages/sdk/test/fides-client.test.ts | 10 ++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 83d653a..c85681f 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -120,7 +120,7 @@ const incident = await client.incidents.report({ description: 'Attempted invocation outside delegated authority.', }) await client.incidents.resolve(incident.record.id, { status: 'resolved' }) -const attestation = await client.attestations.create({ +const attestation = await client.attestations.runtime({ agentId: identity.identity.did, codeHash: `sha256:${'a'.repeat(64)}`, runtimeHash: `sha256:${'b'.repeat(64)}`, diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 1952cd6..d3c01af 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -171,7 +171,7 @@ const incident = await client.incidents.report({ description: 'Attempted invocation outside delegated authority.', }) await client.incidents.resolve(incident.record.id, { status: 'resolved' }) -const attestation = await client.attestations.create({ +const attestation = await client.attestations.runtime({ agentId: identity.identity.did, codeHash: `sha256:${'a'.repeat(64)}`, runtimeHash: `sha256:${'b'.repeat(64)}`, diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index f9992c5..4b53fdf 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -478,6 +478,15 @@ export interface FidesWalletAttestationRequest extends FidesIdentityAttestationR address: string } +export interface FidesRuntimeAttestationRequest { + agentId: string + codeHash: string + runtimeHash: string + policyHash: string + enclaveMeasurement?: string + expiresAt?: string +} + export interface FidesIdentityAttestation { id: string schema_version: 'fides.identity_attestation.v1' @@ -887,6 +896,9 @@ export class FidesClient { wallet: (body: FidesWalletAttestationRequest): Promise => ( this.post('/attestations', { type: 'wallet', ...body }) as Promise ), + runtime: (body: FidesRuntimeAttestationRequest): Promise => ( + this.post('/attestations', body) as Promise + ), get: (attestationId: string): Promise => ( this.get(`/attestations/${encodeURIComponent(attestationId)}`) as Promise ), diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index edc61f3..da71449 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -823,6 +823,16 @@ describe('FidesClient', () => { }) expect(issued.attestation.schema_version).toBe('fides.runtime_attestation.v1') + const runtime = await client.attestations.runtime({ + agentId: 'did:fides:agent', + codeHash: 'sha256:code', + runtimeHash: 'sha256:runtime', + policyHash: 'sha256:policy', + enclaveMeasurement: 'sha256:measurement', + }) + expect(runtime.attestation.provider).toBe('mock-tee') + expect(runtime.authorityGranted).toBe(false) + const fetched = await client.attestations.get('att_runtime_1') expect(fetched.attestation.provider).toBe('mock-tee') From bf9fa7ad2be1947b53605cf0561b00a7036c31a0 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 18:54:37 +0300 Subject: [PATCH 218/282] feat(sdk): type session authority requests --- docs/sdk-reference.md | 1 + packages/sdk/README.md | 1 + packages/sdk/src/fides-client.ts | 19 ++++++++++++++++++- packages/sdk/test/fides-client.test.ts | 24 +++++++++++++++++++++++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index c85681f..bd0b7b7 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -173,6 +173,7 @@ const session = await client.sessions.request({ agentId: identity.identity.did, capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], + audience: [identity.identity.did], }) if (session.authorityMode === 'dry_run_only' && session.allowedActions?.includes('dry_run')) { // Dry-run-only sessions are simulation authority, not execution authority. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d3c01af..8251ec3 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -184,6 +184,7 @@ const session = await client.sessions.request({ agentId: identity.identity.did, capability: 'invoice.reconcile', requestedScopes: ['invoice:read'], + audience: [identity.identity.did], }) if (session.authorityMode === 'dry_run_only' && session.allowedActions?.includes('dry_run')) { // Dry-run-only sessions are simulation authority, not execution authority. diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 4b53fdf..146fbe2 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -289,6 +289,23 @@ export interface FidesSignedInvocationRequest { issuedAt?: string } +export interface FidesSessionRequest { + agentId?: string + targetAgentId?: string + agent_id?: string + capability: string + capabilityId?: string + principalId?: string + requesterAgentId?: string + requestedScopes?: string[] + constraints?: Record + attestationId?: string + runtimeAttestationValid?: boolean + approvalGranted?: boolean + audience?: string[] + expiresAt?: string +} + export interface FidesEvidenceExportRequest { privacy_mode?: 'public' | 'private' | 'redacted' | 'hash_only' include_metadata?: boolean @@ -908,7 +925,7 @@ export class FidesClient { } readonly sessions = { - request: (body: Record): Promise => ( + request: (body: FidesSessionRequest): Promise => ( this.post('/sessions', body) as Promise ), verify: (sessionId: string): Promise => ( diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index da71449..94a4a5b 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -676,7 +676,9 @@ describe('FidesClient', () => { payload_hash: 'sha256:payload', } - vi.stubGlobal('fetch', vi.fn(async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) return new Response(JSON.stringify({ authorized: true, authorityGranted: false, @@ -695,6 +697,14 @@ describe('FidesClient', () => { const result = await client.sessions.request({ agentId: 'did:fides:agent', capability: 'payments.prepare', + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + requestedScopes: ['payments:prepare'], + constraints: { dryRunOnly: true }, + attestationId: 'att_runtime_1', + approvalGranted: true, + audience: ['did:fides:agent'], + expiresAt: '2026-05-30T01:00:00.000Z', }) expect(result.authorized).toBe(true) @@ -702,6 +712,18 @@ describe('FidesClient', () => { expect(result.authorityMode).toBe('dry_run_only') expect(result.allowedActions).toEqual(['dry_run']) expect(result.session.constraints).toEqual({ dryRunOnly: true }) + expect(JSON.parse(calls[0]?.init?.body as string)).toEqual({ + agentId: 'did:fides:agent', + capability: 'payments.prepare', + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + requestedScopes: ['payments:prepare'], + constraints: { dryRunOnly: true }, + attestationId: 'att_runtime_1', + approvalGranted: true, + audience: ['did:fides:agent'], + expiresAt: '2026-05-30T01:00:00.000Z', + }) }) it('adds identity trust-anchor attestations through promise helpers', async () => { From 326e2a6b7ea1dc63b0ec2da3f660585557f0b139 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 19:00:26 +0300 Subject: [PATCH 219/282] feat(sdk): type governance request contracts --- docs/sdk-reference.md | 5 +- packages/sdk/README.md | 5 +- packages/sdk/src/fides-client.ts | 186 +++++++++++++++++++++++-- packages/sdk/test/fides-client.test.ts | 185 ++++++++++++++++++++++++ 4 files changed, 367 insertions(+), 14 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index bd0b7b7..cf653ca 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -217,7 +217,10 @@ successful provider responses remain available. The aggregate response keeps `authorityGranted: false`; provider orchestration is still discovery, not authority. Trust and reputation APIs return capability-scoped signals, and policy evaluation explains the decision but still requires session grant issuance -before invocation. `client.graph.inspect(agentId)` reads the local trust graph +before invocation. The public facade exposes named TypeScript request +interfaces for policy evaluation, delegation, approvals, kill switch rules, +revocations, incidents, sessions, and evidence append inputs instead of opaque +object bags. `client.graph.inspect(agentId)` reads the local trust graph view through `GET /trust/:agentId` and wraps it as an inspection-only response with `authorityGranted: false`; it is not an authorization surface. Delegation helpers create local DelegationToken intents; the daemon signs them when the diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 8251ec3..e402bad 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -219,7 +219,10 @@ or permission. `client.discovery.allProviders()` queries local, well-known, registry, relay, DHT, and federation surfaces and preserves partial provider failures as `ok: false` results instead of granting authority or dropping successful candidates. Trust and reputation are capability-scoped signals; policy -decisions still require scoped session grants before invocation. +decisions still require scoped session grants before invocation. The public +facade exposes named TypeScript request interfaces for policy evaluation, +delegation, approvals, kill switch rules, revocations, incidents, sessions, and +evidence append inputs instead of opaque object bags. Root session and invocation helpers use the local daemon preflight path and are currently in-memory. Session responses preserve `authorityMode` and `allowedActions`; full sessions return `authorityGranted: true`, while diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 146fbe2..de6f70a 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -306,6 +306,168 @@ export interface FidesSessionRequest { expiresAt?: string } +export type FidesRiskLevel = 'low' | 'medium' | 'high' | 'critical' + +export interface FidesTrustEvaluationRequest { + agentId?: string + agent_id?: string + capability: string + principalId?: string + requesterAgentId?: string + context?: Record + evidenceRefs?: string[] +} + +export interface FidesReputationUpdateRequest { + agentId?: string + agent_id?: string + capability: string + successfulInvocations?: number + failedInvocations?: number + incidentCount?: number + publisherWeight?: number + contextBoundaryPenalty?: number + evidenceRefs?: string[] +} + +export interface FidesPolicyEvaluationRequest { + agentId?: string + targetAgentId?: string + agent_id?: string + capability: string + principalId?: string + requesterAgentId?: string + requestedScopes?: string[] + constraints?: Record + trustResult?: TrustResult + reputationResult?: ReputationRecord + runtimeAttestationValid?: boolean + attestationId?: string + revoked?: boolean + incidentsActive?: boolean + incidentCount?: number + killSwitchActive?: boolean + approvalGranted?: boolean + dryRun?: boolean + evidenceRefs?: string[] +} + +export interface FidesDelegationRequest { + delegator: string + delegatee: string + capabilities: string[] + constraints?: Record + expiresAt?: string + audience?: string[] + nonce?: string + signature?: string +} + +export interface FidesApprovalCreateRequest { + agentId?: string + targetAgentId?: string + capability: string + requesterAgentId?: string + principalId?: string + requestedScopes?: string[] + riskLevel?: FidesRiskLevel + policyDecisionHash?: string + evidenceRefs?: string[] + expiresAt?: string +} + +export interface FidesApprovalDecisionRequest { + approverId?: string + reason?: string + constraints?: Record + evidenceRefs?: string[] +} + +export type FidesKillSwitchTargetType = + | 'agent' + | 'publisher' + | 'capability' + | 'session' + | 'principal' + | 'risk_class' + +export interface FidesKillSwitchEnableRequest { + targetType: FidesKillSwitchTargetType + target: string + reason?: string + issuer?: string +} + +export type FidesRevocationTargetType = + | 'key' + | 'identity' + | 'agent' + | 'agent_card' + | 'capability' + | 'session' + | 'attestation' + | 'publisher' + +export interface FidesRevocationCreateRequest { + targetType: FidesRevocationTargetType + targetId: string + reason?: string + issuer?: string + evidenceRefs?: string[] +} + +export type FidesIncidentSeverity = 'low' | 'medium' | 'high' | 'critical' +export type FidesIncidentCategory = + | 'policy_violation' + | 'data_exfiltration' + | 'malicious_output' + | 'sandbox_escape' + | 'unauthorized_action' + | 'prompt_injection_failure' + | 'payment_error' + | 'suspicious_behavior' + +export interface FidesIncidentReportRequest { + targetAgentId: string + severity: FidesIncidentSeverity + category: FidesIncidentCategory + description: string + reporter?: string + evidenceRefs?: string[] + trustPenalty?: number + reputationPenalty?: number +} + +export interface FidesIncidentResolveRequest { + status: 'resolved' | 'dismissed' | 'false_positive' + reason?: string + resolver?: string + evidenceRefs?: string[] +} + +export interface FidesEvidenceAppendRequest { + type: FidesEvidenceEventType + actor: string + subject?: string + principal?: string + capability?: string + input?: unknown + inputHash?: string + input_hash?: string + output?: unknown + outputHash?: string + output_hash?: string + policy?: unknown + policyHash?: string + policy_hash?: string + decision?: string + riskLevel?: FidesRiskLevel + risk_level?: FidesRiskLevel + privacyMode?: FidesEvidencePrivacyMode + privacy_mode?: FidesEvidencePrivacyMode + metadata?: Record +} + export interface FidesEvidenceExportRequest { privacy_mode?: 'public' | 'private' | 'redacted' | 'hash_only' include_metadata?: boolean @@ -800,7 +962,7 @@ export class FidesClient { } readonly trust = { - evaluate: (body: Record): Promise => ( + evaluate: (body: FidesTrustEvaluationRequest): Promise => ( this.post('/trust/evaluate', body) as Promise ), get: (agentId: string): Promise => ( @@ -817,7 +979,7 @@ export class FidesClient { } readonly reputation = { - update: (body: Record): Promise => ( + update: (body: FidesReputationUpdateRequest): Promise => ( this.post('/reputation/update', body) as Promise ), get: (agentId: string): Promise => ( @@ -837,32 +999,32 @@ export class FidesClient { } readonly policy = { - evaluate: (body: Record): Promise => ( + evaluate: (body: FidesPolicyEvaluationRequest): Promise => ( this.post('/policy/evaluate', body) as Promise ), } readonly delegations = { - create: (body: Record): Promise => ( + create: (body: FidesDelegationRequest): Promise => ( this.post('/delegations', body) as Promise ), } readonly approvals = { - create: (body: Record): Promise => ( + create: (body: FidesApprovalCreateRequest): Promise => ( this.post('/approvals', body) as Promise ), list: (): Promise => this.get('/approvals') as Promise, - approve: (approvalId: string, body: Record = {}): Promise => ( + approve: (approvalId: string, body: FidesApprovalDecisionRequest = {}): Promise => ( this.post(`/approvals/${encodeURIComponent(approvalId)}/approve`, body) as Promise ), - deny: (approvalId: string, body: Record = {}): Promise => ( + deny: (approvalId: string, body: FidesApprovalDecisionRequest = {}): Promise => ( this.post(`/approvals/${encodeURIComponent(approvalId)}/deny`, body) as Promise ), } readonly killSwitch = { - enable: (body: Record): Promise => ( + enable: (body: FidesKillSwitchEnableRequest): Promise => ( this.post('/killswitch', body) as Promise ), list: (): Promise => this.get('/killswitch') as Promise, @@ -872,7 +1034,7 @@ export class FidesClient { } readonly revocations = { - create: (body: Record): Promise => ( + create: (body: FidesRevocationCreateRequest): Promise => ( this.post('/revocations', body) as Promise ), list: (): Promise => this.get('/revocations') as Promise, @@ -882,14 +1044,14 @@ export class FidesClient { } readonly incidents = { - report: (body: Record): Promise => ( + report: (body: FidesIncidentReportRequest): Promise => ( this.post('/incidents', body) as Promise ), list: (): Promise => this.get('/incidents') as Promise, get: (recordId: string): Promise => ( this.get(`/incidents/${encodeURIComponent(recordId)}`) as Promise ), - resolve: (recordId: string, body: Record = {}): Promise => ( + resolve: (recordId: string, body: FidesIncidentResolveRequest): Promise => ( this.post(`/incidents/${encodeURIComponent(recordId)}/resolve`, body) as Promise ), } @@ -982,7 +1144,7 @@ export class FidesClient { } readonly evidence = { - append: (body: Record): Promise => ( + append: (body: FidesEvidenceAppendRequest): Promise => ( this.post('/evidence', body) as Promise ), list: (): Promise => this.get('/evidence') as Promise, diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 94a4a5b..adcaa9c 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -188,6 +188,191 @@ describe('FidesClient', () => { expect(disabled.rule.enabled).toBe(false) }) + it('serializes typed governance and evidence request contracts', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) + return new Response(JSON.stringify({ + policy: { decision: 'allow' }, + token: { id: 'del_1' }, + approval: { id: 'appr_1' }, + decision: { id: 'apprd_1' }, + rule: { id: 'ks_1' }, + record: { id: 'rec_1' }, + event: { event_id: 'evt_1' }, + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + await client.policy.evaluate({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: 'did:fides:target', + capability: 'payments.prepare', + requestedScopes: ['payments:prepare'], + runtimeAttestationValid: false, + approvalGranted: true, + evidenceRefs: ['evt_policy_input'], + }) + await client.delegations.create({ + delegator: 'did:fides:principal', + delegatee: 'did:fides:requester', + capabilities: ['payments.prepare'], + constraints: { dryRunOnly: true }, + audience: ['did:fides:target'], + nonce: 'nonce_1', + }) + await client.approvals.create({ + targetAgentId: 'did:fides:target', + capability: 'payments.prepare', + requesterAgentId: 'did:fides:requester', + principalId: 'did:fides:principal', + requestedScopes: ['payments:prepare'], + riskLevel: 'high', + policyDecisionHash: 'sha256:policy', + }) + await client.approvals.approve('appr_1', { + approverId: 'did:fides:approver', + reason: 'approved for dry run', + constraints: { dryRunOnly: true }, + evidenceRefs: ['evt_approval'], + }) + await client.killSwitch.enable({ + targetType: 'risk_class', + target: 'critical', + issuer: 'did:fides:operator', + reason: 'incident response', + }) + await client.revocations.create({ + targetType: 'attestation', + targetId: 'att_1', + issuer: 'did:fides:operator', + reason: 'expired attestation', + evidenceRefs: ['evt_revocation'], + }) + await client.incidents.report({ + targetAgentId: 'did:fides:target', + reporter: 'did:fides:principal', + severity: 'critical', + category: 'sandbox_escape', + description: 'escaped policy sandbox', + trustPenalty: 0.5, + reputationPenalty: 0.4, + evidenceRefs: ['evt_incident'], + }) + await client.incidents.resolve('inc_1', { + status: 'resolved', + reason: 'patched', + resolver: 'did:fides:operator', + evidenceRefs: ['evt_resolution'], + }) + await client.evidence.append({ + type: 'policy.evaluated', + actor: 'did:fides:agentd', + subject: 'did:fides:target', + principal: 'did:fides:principal', + capability: 'payments.prepare', + input: { amount: 100 }, + output: { decision: 'require_approval' }, + policy: { decision: 'require_approval' }, + decision: 'require_approval', + riskLevel: 'high', + privacyMode: 'hash_only', + metadata: { source: 'sdk-test' }, + }) + + expect(calls.map(call => call.url)).toEqual([ + 'http://localhost:7345/policy/evaluate', + 'http://localhost:7345/delegations', + 'http://localhost:7345/approvals', + 'http://localhost:7345/approvals/appr_1/approve', + 'http://localhost:7345/killswitch', + 'http://localhost:7345/revocations', + 'http://localhost:7345/incidents', + 'http://localhost:7345/incidents/inc_1/resolve', + 'http://localhost:7345/evidence', + ]) + expect(calls.map(call => JSON.parse(call.init?.body as string))).toEqual([ + { + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: 'did:fides:target', + capability: 'payments.prepare', + requestedScopes: ['payments:prepare'], + runtimeAttestationValid: false, + approvalGranted: true, + evidenceRefs: ['evt_policy_input'], + }, + { + delegator: 'did:fides:principal', + delegatee: 'did:fides:requester', + capabilities: ['payments.prepare'], + constraints: { dryRunOnly: true }, + audience: ['did:fides:target'], + nonce: 'nonce_1', + }, + { + targetAgentId: 'did:fides:target', + capability: 'payments.prepare', + requesterAgentId: 'did:fides:requester', + principalId: 'did:fides:principal', + requestedScopes: ['payments:prepare'], + riskLevel: 'high', + policyDecisionHash: 'sha256:policy', + }, + { + approverId: 'did:fides:approver', + reason: 'approved for dry run', + constraints: { dryRunOnly: true }, + evidenceRefs: ['evt_approval'], + }, + { + targetType: 'risk_class', + target: 'critical', + issuer: 'did:fides:operator', + reason: 'incident response', + }, + { + targetType: 'attestation', + targetId: 'att_1', + issuer: 'did:fides:operator', + reason: 'expired attestation', + evidenceRefs: ['evt_revocation'], + }, + { + targetAgentId: 'did:fides:target', + reporter: 'did:fides:principal', + severity: 'critical', + category: 'sandbox_escape', + description: 'escaped policy sandbox', + trustPenalty: 0.5, + reputationPenalty: 0.4, + evidenceRefs: ['evt_incident'], + }, + { + status: 'resolved', + reason: 'patched', + resolver: 'did:fides:operator', + evidenceRefs: ['evt_resolution'], + }, + { + type: 'policy.evaluated', + actor: 'did:fides:agentd', + subject: 'did:fides:target', + principal: 'did:fides:principal', + capability: 'payments.prepare', + input: { amount: 100 }, + output: { decision: 'require_approval' }, + policy: { decision: 'require_approval' }, + decision: 'require_approval', + riskLevel: 'high', + privacyMode: 'hash_only', + metadata: { source: 'sdk-test' }, + }, + ]) + }) + it('types root revocation responses as authority overrides', async () => { const record = { schema_version: 'fides.revocation.record.v1', From 579a412b744e9f56768cfc02254bd26884986982 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 19:05:27 +0300 Subject: [PATCH 220/282] feat(cli): isolate agentd daemon state --- README.md | 2 +- docs/cli-reference.md | 3 +++ docs/getting-started.md | 7 ++++++ packages/cli/src/commands/daemon.ts | 13 +++++++++++ packages/cli/test/commands.test.ts | 36 ++++++++++++++++++++++++++++- 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 058bcac..20bdad7 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ pnpm --filter @fides/cli fides daemon status --agentd-url http://localhost:7345 pnpm --filter @fides/cli fides daemon stop ``` -`daemon start` launches the configured command in the background, writes a pid file to `~/.fides/agentd.pid`, and appends logs to `~/.fides/agentd.log`. Use `--command`, `--args`, `--pid-file`, and `--log-file` when running outside the pnpm workspace layout. +`daemon start` launches the configured command in the background, writes a pid file to `~/.fides/agentd.pid`, and appends logs to `~/.fides/agentd.log`. Use `--sqlite-path`, `--local-state memory`, and `--authority-store-path` to isolate demo state. Use `--command`, `--args`, `--pid-file`, and `--log-file` when running outside the pnpm workspace layout. For production agentd mutations through the CLI, export the same API key used by the service: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 72c1480..c0c04cb 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -212,3 +212,6 @@ metadata fields. `hash-only` is accepted as a CLI alias for `hash_only`. `daemon status` calls `GET /health` and prints upstream checks, the authority store, and the root v2 local state store. When SQLite local state is enabled, the status output includes the SQLite path used for the daemon snapshot. +`daemon start` accepts `--sqlite-path`, `--local-state memory|sqlite`, and +`--authority-store-path` so demo and manual DX runs can isolate local daemon +state instead of writing to the default `~/.fides` files. diff --git a/docs/getting-started.md b/docs/getting-started.md index 628a966..260bd65 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -56,6 +56,13 @@ Local daemon state is stored in `~/.fides/fides.sqlite` by default. Use `AGENTD_SQLITE_PATH=/path/to/fides.sqlite` for a specific database file or `AGENTD_LOCAL_STATE=memory` for an ephemeral run. +The CLI daemon launcher exposes the same controls: + +```bash +pnpm agentd daemon start --port 7345 --sqlite-path /tmp/fides-demo.sqlite +pnpm agentd daemon start --port 7345 --local-state memory +``` + ## Create Local Identities Create a principal, publisher, requester agent, and target agent. diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 31cc51c..58aac98 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -36,6 +36,9 @@ export function createDaemonCommand(): Command { cmd.command('start') .description('Start agentd') .option('--port ', 'Port to listen on', '7345') + .option('--sqlite-path ', 'SQLite path for root v2 local daemon state') + .option('--local-state ', 'Local daemon state mode: sqlite or memory') + .option('--authority-store-path ', 'File authority store path') .option('--command ', 'Command used to start agentd', 'pnpm') .option('--args ', 'Comma-separated command args', '--filter,@fides/agentd,dev') .option('--pid-file ', 'PID file path', AGENTD_PID_PATH) @@ -52,12 +55,16 @@ export function createDaemonCommand(): Command { ensureDir(options.logFile) const logFd = fs.openSync(options.logFile, 'a') const args = String(options.args).split(',').map(item => item.trim()).filter(Boolean) + const localState = normalizeLocalStateMode(options.localState) const child = spawn(options.command, args, { detached: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, AGENTD_PORT: String(options.port), + ...(options.sqlitePath && { AGENTD_SQLITE_PATH: String(options.sqlitePath) }), + ...(localState && { AGENTD_LOCAL_STATE: localState }), + ...(options.authorityStorePath && { AGENTD_STATE_STORE_PATH: String(options.authorityStorePath) }), }, }) if (!child.pid) { @@ -203,3 +210,9 @@ function readPid(pidFile: string): number | null { return null } } + +function normalizeLocalStateMode(mode?: string): 'sqlite' | 'memory' | undefined { + if (mode === undefined) return undefined + if (mode === 'sqlite' || mode === 'memory') return mode + throw new Error('--local-state must be sqlite or memory') +} diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 695a364..7e3e0b5 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -914,6 +914,12 @@ describe('CLI Commands', () => { 'start', '--port', '7444', + '--sqlite-path', + '/tmp/fides.sqlite', + '--local-state', + 'sqlite', + '--authority-store-path', + '/tmp/authority-store.json', '--pid-file', '/tmp/fides-agentd.pid', '--log-file', @@ -923,11 +929,39 @@ describe('CLI Commands', () => { expect(childProcess.spawn).toHaveBeenCalledWith('pnpm', ['--filter', '@fides/agentd', 'dev'], expect.objectContaining({ detached: true, stdio: ['ignore', 1, 1], - env: expect.objectContaining({ AGENTD_PORT: '7444' }), + env: expect.objectContaining({ + AGENTD_PORT: '7444', + AGENTD_SQLITE_PATH: '/tmp/fides.sqlite', + AGENTD_LOCAL_STATE: 'sqlite', + AGENTD_STATE_STORE_PATH: '/tmp/authority-store.json', + }), })); expect(fs.default.writeFileSync).toHaveBeenCalledWith('/tmp/fides-agentd.pid', '12345', 'utf-8'); }); + it('rejects invalid local daemon state modes before spawning agentd', async () => { + const fs = await import('node:fs'); + const childProcess = await import('node:child_process'); + vi.mocked(fs.default.existsSync).mockReturnValue(false); + + const { createDaemonCommand } = await import('../src/commands/daemon.js'); + const cmd = createDaemonCommand(); + + await cmd.parseAsync([ + 'start', + '--local-state', + 'file', + '--pid-file', + '/tmp/fides-agentd.pid', + '--log-file', + '/tmp/fides-agentd.log', + ], { from: 'user' }); + + expect(childProcess.spawn).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(console.error).toHaveBeenCalledWith('Error:', '--local-state must be sqlite or memory'); + }); + it('should stop agentd from the pid file', async () => { const fs = await import('node:fs'); vi.mocked(fs.default.existsSync).mockReturnValue(true); From ec489929e0ffec8b04584322e68882ec8356997e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 19:08:59 +0300 Subject: [PATCH 221/282] test(agentd): add local dx smoke --- README.md | 7 + docs/getting-started.md | 8 + docs/status/fides-v2-implementation-status.md | 4 + package.json | 1 + scripts/agentd-dx-smoke.ts | 139 ++++++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 scripts/agentd-dx-smoke.ts diff --git a/README.md b/README.md index 20bdad7..a089c9c 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,13 @@ pnpm --filter @fides/cli fides daemon stop `daemon start` launches the configured command in the background, writes a pid file to `~/.fides/agentd.pid`, and appends logs to `~/.fides/agentd.log`. Use `--sqlite-path`, `--local-state memory`, and `--authority-store-path` to isolate demo state. Use `--command`, `--args`, `--pid-file`, and `--log-file` when running outside the pnpm workspace layout. +To smoke test the actual local daemon plus CLI demo/simulation path with +isolated state: + +```bash +pnpm smoke:agentd +``` + For production agentd mutations through the CLI, export the same API key used by the service: ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index 260bd65..b160705 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -63,6 +63,14 @@ pnpm agentd daemon start --port 7345 --sqlite-path /tmp/fides-demo.sqlite pnpm agentd daemon start --port 7345 --local-state memory ``` +For a repeatable local DX check that starts an isolated daemon, runs the full +demo, runs the adversarial simulation, and verifies the main authority +invariants: + +```bash +pnpm smoke:agentd +``` + ## Create Local Identities Create a principal, publisher, requester agent, and target agent. diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index ea18a94..004fa88 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -252,11 +252,15 @@ pnpm --filter @fides/cli lint pnpm --filter @fides/agentd test pnpm --filter @fides/cli build pnpm package:hygiene +pnpm smoke:agentd ``` Manual DX smoke: ```bash +pnpm smoke:agentd + +# Equivalent manual flow: AGENTD_LOCAL_STATE=memory AGENTD_PORT=7486 pnpm agentd:dev pnpm --silent agentd demo run --agentd-url http://localhost:7486 --json pnpm --silent agentd simulate adversarial --agentd-url http://localhost:7486 --json diff --git a/package.json b/package.json index 4743123..d04b072 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "demo": "tsx examples/demo.ts", "demo:authority": "tsx scripts/authority-path-demo.ts", "demo:authority:processes": "tsx scripts/multiprocess-authority-demo.ts", + "smoke:agentd": "tsx scripts/agentd-dx-smoke.ts", "smoke:docker": "tsx scripts/docker-compose-smoke.ts" }, "dependencies": { diff --git a/scripts/agentd-dx-smoke.ts b/scripts/agentd-dx-smoke.ts new file mode 100644 index 0000000..a02feca --- /dev/null +++ b/scripts/agentd-dx-smoke.ts @@ -0,0 +1,139 @@ +import { spawn, execFile } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +const port = process.env.AGENTD_DX_SMOKE_PORT ?? '4819' +const baseUrl = `http://127.0.0.1:${port}` + +async function main() { + const workdir = await mkdtemp(join(tmpdir(), 'fides-agentd-dx-')) + const sqlitePath = join(workdir, 'fides.sqlite') + const authorityStorePath = join(workdir, 'authority-store.json') + + const agentd = spawn('pnpm', ['--filter', '@fides/agentd', 'dev'], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + AGENTD_PORT: port, + AGENTD_SQLITE_PATH: sqlitePath, + AGENTD_STATE_STORE_PATH: authorityStorePath, + }, + }) + + const logs: string[] = [] + agentd.stdout.on('data', (chunk) => logs.push(String(chunk))) + agentd.stderr.on('data', (chunk) => logs.push(String(chunk))) + + try { + await waitForAgentd(sqlitePath, authorityStorePath) + console.log(`ok agentd reachable at ${baseUrl}`) + + const demo = await runAgentdJson(['demo', 'run', '--json']) + assert(demo.status === 'executed', `demo status was not executed: ${JSON.stringify(demo.status)}`) + assert(demo.authority?.discoveryGrantsAuthority === false, 'demo must keep discovery non-authoritative') + assert(demo.authority?.policyBeforeExecution === true, 'demo must enforce policy before execution') + assert(demo.verification?.evidenceHashChainValid === true, 'demo evidence hash chain must verify') + console.log('ok agentd demo run') + + const simulation = await runAgentdJson(['simulate', 'adversarial', '--json']) + assert(simulation.status === 'detected', `simulation status was not detected: ${JSON.stringify(simulation.status)}`) + const detections = new Set(Array.isArray(simulation.detections) ? simulation.detections : []) + for (const expected of [ + 'fake_agent', + 'malicious_dht_pointer', + 'tampered_agent_card', + 'revoked_agent', + 'broken_evidence_chain', + ]) { + assert(detections.has(expected), `simulation did not detect ${expected}`) + } + assert(simulation.authority?.discoveryGrantsAuthority === false, 'simulation must keep discovery non-authoritative') + assert(simulation.evidence?.rootChainValid === true, 'simulation root evidence chain must verify') + assert(simulation.evidence?.brokenEvidenceChainValid === false, 'simulation must detect broken evidence chain') + console.log('ok adversarial simulation') + + console.log('agentd dx smoke complete') + } finally { + agentd.kill('SIGTERM') + await onceExit(agentd) + await rm(workdir, { recursive: true, force: true }) + } + + function printAgentdLogs() { + const output = logs.join('').trim() + if (output) { + console.error(output.split('\n').slice(-40).join('\n')) + } + } + + async function waitForAgentd(expectedSqlitePath: string, expectedAuthorityStorePath: string) { + const startedAt = Date.now() + let lastError = '' + while (Date.now() - startedAt < 20_000) { + try { + const response = await fetch(`${baseUrl}/health`) + const health = await response.json() as { + service?: string + authorityStore?: { ok?: boolean; detail?: string } + localStateStore?: { ok?: boolean; path?: string } + } + if ( + health.service === 'agentd' && + health.authorityStore?.ok === true && + health.authorityStore.detail === expectedAuthorityStorePath && + health.localStateStore?.path === expectedSqlitePath + ) { + return + } + lastError = JSON.stringify(health) + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + } + await sleep(250) + } + printAgentdLogs() + throw new Error(`agentd did not become ready with isolated state: ${lastError}`) + } +} + +async function runAgentdJson(args: string[]): Promise> { + const { stdout } = await execFileAsync('pnpm', ['--silent', 'agentd', ...args], { + env: { + ...process.env, + FIDES_AGENTD_URL: baseUrl, + }, + maxBuffer: 20 * 1024 * 1024, + }) + return JSON.parse(stdout) as Record +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function onceExit(child: ReturnType): Promise { + if (child.exitCode !== null) return Promise.resolve() + return new Promise((resolve) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL') + resolve() + }, 2_000) + child.once('exit', () => { + clearTimeout(timeout) + resolve() + }) + }) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) From 050003207e9af5b52996e15c77e3aba181bee8ea Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 20:46:26 +0300 Subject: [PATCH 222/282] test(api): audit agentd openapi routes --- README.md | 7 ++ docs/getting-started.md | 2 + docs/status/fides-v2-implementation-status.md | 1 + package.json | 3 +- scripts/audit-agentd-api.mjs | 109 ++++++++++++++++++ 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 scripts/audit-agentd-api.mjs diff --git a/README.md b/README.md index a089c9c..d4ed42c 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,13 @@ isolated state: pnpm smoke:agentd ``` +To verify that the documented agentd OpenAPI surface matches the routes +implemented by the local daemon: + +```bash +pnpm api:audit +``` + For production agentd mutations through the CLI, export the same API key used by the service: ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index b160705..d7c706f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -366,6 +366,8 @@ pnpm --filter @fides/sdk test pnpm --filter @fides/sdk build pnpm --filter @fides/sdk lint pnpm --filter @fides/agentd test +pnpm api:audit +pnpm smoke:agentd ``` ## Next Steps diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 004fa88..6516f9d 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -252,6 +252,7 @@ pnpm --filter @fides/cli lint pnpm --filter @fides/agentd test pnpm --filter @fides/cli build pnpm package:hygiene +pnpm api:audit pnpm smoke:agentd ``` diff --git a/package.json b/package.json index d04b072..a218a15 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,10 @@ "lint": "turbo run lint", "typecheck": "turbo run typecheck", "examples:typecheck": "tsc -p examples/tsconfig.json", + "api:audit": "node scripts/audit-agentd-api.mjs", "package:hygiene": "node scripts/check-package-hygiene.mjs", "package:packcheck": "node scripts/check-public-package-packs.mjs", - "verify": "pnpm package:hygiene && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", + "verify": "pnpm package:hygiene && pnpm api:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", "verify:quick": "pnpm lint && pnpm test", "ci:local": "pnpm verify", "dev": "turbo run dev --parallel", diff --git a/scripts/audit-agentd-api.mjs b/scripts/audit-agentd-api.mjs new file mode 100644 index 0000000..dfb5e01 --- /dev/null +++ b/scripts/audit-agentd-api.mjs @@ -0,0 +1,109 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const agentdSourcePath = join(root, 'services/agentd/src/index.ts') +const agentdOpenApiPath = join(root, 'docs/api/agentd.yaml') + +const source = readFileSync(agentdSourcePath, 'utf8') +const openApi = readFileSync(agentdOpenApiPath, 'utf8') + +const routeAliases = new Map([ + ['GET /.well-known/agents/*', ['GET /.well-known/agents/{param}.json']], +]) + +const documentedRoutes = parseOpenApiRoutes(openApi) +const implementedRoutes = parseImplementedRoutes(source) + +const documented = new Set(documentedRoutes.map(normalizeRoute)) +const implemented = new Set(expandAliases(implementedRoutes).map(normalizeRoute)) + +const missingFromDocs = [...implemented].filter(route => !documented.has(route)).sort() +const missingFromImplementation = [...documented].filter(route => !implemented.has(route)).sort() + +const routeOrderErrors = checkRouteOrdering(source) +const errors = [ + ...missingFromDocs.map(route => `implemented route is missing from docs/api/agentd.yaml: ${route}`), + ...missingFromImplementation.map(route => `documented route is missing from services/agentd/src/index.ts: ${route}`), + ...routeOrderErrors, +] + +if (errors.length > 0) { + console.error('agentd API audit failed:') + for (const error of errors) { + console.error(`- ${error}`) + } + process.exit(1) +} + +console.log(`agentd API audit passed for ${implemented.size} documented routes.`) + +function parseOpenApiRoutes(contents) { + const routes = [] + let currentPath = null + for (const line of contents.split('\n')) { + const pathMatch = line.match(/^ (\/[^:]+):$/) + if (pathMatch) { + currentPath = pathMatch[1] + continue + } + + const methodMatch = line.match(/^ (get|post|put|delete|patch):$/) + if (methodMatch && currentPath) { + routes.push(`${methodMatch[1].toUpperCase()} ${currentPath}`) + } + } + return routes +} + +function parseImplementedRoutes(contents) { + const routes = [] + const routePattern = /^app\.(get|post|put|delete|patch)\('([^']+)'/gm + for (const match of contents.matchAll(routePattern)) { + const method = match[1].toUpperCase() + const path = match[2] + if (path === '*') continue + routes.push(`${method} ${path}`) + } + return routes +} + +function expandAliases(routes) { + const expanded = [] + for (const route of routes) { + const aliases = routeAliases.get(route) + if (aliases) { + expanded.push(...aliases) + } else { + expanded.push(route) + } + } + return expanded +} + +function normalizeRoute(route) { + const [method, rawPath] = route.split(' ') + const path = rawPath + .replace(/:[^/]+/g, '{param}') + .replace(/\{[^/}]+\}/g, '{param}') + return `${method} ${path}` +} + +function checkRouteOrdering(contents) { + const errors = [] + const domainVerifyIndex = contents.indexOf("app.get('/v1/identities/domain/verify'") + const didIdentityIndex = contents.indexOf("app.get('/v1/identities/:did'") + + if (domainVerifyIndex === -1) { + errors.push('implemented route missing static domain verification route: GET /v1/identities/domain/verify') + } + if (didIdentityIndex === -1) { + errors.push('implemented route missing parameterized identity route: GET /v1/identities/{param}') + } + if (domainVerifyIndex !== -1 && didIdentityIndex !== -1 && domainVerifyIndex > didIdentityIndex) { + errors.push('GET /v1/identities/domain/verify must be registered before GET /v1/identities/{param}') + } + + return errors +} From e35d5ea8fc0c7332d14a1734b03b98d73ccc932c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 20:47:39 +0300 Subject: [PATCH 223/282] test(agentd): smoke all-provider discovery --- scripts/agentd-dx-smoke.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/scripts/agentd-dx-smoke.ts b/scripts/agentd-dx-smoke.ts index a02feca..eaa723c 100644 --- a/scripts/agentd-dx-smoke.ts +++ b/scripts/agentd-dx-smoke.ts @@ -39,6 +39,20 @@ async function main() { assert(demo.verification?.evidenceHashChainValid === true, 'demo evidence hash chain must verify') console.log('ok agentd demo run') + const discovery = await runAgentdJson(['discover', '--capability', 'invoice.reconcile', '--all-providers', '--json']) + assert(discovery.authorityGranted === false, 'all-provider discovery must not grant authority') + assertNoAuthorityGrantedTrue(discovery, 'all-provider discovery response') + const discoveryResults = Array.isArray(discovery.results) ? discovery.results : [] + const providers = new Set(discoveryResults.map((entry: Record) => entry.provider)) + for (const expected of ['local', 'well-known', 'registry', 'relay', 'dht', 'federation']) { + assert(providers.has(expected), `all-provider discovery did not query ${expected}`) + } + assert( + discoveryResults.some((entry: Record) => entry.provider === 'local' && entry.ok === true), + 'all-provider discovery did not return a successful local provider result', + ) + console.log('ok all-provider discovery') + const simulation = await runAgentdJson(['simulate', 'adversarial', '--json']) assert(simulation.status === 'detected', `simulation status was not detected: ${JSON.stringify(simulation.status)}`) const detections = new Set(Array.isArray(simulation.detections) ? simulation.detections : []) @@ -115,6 +129,20 @@ function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message) } +function assertNoAuthorityGrantedTrue(value: unknown, label: string): void { + if (!value || typeof value !== 'object') return + if (Array.isArray(value)) { + for (const item of value) { + assertNoAuthorityGrantedTrue(item, label) + } + return + } + for (const [key, nested] of Object.entries(value)) { + assert(!(key === 'authorityGranted' && nested === true), `${label} included authorityGranted: true`) + assertNoAuthorityGrantedTrue(nested, label) + } +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } From 8199a2e6482e4c3f0c8470528fcb60389cc07346 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 20:48:12 +0300 Subject: [PATCH 224/282] docs: record all-provider discovery smoke --- docs/status/fides-v2-implementation-status.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 6516f9d..33d9e4e 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -264,6 +264,7 @@ pnpm smoke:agentd # Equivalent manual flow: AGENTD_LOCAL_STATE=memory AGENTD_PORT=7486 pnpm agentd:dev pnpm --silent agentd demo run --agentd-url http://localhost:7486 --json +pnpm --silent agentd discover --capability invoice.reconcile --all-providers --agentd-url http://localhost:7486 --json pnpm --silent agentd simulate adversarial --agentd-url http://localhost:7486 --json ``` @@ -274,6 +275,8 @@ Observed manual smoke results: - demo returned `evidenceHashChainValid: true`. - demo returned `discoveryGrantsAuthority: false`. - demo returned `payments: "dry_run_only"`. +- all-provider discovery queried local, well-known, registry, relay, DHT, and federation providers. +- all-provider discovery returned `authorityGranted: false`. - adversarial simulation returned `status: "detected"`. - adversarial simulation detected 10 scenarios. - adversarial simulation returned `rootChainValid: true`. From 216730957eb235bcb9a2375da74f74743200cb19 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 20:51:34 +0300 Subject: [PATCH 225/282] test(api): audit documented endpoint summaries --- scripts/audit-agentd-api.mjs | 55 +++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/scripts/audit-agentd-api.mjs b/scripts/audit-agentd-api.mjs index dfb5e01..fa47160 100644 --- a/scripts/audit-agentd-api.mjs +++ b/scripts/audit-agentd-api.mjs @@ -5,9 +5,13 @@ import { fileURLToPath } from 'node:url' const root = dirname(dirname(fileURLToPath(import.meta.url))) const agentdSourcePath = join(root, 'services/agentd/src/index.ts') const agentdOpenApiPath = join(root, 'docs/api/agentd.yaml') +const statusDocPath = join(root, 'docs/status/fides-v2-implementation-status.md') +const apiReferencePath = join(root, 'docs/api-reference.md') const source = readFileSync(agentdSourcePath, 'utf8') const openApi = readFileSync(agentdOpenApiPath, 'utf8') +const statusDoc = readFileSync(statusDocPath, 'utf8') +const apiReference = readFileSync(apiReferencePath, 'utf8') const routeAliases = new Map([ ['GET /.well-known/agents/*', ['GET /.well-known/agents/{param}.json']], @@ -23,10 +27,15 @@ const missingFromDocs = [...implemented].filter(route => !documented.has(route)) const missingFromImplementation = [...documented].filter(route => !implemented.has(route)).sort() const routeOrderErrors = checkRouteOrdering(source) +const markdownErrors = [ + ...checkStatusRootApiOverview(documented, statusDoc), + ...checkMarkdownRoutesExist(documented, apiReference, 'docs/api-reference.md'), +] const errors = [ ...missingFromDocs.map(route => `implemented route is missing from docs/api/agentd.yaml: ${route}`), ...missingFromImplementation.map(route => `documented route is missing from services/agentd/src/index.ts: ${route}`), ...routeOrderErrors, + ...markdownErrors, ] if (errors.length > 0) { @@ -85,11 +94,55 @@ function expandAliases(routes) { function normalizeRoute(route) { const [method, rawPath] = route.split(' ') const path = rawPath - .replace(/:[^/]+/g, '{param}') + .replace(/:([A-Za-z0-9_]+)/g, '{param}') .replace(/\{[^/}]+\}/g, '{param}') return `${method} ${path}` } +function checkStatusRootApiOverview(openApiRoutes, contents) { + const section = extractSection(contents, 'Primary root v2 API:', 'See `docs/api/agentd.yaml`') + const markdownRoutes = new Set(parseMarkdownRoutes(section).map(normalizeRoute)) + const expectedRoutes = [...openApiRoutes] + .filter(route => { + const path = route.split(' ')[1] + return !path.startsWith('/v1/') && path !== '/metrics' + }) + .sort() + + const missing = expectedRoutes.filter(route => !markdownRoutes.has(route)) + const extra = [...markdownRoutes].filter(route => !expectedRoutes.includes(route)).sort() + + return [ + ...missing.map(route => `docs/status/fides-v2-implementation-status.md Primary root v2 API is missing ${route}`), + ...extra.map(route => `docs/status/fides-v2-implementation-status.md Primary root v2 API lists non-root or undocumented route ${route}`), + ] +} + +function checkMarkdownRoutesExist(openApiRoutes, contents, label) { + const markdownRoutes = parseMarkdownRoutes(contents).map(normalizeRoute) + return markdownRoutes + .filter(route => !openApiRoutes.has(route)) + .sort() + .map(route => `${label} lists route missing from docs/api/agentd.yaml: ${route}`) +} + +function parseMarkdownRoutes(contents) { + const routes = [] + const routePattern = /^- `((GET|POST|PUT|DELETE|PATCH) [^`]+)`$/gm + for (const match of contents.matchAll(routePattern)) { + routes.push(match[1]) + } + return routes +} + +function extractSection(contents, startMarker, endMarker) { + const start = contents.indexOf(startMarker) + if (start === -1) return '' + const end = contents.indexOf(endMarker, start) + if (end === -1) return contents.slice(start) + return contents.slice(start, end) +} + function checkRouteOrdering(contents) { const errors = [] const domainVerifyIndex = contents.indexOf("app.get('/v1/identities/domain/verify'") From 9c2cbb37d8dc0d599917e885468dde0725955a10 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 20:55:10 +0300 Subject: [PATCH 226/282] test(sdk): cover root agentd endpoint helpers --- packages/sdk/test/fides-client.test.ts | 224 ++++++++++--------------- 1 file changed, 86 insertions(+), 138 deletions(-) diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index adcaa9c..4135cd3 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -501,9 +501,15 @@ describe('FidesClient', () => { const client = new FidesClient({ daemonUrl: 'http://localhost:4817' }) await client.identity.createAgent() + await client.identity.list() + await client.identity.show('did:fides:agent') await client.cards.create({ name: 'Invoice Agent', capabilities: [] }) await client.cards.sign({ id: 'card_1' }) + await client.cards.verify('card_1') + await client.cards.get('card_1') await client.agents.register({ id: 'card_1' }) + await client.agents.list() + await client.agents.inspect('did:fides:agent') await client.discovery.find({ capability: 'invoice.reconcile' }) await client.discovery.local({ capability: 'invoice.reconcile' }) await client.discovery.wellKnown({ capability: 'invoice.reconcile' }) @@ -579,155 +585,97 @@ describe('FidesClient', () => { await client.invoke({ sessionId: 'sess_1', input: { invoiceId: 'inv_123' } }) await client.graph.inspect('did:fides:agent') - expect(calls.map(call => call.url)).toEqual([ - 'http://localhost:4817/identities', - 'http://localhost:4817/agent-cards', - 'http://localhost:4817/agent-cards/card_1/sign', - 'http://localhost:4817/agents/register', - 'http://localhost:4817/discover', - 'http://localhost:4817/discover/local', - 'http://localhost:4817/discover/well-known', - 'http://localhost:4817/discover/registry', - 'http://localhost:4817/discover/relay', - 'http://localhost:4817/discover/dht', - 'http://localhost:4817/discover/federation', - 'http://localhost:4817/discover/local', - 'http://localhost:4817/discover/well-known', - 'http://localhost:4817/discover/registry', - 'http://localhost:4817/discover/relay', - 'http://localhost:4817/discover/dht', - 'http://localhost:4817/discover/federation', - 'http://localhost:4817/trust/evaluate', - 'http://localhost:4817/trust/did%3Afides%3Aagent', - 'http://localhost:4817/reputation/update', - 'http://localhost:4817/reputation/did%3Afides%3Aagent', - 'http://localhost:4817/policy/evaluate', - 'http://localhost:4817/delegations', - 'http://localhost:4817/approvals', - 'http://localhost:4817/approvals', - 'http://localhost:4817/approvals/approval_1/approve', - 'http://localhost:4817/approvals/approval_1/deny', - 'http://localhost:4817/killswitch', - 'http://localhost:4817/killswitch', - 'http://localhost:4817/killswitch/rule_1', - 'http://localhost:4817/revocations', - 'http://localhost:4817/revocations', - 'http://localhost:4817/revocations/rev_1', - 'http://localhost:4817/incidents', - 'http://localhost:4817/incidents', - 'http://localhost:4817/incidents/inc_1', - 'http://localhost:4817/incidents/inc_1/resolve', - 'http://localhost:4817/attestations', - 'http://localhost:4817/attestations/att_1', - 'http://localhost:4817/attestations/att_1/verify', - 'http://localhost:4817/sessions', - 'http://localhost:4817/sessions/sess_1', - 'http://localhost:4817/sessions/sess_1/verify', - 'http://localhost:4817/registry/start', - 'http://localhost:4817/registry/publish', - 'http://localhost:4817/registry/search', - 'http://localhost:4817/registry/index', - 'http://localhost:4817/relay/start', - 'http://localhost:4817/relay/register', - 'http://localhost:4817/relay/discover', - 'http://localhost:4817/dht/start', - 'http://localhost:4817/dht/publish', - 'http://localhost:4817/dht/find', - 'http://localhost:4817/.well-known/fides.json', - 'http://localhost:4817/.well-known/agents.json', - 'http://localhost:4817/.well-known/agents/did%3Afides%3Aagent.json', - 'http://localhost:4817/evidence', - 'http://localhost:4817/evidence', - 'http://localhost:4817/evidence/evt_1', - 'http://localhost:4817/evidence/verify', - 'http://localhost:4817/evidence/export', - 'http://localhost:4817/demo/run', - 'http://localhost:4817/simulate/adversarial', - 'http://localhost:4817/invoke', - 'http://localhost:4817/trust/did%3Afides%3Aagent', - ]) - expect(calls.map(call => call.init?.method)).toEqual([ - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'GET', - 'POST', - 'GET', - 'POST', - 'POST', - 'POST', - 'GET', - 'POST', - 'POST', - 'POST', - 'GET', - 'DELETE', - 'POST', - 'GET', - 'GET', - 'POST', - 'GET', - 'GET', - 'POST', - 'POST', - 'GET', - 'POST', - 'POST', - 'GET', - 'POST', - 'POST', - 'POST', - 'POST', - 'GET', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'GET', - 'GET', - 'GET', - 'POST', - 'GET', - 'GET', - 'POST', - 'POST', - 'POST', - 'POST', - 'POST', - 'GET', - ]) - expect(JSON.parse(calls[7].init?.body as string)).toEqual({ + const expectedCalls = [ + ['POST', 'http://localhost:4817/identities'], + ['GET', 'http://localhost:4817/identities'], + ['GET', 'http://localhost:4817/identities/did%3Afides%3Aagent'], + ['POST', 'http://localhost:4817/agent-cards'], + ['POST', 'http://localhost:4817/agent-cards/card_1/sign'], + ['POST', 'http://localhost:4817/agent-cards/card_1/verify'], + ['GET', 'http://localhost:4817/agent-cards/card_1'], + ['POST', 'http://localhost:4817/agents/register'], + ['GET', 'http://localhost:4817/agents'], + ['GET', 'http://localhost:4817/agents/did%3Afides%3Aagent'], + ['POST', 'http://localhost:4817/discover'], + ['POST', 'http://localhost:4817/discover/local'], + ['POST', 'http://localhost:4817/discover/well-known'], + ['POST', 'http://localhost:4817/discover/registry'], + ['POST', 'http://localhost:4817/discover/relay'], + ['POST', 'http://localhost:4817/discover/dht'], + ['POST', 'http://localhost:4817/discover/federation'], + ['POST', 'http://localhost:4817/discover/local'], + ['POST', 'http://localhost:4817/discover/well-known'], + ['POST', 'http://localhost:4817/discover/registry'], + ['POST', 'http://localhost:4817/discover/relay'], + ['POST', 'http://localhost:4817/discover/dht'], + ['POST', 'http://localhost:4817/discover/federation'], + ['POST', 'http://localhost:4817/trust/evaluate'], + ['GET', 'http://localhost:4817/trust/did%3Afides%3Aagent'], + ['POST', 'http://localhost:4817/reputation/update'], + ['GET', 'http://localhost:4817/reputation/did%3Afides%3Aagent'], + ['POST', 'http://localhost:4817/policy/evaluate'], + ['POST', 'http://localhost:4817/delegations'], + ['POST', 'http://localhost:4817/approvals'], + ['GET', 'http://localhost:4817/approvals'], + ['POST', 'http://localhost:4817/approvals/approval_1/approve'], + ['POST', 'http://localhost:4817/approvals/approval_1/deny'], + ['POST', 'http://localhost:4817/killswitch'], + ['GET', 'http://localhost:4817/killswitch'], + ['DELETE', 'http://localhost:4817/killswitch/rule_1'], + ['POST', 'http://localhost:4817/revocations'], + ['GET', 'http://localhost:4817/revocations'], + ['GET', 'http://localhost:4817/revocations/rev_1'], + ['POST', 'http://localhost:4817/incidents'], + ['GET', 'http://localhost:4817/incidents'], + ['GET', 'http://localhost:4817/incidents/inc_1'], + ['POST', 'http://localhost:4817/incidents/inc_1/resolve'], + ['POST', 'http://localhost:4817/attestations'], + ['GET', 'http://localhost:4817/attestations/att_1'], + ['POST', 'http://localhost:4817/attestations/att_1/verify'], + ['POST', 'http://localhost:4817/sessions'], + ['GET', 'http://localhost:4817/sessions/sess_1'], + ['POST', 'http://localhost:4817/sessions/sess_1/verify'], + ['POST', 'http://localhost:4817/registry/start'], + ['POST', 'http://localhost:4817/registry/publish'], + ['POST', 'http://localhost:4817/registry/search'], + ['GET', 'http://localhost:4817/registry/index'], + ['POST', 'http://localhost:4817/relay/start'], + ['POST', 'http://localhost:4817/relay/register'], + ['POST', 'http://localhost:4817/relay/discover'], + ['POST', 'http://localhost:4817/dht/start'], + ['POST', 'http://localhost:4817/dht/publish'], + ['POST', 'http://localhost:4817/dht/find'], + ['GET', 'http://localhost:4817/.well-known/fides.json'], + ['GET', 'http://localhost:4817/.well-known/agents.json'], + ['GET', 'http://localhost:4817/.well-known/agents/did%3Afides%3Aagent.json'], + ['POST', 'http://localhost:4817/evidence'], + ['GET', 'http://localhost:4817/evidence'], + ['GET', 'http://localhost:4817/evidence/evt_1'], + ['POST', 'http://localhost:4817/evidence/verify'], + ['POST', 'http://localhost:4817/evidence/export'], + ['POST', 'http://localhost:4817/demo/run'], + ['POST', 'http://localhost:4817/simulate/adversarial'], + ['POST', 'http://localhost:4817/invoke'], + ['GET', 'http://localhost:4817/trust/did%3Afides%3Aagent'], + ] + expect(calls.map(call => [call.init?.method, call.url])).toEqual(expectedCalls) + + const bodyFor = (url: string) => JSON.parse(calls.find(call => call.url === url)?.init?.body as string) + expect(bodyFor('http://localhost:4817/discover/registry')).toEqual({ capability: 'invoice.reconcile', supported_versions: ['fides.v2.0'], required_versions: ['fides.v2.0'], }) - expect(JSON.parse(calls[45].init?.body as string)).toEqual({ + expect(bodyFor('http://localhost:4817/registry/search')).toEqual({ capability: 'invoice.reconcile', supported_versions: ['fides.v2.0'], required_versions: ['fides.v2.0'], }) - expect(JSON.parse(calls[51].init?.body as string)).toEqual({ + expect(bodyFor('http://localhost:4817/dht/publish')).toEqual({ capability: 'invoice.reconcile', agentId: 'did:fides:agent', }) - expect(JSON.parse(calls[60].init?.body as string)).toEqual({ + expect(bodyFor('http://localhost:4817/evidence/export')).toEqual({ privacy_mode: 'hash_only', include_metadata: false, }) From 29df5d63d04560be28508e92dc7f131cf655d60d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 20:57:34 +0300 Subject: [PATCH 227/282] test(agentd): cover sqlite local state mirrors --- services/agentd/test/storage.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/services/agentd/test/storage.test.ts b/services/agentd/test/storage.test.ts index 21ee384..5a3d0a4 100644 --- a/services/agentd/test/storage.test.ts +++ b/services/agentd/test/storage.test.ts @@ -160,6 +160,11 @@ describe('agentd authority stores', () => { dhtPointers: [{ agent_id: 'did:fides:agent', capability: 'invoice.reconcile' }], registryRecords: [{ id: 'registry-record-1', agentId: 'did:fides:agent' }], relayRecords: [{ id: 'relay-record-1', agentId: 'did:fides:agent' }], + trustResults: [{ agentId: 'did:fides:agent', capability: 'invoice.reconcile', score: 0.82 }], + reputationRecords: [{ agentId: 'did:fides:agent', capability: 'invoice.reconcile', score: 0.76 }], + delegationTokens: [{ id: 'delegation-1', delegatee: 'did:fides:agent' }], + approvals: [{ id: 'approval-1', capability: 'payments.prepare' }], + approvalDecisions: [{ id: 'approval-decision-1', approvalId: 'approval-1', decision: 'approved' }], evidenceEvents: [{ event_id: 'evt-1' }], runtimeAttestations: [{ attestation_id: 'att-1' }], sessionGrants: [{ session: { session_id: 'sess-1' }, policy: { id: 'policy-decision-1', decision: 'allow' } }], @@ -202,7 +207,11 @@ describe('agentd authority stores', () => { 'dht_records', 'registry_records', 'relay_records', + 'trust_results', + 'reputation_records', 'policy_decisions', + 'approvals', + 'delegations', 'sessions', 'evidence_events', 'revocations', @@ -212,7 +221,17 @@ describe('agentd authority stores', () => { expect((db.prepare('SELECT COUNT(*) AS count FROM identities').get() as { count: number }).count).toBe(1) expect((db.prepare('SELECT COUNT(*) AS count FROM capabilities').get() as { count: number }).count).toBe(1) expect((db.prepare('SELECT COUNT(*) AS count FROM discovery_records').get() as { count: number }).count).toBe(3) + expect((db.prepare('SELECT COUNT(*) AS count FROM trust_results').get() as { count: number }).count).toBe(1) + expect((db.prepare('SELECT COUNT(*) AS count FROM reputation_records').get() as { count: number }).count).toBe(1) expect((db.prepare('SELECT COUNT(*) AS count FROM policy_decisions').get() as { count: number }).count).toBe(1) + expect((db.prepare('SELECT COUNT(*) AS count FROM approvals').get() as { count: number }).count).toBe(2) + expect((db.prepare('SELECT COUNT(*) AS count FROM delegations').get() as { count: number }).count).toBe(1) + expect((db.prepare('SELECT COUNT(*) AS count FROM attestations').get() as { count: number }).count).toBe(1) + expect((db.prepare('SELECT COUNT(*) AS count FROM sessions').get() as { count: number }).count).toBe(1) + expect((db.prepare('SELECT COUNT(*) AS count FROM evidence_events').get() as { count: number }).count).toBe(1) + expect((db.prepare('SELECT COUNT(*) AS count FROM revocations').get() as { count: number }).count).toBe(1) + expect((db.prepare('SELECT COUNT(*) AS count FROM incidents').get() as { count: number }).count).toBe(1) + expect((db.prepare('SELECT COUNT(*) AS count FROM kill_switch_rules').get() as { count: number }).count).toBe(1) } finally { db.close() } From 1b2276c76694d63e1ee8c90213656fd7bf17a514 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 20:58:03 +0300 Subject: [PATCH 228/282] docs: document sqlite local state mirrors --- docs/status/fides-v2-implementation-status.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 33d9e4e..57e32e7 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -30,7 +30,11 @@ Last verified locally: 2026-05-30. - Scoped SessionGrants and invocation preflight. - Hash-chained EvidenceEvents with verification and export. - Runtime attestation schema and local MockTEE provider. -- Local SQLite daemon snapshot store for v2 local state. +- Local SQLite daemon snapshot store for v2 local state, with mirror tables for + identities, trust anchors, attestations, AgentCards, agents, capabilities, + discovery records, DHT records, registry records, relay records, trust + results, reputation records, policy decisions, approvals, delegations, + sessions, evidence events, revocations, incidents, and kill switch rules. - Full local demo and adversarial simulation endpoints. - Public docs refreshed around `agentd`, `FidesClient`, candidate-only discovery, and authority-via-policy/session. @@ -41,6 +45,7 @@ Last verified locally: 2026-05-30. - Typed error vocabulary and `ErrorEnvelope` response shape. - `agentd` scoped API key enforcement on protected mutation routes. - Postgres authority-store migration and health-check path for `agentd`. +- SQLite local-state snapshot and mirror-table persistence for local inspection. - Revocation, incident, kill switch, session, and evidence policy hooks. - SDK type coverage for the main root v2 API responses. - OpenAPI schemas for root `agentd` demo and simulation responses. From 6c09690c0e7837f105a2f3bdee0ec8b0982be70c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:00:39 +0300 Subject: [PATCH 229/282] test(api): lock demo response contracts --- tests/e2e/agentd-openapi-contract.test.ts | 83 +++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/e2e/agentd-openapi-contract.test.ts b/tests/e2e/agentd-openapi-contract.test.ts index e4afd13..d2fb97b 100644 --- a/tests/e2e/agentd-openapi-contract.test.ts +++ b/tests/e2e/agentd-openapi-contract.test.ts @@ -193,6 +193,53 @@ describe('Agentd OpenAPI contract', () => { } }) + it('documents demo and adversarial simulation response invariants', () => { + expect(extractSchemaRequired(openApi, 'LocalDemoRunResponse')).toEqual([ + 'status', + 'mode', + 'steps', + 'identities', + 'discovery', + 'verification', + 'authority', + 'surfaces', + 'limitations', + ]) + expect(extractSchemaRequired(openApi, 'LocalDemoAuthoritySummary')).toEqual([ + 'discoveryGrantsAuthority', + 'policyBeforeExecution', + 'evidenceProduced', + ]) + expect(extractNestedRequired(openApi, 'LocalDemoRunResponse', 'verification')).toEqual([ + 'agentCardsVerified', + 'evidenceHashChainValid', + 'evidenceEventCount', + 'evidenceExport', + ]) + expect(extractSchemaRequired(openApi, 'LocalAdversarialScenario')).toEqual([ + 'name', + 'detected', + 'outcome', + 'evidenceRef', + ]) + expect(extractSchemaRequired(openApi, 'LocalAdversarialSimulationResponse')).toEqual([ + 'status', + 'mode', + 'detections', + 'scenarios', + 'evidence', + 'authority', + 'limitations', + ]) + expect(extractNestedRequired(openApi, 'LocalAdversarialSimulationResponse', 'evidence')).toEqual([ + 'scenarioEvents', + 'incidentEvidenceRef', + 'rootChainValid', + 'rootEventCount', + 'brokenEvidenceChainValid', + ]) + }) + it('keeps root v2 runtime routes documented in OpenAPI', () => { const runtimeOperations = extractAgentdRuntimeRoutes(agentdSource) .filter(operation => operation.path.startsWith('/')) @@ -324,3 +371,39 @@ function extractApiKeySecuredOperations(source: string): string[] { return Array.from(secured) } + +function extractSchemaRequired(source: string, schemaName: string): string[] { + const schema = extractSchemaBlock(source, schemaName) + for (const line of schema.split('\n')) { + const match = line.match(/^ {6}required: \[(.*)\]$/) + if (match) return match[1].split(',').map(item => item.trim()).filter(Boolean) + } + throw new Error(`OpenAPI schema ${schemaName} does not define a top-level required array`) +} + +function extractNestedRequired(source: string, schemaName: string, propertyName: string): string[] { + const schema = extractSchemaBlock(source, schemaName) + const lines = schema.split('\n') + const propertyStart = lines.findIndex(line => line === ` ${propertyName}:`) + if (propertyStart === -1) throw new Error(`OpenAPI schema ${schemaName} does not define ${propertyName}`) + + for (const line of lines.slice(propertyStart + 1)) { + if (line.match(/^ [A-Za-z0-9_]+:$/)) break + const match = line.match(/^ {10}required: \[(.*)\]$/) + if (match) return match[1].split(',').map(item => item.trim()).filter(Boolean) + } + throw new Error(`OpenAPI schema ${schemaName}.${propertyName} does not define a required array`) +} + +function extractSchemaBlock(source: string, schemaName: string): string { + const lines = source.split('\n') + const schemaStart = lines.findIndex(line => line === ` ${schemaName}:`) + if (schemaStart === -1) throw new Error(`OpenAPI schema ${schemaName} was not found`) + + const schemaLines = [] + for (const line of lines.slice(schemaStart + 1)) { + if (line.match(/^ [A-Za-z0-9_]+:$/)) break + schemaLines.push(line) + } + return schemaLines.join('\n') +} From 39cbfd66b10ef752406cd2298f14cd01db7e5599 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:01:35 +0300 Subject: [PATCH 230/282] docs: refresh fides v2 status snapshot --- docs/status/fides-v2-implementation-status.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 57e32e7..039a0b6 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -48,7 +48,8 @@ Last verified locally: 2026-05-30. - SQLite local-state snapshot and mirror-table persistence for local inspection. - Revocation, incident, kill switch, session, and evidence policy hooks. - SDK type coverage for the main root v2 API responses. -- OpenAPI schemas for root `agentd` demo and simulation responses. +- OpenAPI route audit and response-shape contract coverage for root `agentd` + demo and adversarial simulation responses. ## Working Prototype @@ -255,6 +256,7 @@ pnpm --filter @fides/sdk build pnpm --filter @fides/sdk test pnpm --filter @fides/cli lint pnpm --filter @fides/agentd test +pnpm --filter @fides/e2e-tests test -- agentd-openapi-contract.test.ts pnpm --filter @fides/cli build pnpm package:hygiene pnpm api:audit @@ -318,6 +320,14 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `6c09690 test(api): lock demo response contracts` +- `1b2276c docs: document sqlite local state mirrors` +- `29df5d6 test(agentd): cover sqlite local state mirrors` +- `9c2cbb3 test(sdk): cover root agentd endpoint helpers` +- `2167309 test(api): audit documented endpoint summaries` +- `8199a2e docs: record all-provider discovery smoke` +- `e35d5ea test(agentd): smoke all-provider discovery` +- `0500032 test(api): audit agentd openapi routes` - `416de6c docs(cli): note silent json mode for pnpm agentd` - `57fd72c chore(cli): add root agentd scripts` - `e0554eb docs(cli): fix workspace agentd invocation` From fd172642f733490f62d7bda2ff45c5ca4afc0f93 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:03:43 +0300 Subject: [PATCH 231/282] feat(agentd): mark discovery writes non-authoritative --- docs/api/agentd.yaml | 15 ++++++++++++--- services/agentd/src/index.ts | 8 ++++---- services/agentd/test/routes.test.ts | 4 ++++ tests/e2e/agentd-openapi-contract.test.ts | 22 +++++++++++++++++++--- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 0f0ff94..b73645c 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -1913,12 +1913,15 @@ components: LocalDhtPublishResponse: type: object - required: [accepted, pointer] + required: [accepted, pointer, authorityGranted] properties: accepted: type: boolean pointer: $ref: "#/components/schemas/LocalDiscoveryRecord" + authorityGranted: + type: boolean + enum: [false] LocalDhtFindResponse: type: object @@ -1947,12 +1950,15 @@ components: LocalRegistryPublishResponse: type: object - required: [accepted, record] + required: [accepted, record, authorityGranted] properties: accepted: type: boolean record: $ref: "#/components/schemas/LocalDiscoveryRecord" + authorityGranted: + type: boolean + enum: [false] LocalRegistryIndexResponse: type: object @@ -1984,12 +1990,15 @@ components: LocalRelayRegisterResponse: type: object - required: [accepted, record] + required: [accepted, record, authorityGranted] properties: accepted: type: boolean record: $ref: "#/components/schemas/LocalDiscoveryRecord" + authorityGranted: + type: boolean + enum: [false] WellKnownFidesResponse: type: object diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 3c8459c..d64f945 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -2625,7 +2625,7 @@ app.post('/dht/publish', async (c) => { source: 'agentd-signed-dht-pointer', } localDhtPointers.push(storedPointer) - return c.json({ accepted: true, pointer: storedPointer }, 201) + return c.json({ accepted: true, pointer: storedPointer, authorityGranted: false }, 201) } const pointer = { @@ -2642,7 +2642,7 @@ app.post('/dht/publish', async (c) => { source: 'agentd-in-memory-dht', } localDhtPointers.push(pointer) - return c.json({ accepted: true, pointer }, 201) + return c.json({ accepted: true, pointer, authorityGranted: false }, 201) }) async function findLocalDhtPointers(capability?: string) { @@ -2854,7 +2854,7 @@ app.post('/registry/publish', async (c) => { return c.json({ error: 'registered local AgentCard not found', cardId }, 404) } localRegistryRecords.set(String(record.id), record) - return c.json({ accepted: true, record }, 201) + return c.json({ accepted: true, record, authorityGranted: false }, 201) }) app.post('/registry/search', async (c) => { @@ -2983,7 +2983,7 @@ app.post('/relay/register', async (c) => { return c.json({ error: 'registered local agent not found', agentId }, 404) } localRelayRecords.set(agentId, record) - return c.json({ accepted: true, record }, 201) + return c.json({ accepted: true, record, authorityGranted: false }, 201) }) app.post('/relay/discover', async (c) => { diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index f3067b2..fc5c0cc 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1858,6 +1858,7 @@ describe('Agentd Service Routes', () => { }), }) expect(publish.status).toBe(201) + expect((await publish.json()).authorityGranted).toBe(false) const find = await app.request('/dht/find?capability=invoice.reconcile') expect(find.status).toBe(200) @@ -1935,6 +1936,7 @@ describe('Agentd Service Routes', () => { }) expect(publish.status).toBe(201) const published = await publish.json() + expect(published.authorityGranted).toBe(false) expect(published.pointer).toMatchObject({ schema_version: 'fides.dht.pointer.v1', record_type: 'capability_pointer', @@ -2063,6 +2065,7 @@ describe('Agentd Service Routes', () => { }) expect(publish.status).toBe(201) const publishedRegistry = await publish.json() + expect(publishedRegistry.authorityGranted).toBe(false) expect(publishedRegistry.record).toMatchObject({ agentId: identity.did, agentCardUrl: `local://agent-cards/${encodeURIComponent(identity.did)}`, @@ -2147,6 +2150,7 @@ describe('Agentd Service Routes', () => { }) expect(relayRegister.status).toBe(201) const relayRegistration = await relayRegister.json() + expect(relayRegistration.authorityGranted).toBe(false) expect(relayRegistration.record).toMatchObject({ agentId: identity.did, agentCardUrl: `local://agent-cards/${encodeURIComponent(identity.did)}`, diff --git a/tests/e2e/agentd-openapi-contract.test.ts b/tests/e2e/agentd-openapi-contract.test.ts index d2fb97b..4243de8 100644 --- a/tests/e2e/agentd-openapi-contract.test.ts +++ b/tests/e2e/agentd-openapi-contract.test.ts @@ -240,6 +240,13 @@ describe('Agentd OpenAPI contract', () => { ]) }) + it('documents discovery publish and presence writes as non-authority responses', () => { + for (const schemaName of ['LocalDhtPublishResponse', 'LocalRegistryPublishResponse', 'LocalRelayRegisterResponse']) { + expect(extractSchemaRequired(openApi, schemaName), schemaName).toContain('authorityGranted') + expect(extractSchemaPropertyBlock(openApi, schemaName, 'authorityGranted'), schemaName).toContain('enum: [false]') + } + }) + it('keeps root v2 runtime routes documented in OpenAPI', () => { const runtimeOperations = extractAgentdRuntimeRoutes(agentdSource) .filter(operation => operation.path.startsWith('/')) @@ -382,17 +389,26 @@ function extractSchemaRequired(source: string, schemaName: string): string[] { } function extractNestedRequired(source: string, schemaName: string, propertyName: string): string[] { + const property = extractSchemaPropertyBlock(source, schemaName, propertyName) + for (const line of property.split('\n')) { + const match = line.match(/^ {10}required: \[(.*)\]$/) + if (match) return match[1].split(',').map(item => item.trim()).filter(Boolean) + } + throw new Error(`OpenAPI schema ${schemaName}.${propertyName} does not define a required array`) +} + +function extractSchemaPropertyBlock(source: string, schemaName: string, propertyName: string): string { const schema = extractSchemaBlock(source, schemaName) const lines = schema.split('\n') const propertyStart = lines.findIndex(line => line === ` ${propertyName}:`) if (propertyStart === -1) throw new Error(`OpenAPI schema ${schemaName} does not define ${propertyName}`) + const propertyLines = [] for (const line of lines.slice(propertyStart + 1)) { if (line.match(/^ [A-Za-z0-9_]+:$/)) break - const match = line.match(/^ {10}required: \[(.*)\]$/) - if (match) return match[1].split(',').map(item => item.trim()).filter(Boolean) + propertyLines.push(line) } - throw new Error(`OpenAPI schema ${schemaName}.${propertyName} does not define a required array`) + return propertyLines.join('\n') } function extractSchemaBlock(source: string, schemaName: string): string { From ffd0874c0369a913dde2caf4b1394d92082b2715 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:05:34 +0300 Subject: [PATCH 232/282] test(sdk): expose non-authoritative discovery writes --- packages/sdk/src/fides-client.ts | 1 + packages/sdk/test/fides-client.test.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index de6f70a..7776554 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -221,6 +221,7 @@ export interface FidesDiscoveryInfrastructurePublishResponse { accepted: boolean record?: FidesProviderRecord pointer?: FidesProviderRecord + authorityGranted: false [key: string]: unknown } diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 4135cd3..4ee27ea 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -1253,7 +1253,7 @@ describe('FidesClient', () => { }), { status: 200, headers: { 'Content-Type': 'application/json' } }) } if (target.endsWith('/registry/publish')) { - return new Response(JSON.stringify({ accepted: true, record }), { + return new Response(JSON.stringify({ accepted: true, record, authorityGranted: false }), { status: 201, headers: { 'Content-Type': 'application/json' }, }) @@ -1275,7 +1275,7 @@ describe('FidesClient', () => { }), { status: 200, headers: { 'Content-Type': 'application/json' } }) } if (target.endsWith('/relay/register')) { - return new Response(JSON.stringify({ accepted: true, record }), { + return new Response(JSON.stringify({ accepted: true, record, authorityGranted: false }), { status: 201, headers: { 'Content-Type': 'application/json' }, }) @@ -1288,7 +1288,7 @@ describe('FidesClient', () => { }), { status: 200, headers: { 'Content-Type': 'application/json' } }) } if (target.endsWith('/dht/publish')) { - return new Response(JSON.stringify({ accepted: true, pointer }), { + return new Response(JSON.stringify({ accepted: true, pointer, authorityGranted: false }), { status: 201, headers: { 'Content-Type': 'application/json' }, }) @@ -1335,6 +1335,7 @@ describe('FidesClient', () => { const registryPublished = await client.registry.publish({ agentCardId: 'card_1' }) expect(registryPublished.accepted).toBe(true) + expect(registryPublished.authorityGranted).toBe(false) expect(registryPublished.record?.authorityGranted).toBe(false) const registryIndex = await client.registry.index() @@ -1346,12 +1347,14 @@ describe('FidesClient', () => { expect(relayStarted.authorityGranted).toBe(false) const relayRegistered = await client.relay.register({ agentId: 'did:fides:agent' }) + expect(relayRegistered.authorityGranted).toBe(false) expect(relayRegistered.record?.authorityGranted).toBe(false) const dhtStarted = await client.dht.start() expect(dhtStarted.mode).toBe('in_memory_simulator') const dhtPublished = await client.dht.publish({ capability: 'invoice.reconcile', agentId: 'did:fides:agent' }) + expect(dhtPublished.authorityGranted).toBe(false) expect(dhtPublished.pointer?.authorityGranted).toBe(false) const dhtFind = await client.dht.find({ capability: 'invoice.reconcile' }) From 55653a80dead197b2999ece15342bb2d843730bf Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:06:17 +0300 Subject: [PATCH 233/282] docs: record non-authoritative discovery writes --- docs/status/fides-v2-implementation-status.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 039a0b6..b10be76 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -24,6 +24,9 @@ Last verified locally: 2026-05-30. federation-ready surfaces. - Signed registry index records, signed relay AgentCard references, and signed DHT pointer records. +- Discovery publish and presence writes explicitly return `authorityGranted: + false`; publishing to registry, relay, or DHT never grants invocation + authority. - Capability-specific trust and reputation scoring with explainability. - Policy-before-execution with approval, dry-run, revocation, incident, runtime attestation, and kill switch inputs. @@ -49,7 +52,8 @@ Last verified locally: 2026-05-30. - Revocation, incident, kill switch, session, and evidence policy hooks. - SDK type coverage for the main root v2 API responses. - OpenAPI route audit and response-shape contract coverage for root `agentd` - demo and adversarial simulation responses. + demo, adversarial simulation, and non-authoritative discovery write + responses. ## Working Prototype @@ -254,6 +258,8 @@ pnpm verify pnpm examples:typecheck pnpm --filter @fides/sdk build pnpm --filter @fides/sdk test +pnpm --filter @fides/sdk test -- fides-client.test.ts +pnpm --filter @fides/sdk lint pnpm --filter @fides/cli lint pnpm --filter @fides/agentd test pnpm --filter @fides/e2e-tests test -- agentd-openapi-contract.test.ts @@ -284,6 +290,8 @@ Observed manual smoke results: - demo returned `payments: "dry_run_only"`. - all-provider discovery queried local, well-known, registry, relay, DHT, and federation providers. - all-provider discovery returned `authorityGranted: false`. +- registry publish, relay register, and DHT publish responses return top-level + `authorityGranted: false`. - adversarial simulation returned `status: "detected"`. - adversarial simulation detected 10 scenarios. - adversarial simulation returned `rootChainValid: true`. @@ -320,6 +328,8 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `ffd0874 test(sdk): expose non-authoritative discovery writes` +- `fd17264 feat(agentd): mark discovery writes non-authoritative` - `6c09690 test(api): lock demo response contracts` - `1b2276c docs: document sqlite local state mirrors` - `29df5d6 test(agentd): cover sqlite local state mirrors` From 22cd792de1896db92a5d737e323af7ab86f4e5fc Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:12:31 +0300 Subject: [PATCH 234/282] feat(agentd): emit discovery evidence events --- docs/api/agentd.yaml | 10 +++ packages/sdk/src/fides-client.ts | 2 + services/agentd/src/index.ts | 80 +++++++++++++++++++---- services/agentd/test/routes.test.ts | 24 +++++++ tests/e2e/agentd-openapi-contract.test.ts | 8 +++ 5 files changed, 113 insertions(+), 11 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index b73645c..fe08174 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -2630,6 +2630,16 @@ components: enum: [false] explanation: type: string + evidenceRefs: + type: array + description: Evidence event references produced by root discovery endpoints. + items: + type: string + evidence_refs: + type: array + description: Snake-case evidence event references for protocol object compatibility. + items: + type: string CardResponse: type: object diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 7776554..55df1b3 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -101,6 +101,8 @@ export interface FidesDiscoveryResponse { pointers?: FidesProviderRecord[] rejectedPointers?: FidesProviderRecord[] authorityGranted: false + evidenceRefs?: string[] + evidence_refs?: string[] explanation?: string [key: string]: unknown } diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index d64f945..996096e 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -1269,25 +1269,79 @@ async function localDiscoveryResult(body: Record, provider = 'l } } +function appendDiscoveryEvidence>( + provider: string, + body: Record, + result: T +): T & { evidenceRefs: string[]; evidence_refs: string[] } { + const capability = typeof body.capability === 'string' ? body.capability : undefined + const candidates = Array.isArray(result.candidates) ? result.candidates.length : 0 + const records = Array.isArray(result.records) ? result.records.length : 0 + const pointers = Array.isArray(result.pointers) ? result.pointers.length : 0 + const rejectedCandidates = Array.isArray(result.rejectedCandidates) ? result.rejectedCandidates.length : 0 + const rejectedRecords = Array.isArray(result.rejectedRecords) ? result.rejectedRecords.length : 0 + const rejectedPointers = Array.isArray(result.rejectedPointers) ? result.rejectedPointers.length : 0 + const event = appendRootEvidence({ + type: 'discovery.performed', + actor: 'did:fides:agentd:local-daemon', + subject: `fides.discovery.${provider}`, + capability, + input: { + provider, + capability, + constraints: body.constraints, + supported_versions: body.supported_versions ?? body.supportedVersions, + required_versions: body.required_versions ?? body.requiredVersions, + }, + output: { + candidates, + records, + pointers, + rejectedCandidates, + rejectedRecords, + rejectedPointers, + authorityGranted: false, + }, + decision: 'candidate_only', + privacy_mode: 'hash_only', + metadata: { + provider, + capability, + candidates, + records, + pointers, + rejectedCandidates, + rejectedRecords, + rejectedPointers, + authorityGranted: false, + }, + }) + return { + ...result, + evidenceRefs: [event.event_id], + evidence_refs: [event.event_id], + } +} + app.post('/discover', async (c) => { const body = await c.req.json().catch(() => ({})) const result = await localDiscoveryResult(body) if ('error' in result) return c.json({ error: result.error }, 400) - return c.json(result) + return c.json(appendDiscoveryEvidence('local', body, result)) }) app.post('/discover/local', async (c) => { const body = await c.req.json().catch(() => ({})) const result = await localDiscoveryResult(body, 'local') if ('error' in result) return c.json({ error: result.error }, 400) - return c.json(result) + return c.json(appendDiscoveryEvidence('local', body, result)) }) app.post('/discover/well-known', async (c) => { const body = await c.req.json().catch(() => ({})) const result = await localDiscoveryResult(body, 'well-known') if ('error' in result) return c.json({ error: result.error }, 400) - return c.json(result) + return c.json(appendDiscoveryEvidence('well-known', body, result)) }) app.post('/trust/evaluate', async (c) => { @@ -2698,7 +2752,7 @@ app.post('/discover/dht', async (c) => { found.pointers as Array>, 'rejectedPointers' ) - return c.json({ + const result = { provider: 'dht', capability: found.capability, pointers: filtered.records, @@ -2708,7 +2762,8 @@ app.post('/discover/dht', async (c) => { ], authorityGranted: false, explanation: 'DHT discovery returns signed pointer candidates only; trust, policy, and session grants are evaluated separately.', - }) + } + return c.json(appendDiscoveryEvidence('dht', body, result)) }) async function localRegistryRecordFor(cardId: string, mode: 'public' | 'private' = 'public') { @@ -2884,7 +2939,7 @@ app.post('/discover/registry', async (c) => { )) const verified = await filterVerifiedLocalRegistryRecords(matched) const filtered = filterVersionCompatibleProviderRecords(body, verified.records) - return c.json({ + const result = { provider: 'registry', capability: capability ?? null, records: filtered.records, @@ -2894,7 +2949,8 @@ app.post('/discover/registry', async (c) => { ], authorityGranted: false, explanation: 'Registry discovery returns registry records only; registration does not grant invocation authority.', - }) + } + return c.json(appendDiscoveryEvidence('registry', body, result)) }) app.post('/discover/federation', async (c) => { @@ -2920,7 +2976,7 @@ app.post('/discover/federation', async (c) => { 'federation_does_not_grant_authority', ], })) - return c.json({ + const result = { provider: 'federation', mode: 'local_mock_federation', capability: capability ?? null, @@ -2934,7 +2990,8 @@ app.post('/discover/federation', async (c) => { federationPeerVerified: peer.verified, authorityGranted: false, explanation: 'Federation expands discovery to registry peers only; federated results are candidates and never invocation authority.', - }) + } + return c.json(appendDiscoveryEvidence('federation', body, result)) }) app.get('/registry/index', async (c) => { @@ -3008,14 +3065,15 @@ app.post('/discover/relay', async (c) => { !capability || (record.capabilities as string[] | undefined)?.includes(capability) )) const filtered = filterVersionCompatibleProviderRecords(body, matched) - return c.json({ + const result = { provider: 'relay', capability: capability ?? null, records: filtered.records, [filtered.rejectedKey]: filtered.rejected, authorityGranted: false, explanation: 'Relay discovery returns presence records only; relay presence is not authority.', - }) + } + return c.json(appendDiscoveryEvidence('relay', body, result)) }) app.get('/.well-known/fides.json', (c) => { diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index fc5c0cc..b24aae8 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -501,6 +501,8 @@ describe('Agentd Service Routes', () => { expect(discovered.status).toBe(200) const discoveredData = await discovered.json() expect(discoveredData.authorityGranted).toBe(false) + expect(discoveredData.evidenceRefs).toEqual([expect.any(String)]) + expect(discoveredData.evidence_refs).toEqual(discoveredData.evidenceRefs) expect(discoveredData.candidates).toEqual(expect.arrayContaining([ expect.objectContaining({ agentId: identity.did, @@ -520,6 +522,26 @@ describe('Agentd Service Routes', () => { expect(discoveredData.candidates[0].reasons).toContain('url_not_required_for_local_discovery') expect(discoveredData.candidates[0].reasons).toContain('protocol_version_compatible') + const evidence = await app.request('/evidence') + expect(evidence.status).toBe(200) + const evidenceData = await evidence.json() + expect(evidenceData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event_id: discoveredData.evidenceRefs[0], + type: 'discovery.performed', + actor: 'did:fides:agentd:local-daemon', + subject: 'fides.discovery.local', + capability: 'invoice.reconcile', + decision: 'candidate_only', + privacy_mode: 'hash_only', + metadata: expect.objectContaining({ + provider: 'local', + candidates: 1, + authorityGranted: false, + }), + }), + ])) + const localDiscovered = await app.request('/discover/local', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -530,6 +552,7 @@ describe('Agentd Service Routes', () => { provider: 'local', authorityGranted: false, count: 1, + evidenceRefs: [expect.any(String)], }) const wellKnownDiscovered = await app.request('/discover/well-known', { @@ -542,6 +565,7 @@ describe('Agentd Service Routes', () => { provider: 'well-known', authorityGranted: false, count: 1, + evidenceRefs: [expect.any(String)], }) }) diff --git a/tests/e2e/agentd-openapi-contract.test.ts b/tests/e2e/agentd-openapi-contract.test.ts index 4243de8..a58e153 100644 --- a/tests/e2e/agentd-openapi-contract.test.ts +++ b/tests/e2e/agentd-openapi-contract.test.ts @@ -247,6 +247,14 @@ describe('Agentd OpenAPI contract', () => { } }) + it('documents discovery responses as evidence-producing candidate results', () => { + const schema = extractSchemaBlock(openApi, 'DiscoveryResponse') + expect(schema).toContain('authorityGranted:') + expect(schema).toContain('enum: [false]') + expect(schema).toContain('evidenceRefs:') + expect(schema).toContain('evidence_refs:') + }) + it('keeps root v2 runtime routes documented in OpenAPI', () => { const runtimeOperations = extractAgentdRuntimeRoutes(agentdSource) .filter(operation => operation.path.startsWith('/')) From 7c60719ea34998fb2893ad56ed3894d698082ad0 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:12:34 +0300 Subject: [PATCH 235/282] docs: document discovery evidence events --- docs/protocol/discovery.md | 8 +++++++- docs/status/fides-v2-implementation-status.md | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/protocol/discovery.md b/docs/protocol/discovery.md index d4a6623..cb9afc8 100644 --- a/docs/protocol/discovery.md +++ b/docs/protocol/discovery.md @@ -67,7 +67,13 @@ negotiation. Incompatible provider or legacy DID-resolution candidates are filtered before ranking and compatible candidates carry a `versionNegotiation` record. Every returned `DiscoveryCandidate` is explicitly marked `authority: candidate_only` and carries `evidence_refs` for audit links. -Trust/policy/evidence integration remains an incremental hardening area. +Root `agentd` discovery endpoints also append a hash-only +`discovery.performed` evidence event and return the event id in both +`evidenceRefs` and `evidence_refs`. The event records provider, capability, +candidate/rejection counts, protocol constraints, and `authorityGranted: false` +metadata without storing raw inputs or outputs by default. Trust/policy +integration remains an incremental hardening area for cross-provider ranking, +but evidence is now produced for the root discovery flow. Root `agentd` local, well-known, registry, relay, locally resolvable DHT, and local mock federation discovery now apply protocol version negotiation before diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index b10be76..5d6cf99 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -27,6 +27,8 @@ Last verified locally: 2026-05-30. - Discovery publish and presence writes explicitly return `authorityGranted: false`; publishing to registry, relay, or DHT never grants invocation authority. +- Root discovery endpoints append hash-only `discovery.performed` evidence + events and return `evidenceRefs`/`evidence_refs` without granting authority. - Capability-specific trust and reputation scoring with explainability. - Policy-before-execution with approval, dry-run, revocation, incident, runtime attestation, and kill switch inputs. @@ -54,6 +56,7 @@ Last verified locally: 2026-05-30. - OpenAPI route audit and response-shape contract coverage for root `agentd` demo, adversarial simulation, and non-authoritative discovery write responses. +- OpenAPI contract coverage for evidence-producing discovery responses. ## Working Prototype @@ -292,6 +295,8 @@ Observed manual smoke results: - all-provider discovery returned `authorityGranted: false`. - registry publish, relay register, and DHT publish responses return top-level `authorityGranted: false`. +- root discovery responses return `evidenceRefs` and `evidence_refs` for the + appended `discovery.performed` event. - adversarial simulation returned `status: "detected"`. - adversarial simulation detected 10 scenarios. - adversarial simulation returned `rootChainValid: true`. @@ -328,6 +333,7 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `55653a8 docs: record non-authoritative discovery writes` - `ffd0874 test(sdk): expose non-authoritative discovery writes` - `fd17264 feat(agentd): mark discovery writes non-authoritative` - `6c09690 test(api): lock demo response contracts` From 2ccaa525bfe19f62c72b8b07ba97801d06dfe95e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:13:30 +0300 Subject: [PATCH 236/282] test(agentd): cover provider discovery evidence refs --- services/agentd/test/routes.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index b24aae8..dbb6ae8 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -720,6 +720,10 @@ describe('Agentd Service Routes', () => { }), ])) expect(data.authorityGranted).toBe(false) + if (path.startsWith('/discover/')) { + expect(data.evidenceRefs).toEqual([expect.any(String)]) + expect(data.evidence_refs).toEqual(data.evidenceRefs) + } } const dht = await app.request('/discover/dht', { @@ -746,6 +750,8 @@ describe('Agentd Service Routes', () => { }), ])) expect(dhtData.authorityGranted).toBe(false) + expect(dhtData.evidenceRefs).toEqual([expect.any(String)]) + expect(dhtData.evidence_refs).toEqual(dhtData.evidenceRefs) }) it('returns federated registry candidates without granting authority', async () => { @@ -788,8 +794,10 @@ describe('Agentd Service Routes', () => { provider: 'federation', mode: 'local_mock_federation', authorityGranted: false, + evidenceRefs: [expect.any(String)], federationPeerVerified: true, }) + expect(data.evidence_refs).toEqual(data.evidenceRefs) expect(data.records).toEqual(expect.arrayContaining([ expect.objectContaining({ provider: 'federation', @@ -2002,6 +2010,8 @@ describe('Agentd Service Routes', () => { ])) expect(discovery.rejectedPointers).toEqual([]) expect(discovery.authorityGranted).toBe(false) + expect(discovery.evidenceRefs).toEqual([expect.any(String)]) + expect(discovery.evidence_refs).toEqual(discovery.evidenceRefs) }) it('rejects expired signed local DHT pointer records during discovery', async () => { @@ -2058,6 +2068,8 @@ describe('Agentd Service Routes', () => { }), ])) expect(discovery.authorityGranted).toBe(false) + expect(discovery.evidenceRefs).toEqual([expect.any(String)]) + expect(discovery.evidence_refs).toEqual(discovery.evidenceRefs) }) it('serves local registry, relay, and well-known discovery aliases without authority', async () => { @@ -2137,6 +2149,7 @@ describe('Agentd Service Routes', () => { expect(discoverRegistryData).toMatchObject({ provider: 'registry', authorityGranted: false, + evidenceRefs: [expect.any(String)], rejectedRecords: [], records: expect.arrayContaining([ expect.objectContaining({ @@ -2146,6 +2159,7 @@ describe('Agentd Service Routes', () => { }), ]), }) + expect(discoverRegistryData.evidence_refs).toEqual(discoverRegistryData.evidenceRefs) const index = await app.request('/registry/index') expect(index.status).toBe(200) @@ -2208,9 +2222,11 @@ describe('Agentd Service Routes', () => { body: JSON.stringify({ capability: 'calendar.schedule' }), }) expect(discoverRelay.status).toBe(200) - expect(await discoverRelay.json()).toMatchObject({ + const discoverRelayData = await discoverRelay.json() + expect(discoverRelayData).toMatchObject({ provider: 'relay', authorityGranted: false, + evidenceRefs: [expect.any(String)], records: expect.arrayContaining([ expect.objectContaining({ agentId: identity.did, @@ -2220,6 +2236,7 @@ describe('Agentd Service Routes', () => { }), ]), }) + expect(discoverRelayData.evidence_refs).toEqual(discoverRelayData.evidenceRefs) const wellKnown = await app.request('/.well-known/fides.json') expect(wellKnown.status).toBe(200) From dee95110449f5b87e28fe3a0d494bfcea62db502 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:13:41 +0300 Subject: [PATCH 237/282] docs: refresh fides v2 status commits --- docs/status/fides-v2-implementation-status.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 5d6cf99..aaf0781 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -333,6 +333,9 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `2ccaa52 test(agentd): cover provider discovery evidence refs` +- `7c60719 docs: document discovery evidence events` +- `22cd792 feat(agentd): emit discovery evidence events` - `55653a8 docs: record non-authoritative discovery writes` - `ffd0874 test(sdk): expose non-authoritative discovery writes` - `fd17264 feat(agentd): mark discovery writes non-authoritative` From 2d4eb41939ee455c8942868d18f7d4bdffba0187 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:19:21 +0300 Subject: [PATCH 238/282] feat(delegation): bind session grants to protocol versions --- docs/api/agentd.yaml | 121 +++++++++++++++++++- packages/core/src/delegation.ts | 35 +++++- packages/core/test/session-grant-v2.test.ts | 29 +++++ packages/sdk/src/fides-client.ts | 2 + services/agentd/src/index.ts | 22 ++++ services/agentd/test/routes.test.ts | 57 +++++++++ tests/e2e/agentd-openapi-contract.test.ts | 9 ++ 7 files changed, 272 insertions(+), 3 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index fe08174..8994451 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -2592,13 +2592,14 @@ components: type: string enum: [execute, dry_run] session: - type: object - description: FIDES v2 SessionGrant. Dry-run-only sessions include constraints.dryRunOnly=true. + $ref: "#/components/schemas/SessionGrantV2" signedSession: type: object description: Canonically signed SessionGrant. signedSessionVerified: type: boolean + versionNegotiation: + $ref: "#/components/schemas/VersionNegotiationRecord" policy: type: object trust: @@ -2608,6 +2609,122 @@ components: items: type: string + SessionGrantV2: + type: object + required: + - schema_version + - id + - session_id + - subject + - requester_agent_id + - target_agent_id + - principal_id + - capability + - scopes + - constraints + - policy_hash + - trust_result_hash + - issued_at + - expires_at + - nonce + - audience + - supported_versions + - negotiated_version + - issuer + - payload_hash + properties: + schema_version: + type: string + enum: [fides.session_grant.v1] + id: + type: string + session_id: + type: string + subject: + type: string + description: Target agent id bound through the shared signed-object envelope. + requester_agent_id: + type: string + target_agent_id: + type: string + principal_id: + type: string + capability: + type: string + scopes: + type: array + items: + type: string + constraints: + type: object + policy_hash: + type: string + trust_result_hash: + type: string + issued_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + nonce: + type: string + audience: + type: array + items: + type: string + supported_versions: + type: array + items: + type: string + required_versions: + type: array + items: + type: string + negotiated_version: + type: string + issuer: + type: string + payload_hash: + type: string + + VersionNegotiationRecord: + type: object + required: + - schema_version + - supported_versions + - peer_supported_versions + - compatible + - errors + properties: + schema_version: + type: string + enum: [fides.version_negotiation.v1] + supported_versions: + type: array + items: + type: string + required_versions: + type: array + items: + type: string + peer_supported_versions: + type: array + items: + type: string + peer_required_versions: + type: array + items: + type: string + negotiated_version: + type: string + compatible: + type: boolean + errors: + type: array + items: + type: object + DiscoveryResponse: type: object required: [authorityGranted] diff --git a/packages/core/src/delegation.ts b/packages/core/src/delegation.ts index d69661f..84da642 100644 --- a/packages/core/src/delegation.ts +++ b/packages/core/src/delegation.ts @@ -6,7 +6,7 @@ import type { SignedObject } from './canonical-signer.js' import { canonicalDigest, signObject, verifyObject } from './canonical-signer.js' -import { hashProtocolPayload } from './protocol.js' +import { FIDES_PROTOCOL_VERSION, FIDES_SUPPORTED_PROTOCOL_VERSIONS, hashProtocolPayload } from './protocol.js' import * as ed from '@noble/ed25519' import { bytesToHex } from '@noble/hashes/utils' @@ -57,6 +57,9 @@ export interface SessionGrantV2 { expires_at: string nonce: string audience: string[] + supported_versions: string[] + required_versions?: string[] + negotiated_version: string issuer: string payload_hash: string } @@ -82,6 +85,9 @@ export interface SessionGrantV2Input { policyHash: string trustResultHash: string audience?: string[] + supportedVersions?: string[] + requiredVersions?: string[] + negotiatedVersion?: string issuer: string issuedAt?: string expiresAt: string @@ -105,6 +111,12 @@ export function createDelegationToken(input: DelegationInput): DelegationToken { export function createSessionGrantV2(input: SessionGrantV2Input): SessionGrantV2 { const sessionId = crypto.randomUUID() + const supportedVersions = input.supportedVersions?.length + ? input.supportedVersions + : [...FIDES_SUPPORTED_PROTOCOL_VERSIONS] + const negotiatedVersion = input.negotiatedVersion ?? ( + supportedVersions.includes(FIDES_PROTOCOL_VERSION) ? FIDES_PROTOCOL_VERSION : supportedVersions[0] + ) const payload = { schema_version: 'fides.session_grant.v1' as const, id: sessionId, @@ -122,6 +134,9 @@ export function createSessionGrantV2(input: SessionGrantV2Input): SessionGrantV2 expires_at: input.expiresAt, nonce: input.nonce ?? crypto.randomUUID(), audience: input.audience ?? [input.targetAgentId], + supported_versions: supportedVersions, + ...(input.requiredVersions?.length ? { required_versions: input.requiredVersions } : {}), + negotiated_version: negotiatedVersion, issuer: input.issuer, } @@ -203,6 +218,24 @@ export function validateSessionGrantV2(session: SessionGrantV2): { valid: boolea if (!session.policy_hash) errors.push('SessionGrant.policy_hash is required') if (!session.trust_result_hash) errors.push('SessionGrant.trust_result_hash is required') if (!session.nonce) errors.push('SessionGrant.nonce is required') + if (!session.supported_versions || session.supported_versions.length === 0) { + errors.push('SessionGrant.supported_versions must not be empty') + } + if (!session.negotiated_version) errors.push('SessionGrant.negotiated_version is required') + if ( + session.negotiated_version && + session.supported_versions?.length && + !session.supported_versions.includes(session.negotiated_version) + ) { + errors.push('SessionGrant.negotiated_version must be included in supported_versions') + } + if ( + session.required_versions?.length && + session.supported_versions?.length && + session.required_versions.some(version => !session.supported_versions.includes(version)) + ) { + errors.push('SessionGrant.required_versions must be included in supported_versions') + } if (!session.issuer) errors.push('SessionGrant.issuer is required') if (!session.expires_at) errors.push('SessionGrant.expires_at is required') if (session.expires_at && isSessionGrantV2Expired(session)) errors.push('SessionGrant is expired') diff --git a/packages/core/test/session-grant-v2.test.ts b/packages/core/test/session-grant-v2.test.ts index 23235eb..15cd1be 100644 --- a/packages/core/test/session-grant-v2.test.ts +++ b/packages/core/test/session-grant-v2.test.ts @@ -38,6 +38,8 @@ describe('SessionGrant v2', () => { policy_hash: 'sha256:policy', trust_result_hash: 'sha256:trust', audience: ['did:fides:target'], + supported_versions: ['fides.v2.0', 'fides.v2'], + negotiated_version: 'fides.v2.0', issuer: issuer.did, }) expect(grant.id).toBe(grant.session_id) @@ -115,4 +117,31 @@ describe('SessionGrant v2', () => { ], }) }) + + it('rejects grants with invalid protocol version declarations', async () => { + const issuer = await createIdentityKeyPair() + const grant = createSessionGrantV2({ + requesterAgentId: 'did:fides:requester', + targetAgentId: 'did:fides:target', + principalId: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['invoice:read'], + constraints: {}, + policyHash: 'sha256:policy', + trustResultHash: 'sha256:trust', + supportedVersions: ['fides.v2.0'], + requiredVersions: ['fides.v3.0'], + negotiatedVersion: 'fides.v3.0', + issuer: issuer.did, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }) + + expect(validateSessionGrantV2(grant)).toEqual({ + valid: false, + errors: [ + 'SessionGrant.negotiated_version must be included in supported_versions', + 'SessionGrant.required_versions must be included in supported_versions', + ], + }) + }) }) diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index 55df1b3..b76f424 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -25,6 +25,7 @@ import { type SignedSessionGrantV2, type SignedInvocationRequest, type SignedInvocationResult, + type VersionNegotiationRecord, } from '@fides/core' import type { AgentdHealthResponse } from './agentd/client.js' @@ -880,6 +881,7 @@ export interface FidesSessionResponse { session: SessionGrantV2 signedSession?: SignedSessionGrantV2 signedSessionVerified?: boolean + versionNegotiation?: VersionNegotiationRecord policy?: FidesPolicyDecision trust?: TrustResult evidenceRefs?: string[] diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 996096e..3b965e8 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -1643,6 +1643,23 @@ app.post('/sessions', async (c) => { const sessionConstraints = policy.decision === 'dry_run_only' ? { ...requestedConstraints, dryRunOnly: true } : requestedConstraints + const sessionVersionNegotiation = negotiateProtocolVersion({ + localSupported: stringArray(body.supported_versions ?? body.supportedVersions), + localRequired: stringArray(body.required_versions ?? body.requiredVersions), + peerSupported: found.card.protocolVersions?.length ? found.card.protocolVersions : ['fides.v2.0'], + peerRequired: stringArray((found.card as unknown as Record).required_versions), + }) + if (!sessionVersionNegotiation.compatible || !sessionVersionNegotiation.negotiated_version) { + return c.json({ + authorized: false, + authorityGranted: false, + error: createErrorEnvelope('VERSION_INCOMPATIBLE', { + message: 'SessionGrant cannot be issued for incompatible protocol versions', + details: { versionNegotiation: sessionVersionNegotiation }, + }), + versionNegotiation: sessionVersionNegotiation, + }, 409) + } const session = createSessionGrantV2({ requesterAgentId, targetAgentId, @@ -1653,6 +1670,9 @@ app.post('/sessions', async (c) => { policyHash: hashProtocolPayload(policy), trustResultHash: hashProtocolPayload(trust), audience: Array.isArray(body.audience) ? body.audience.map(String) : [targetAgentId], + supportedVersions: sessionVersionNegotiation.supported_versions, + requiredVersions: sessionVersionNegotiation.required_versions, + negotiatedVersion: sessionVersionNegotiation.negotiated_version, issuer: authority.identity.did, expiresAt, }) @@ -1673,6 +1693,7 @@ app.post('/sessions', async (c) => { authority_granted: sessionAuthority.authorityGranted, authority_mode: sessionAuthority.authorityMode, allowed_actions: sessionAuthority.allowedActions, + negotiated_version: session.negotiated_version, }, }) @@ -1682,6 +1703,7 @@ app.post('/sessions', async (c) => { session, signedSession, signedSessionVerified: await verifySignedSessionGrantV2Issuer(signedSession), + versionNegotiation: sessionVersionNegotiation, policy, trust, evidenceRefs: [sessionEvidence.event_id], diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index dbb6ae8..a62ea29 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -982,6 +982,12 @@ describe('Agentd Service Routes', () => { expect(sessionData.authorityMode).toBe('full') expect(sessionData.allowedActions).toEqual(['execute', 'dry_run']) expect(sessionData.session.capability).toBe('invoice.reconcile') + expect(sessionData.session.supported_versions).toEqual(expect.arrayContaining(['fides.v2.0'])) + expect(sessionData.session.negotiated_version).toBe('fides.v2.0') + expect(sessionData.versionNegotiation).toMatchObject({ + compatible: true, + negotiated_version: 'fides.v2.0', + }) expect(sessionData.signedSession.payload).toEqual(sessionData.session) expect(sessionData.signedSession.proof.proofPurpose).toBe('delegation') expect(sessionData.signedSession.proof.verificationMethod).toBe(sessionData.session.issuer) @@ -1040,6 +1046,57 @@ describe('Agentd Service Routes', () => { ])) }) + it('refuses to issue SessionGrants for incompatible protocol versions', async () => { + const identityResponse = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'agent', name: 'Legacy Session Agent' }), + }) + const { identity } = await identityResponse.json() + await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identity, + capabilities: [{ + id: 'legacy.session', + riskLevel: 'low', + requiredScopes: ['legacy:read'], + }], + protocolVersions: ['fides.v1'], + }), + }) + await app.request(`/agent-cards/${encodeURIComponent(identity.did)}/sign`, { method: 'POST' }) + await app.request('/agents/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentCardId: identity.did }), + }) + + const session = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + principalId: 'did:fides:principal', + requesterAgentId: 'did:fides:requester', + agentId: identity.did, + capability: 'legacy.session', + requestedScopes: ['legacy:read'], + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }), + }) + + expect(session.status).toBe(409) + const data = await session.json() + expect(data.authorityGranted).toBe(false) + expect(data.error.code).toBe('VERSION_INCOMPATIBLE') + expect(data.versionNegotiation).toMatchObject({ + compatible: false, + peer_supported_versions: ['fides.v1'], + }) + }) + it('verifies caller-supplied signed invocation requests before execution', async () => { const requester = await createIdentityKeyPair() const identityResponse = await app.request('/identities', { diff --git a/tests/e2e/agentd-openapi-contract.test.ts b/tests/e2e/agentd-openapi-contract.test.ts index a58e153..c5b73da 100644 --- a/tests/e2e/agentd-openapi-contract.test.ts +++ b/tests/e2e/agentd-openapi-contract.test.ts @@ -255,6 +255,15 @@ describe('Agentd OpenAPI contract', () => { expect(schema).toContain('evidence_refs:') }) + it('documents v2 SessionGrants as protocol-version-bound authority records', () => { + const schema = extractSchemaBlock(openApi, 'SessionGrantV2') + expect(schema).toContain('schema_version:') + expect(schema).toContain('supported_versions:') + expect(schema).toContain('required_versions:') + expect(schema).toContain('negotiated_version:') + expect(extractSchemaPropertyBlock(openApi, 'LocalSessionResponse', 'versionNegotiation')).toContain('VersionNegotiationRecord') + }) + it('keeps root v2 runtime routes documented in OpenAPI', () => { const runtimeOperations = extractAgentdRuntimeRoutes(agentdSource) .filter(operation => operation.path.startsWith('/')) From 0a1ebdd97b15c1fe4391e9791dce5e8e550eac3a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:19:30 +0300 Subject: [PATCH 239/282] docs: document version-bound session grants --- docs/protocol/delegation-and-sessions.md | 10 ++++++++++ docs/status/fides-v2-implementation-status.md | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index 6b6828f..d19c7b0 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -25,6 +25,9 @@ Current implementation anchors: - `expires_at` - `nonce` - `audience` +- `supported_versions` +- `required_versions` when applicable +- `negotiated_version` - `issuer` - canonical signature @@ -32,6 +35,13 @@ Current implementation anchors: sites. `subject` is the target agent id, so the shared protocol object envelope binds to the same target as the session authority. +`SessionGrantV2` is protocol-version-bound. The grant records the local +supported versions, any required versions, and the negotiated protocol version +used for the session. The negotiated version must be included in +`supported_versions`; any `required_versions` must also be included. `agentd` +refuses to issue a SessionGrant when the requester and target AgentCard cannot +negotiate a compatible protocol version. + Replay protection is required through nonce tracking. Signed `SessionGrant` verification has two levels. `verifySignedSessionGrantV2` diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index aaf0781..9dd65b2 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -33,6 +33,8 @@ Last verified locally: 2026-05-30. - Policy-before-execution with approval, dry-run, revocation, incident, runtime attestation, and kill switch inputs. - Scoped SessionGrants and invocation preflight. +- SessionGrants now carry supported protocol versions, optional required + versions, and the negotiated protocol version used for authority. - Hash-chained EvidenceEvents with verification and export. - Runtime attestation schema and local MockTEE provider. - Local SQLite daemon snapshot store for v2 local state, with mirror tables for @@ -57,6 +59,7 @@ Last verified locally: 2026-05-30. demo, adversarial simulation, and non-authoritative discovery write responses. - OpenAPI contract coverage for evidence-producing discovery responses. +- OpenAPI contract coverage for version-bound `SessionGrantV2` responses. ## Working Prototype @@ -333,6 +336,7 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `2d4eb41 feat(delegation): bind session grants to protocol versions` - `2ccaa52 test(agentd): cover provider discovery evidence refs` - `7c60719 docs: document discovery evidence events` - `22cd792 feat(agentd): emit discovery evidence events` From c7880cd123088cc9d6b44279f88ed4fe4c9b433c Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:24:53 +0300 Subject: [PATCH 240/282] feat(agentd): return typed root v2 errors --- docs/protocol/error-vocabulary.md | 16 +- docs/status/fides-v2-implementation-status.md | 6 +- packages/core/src/errors.ts | 43 +++++ packages/core/test/errors.test.ts | 32 ++++ services/agentd/src/index.ts | 55 ++++-- services/agentd/test/routes.test.ts | 157 ++++++++++++++++++ 6 files changed, 288 insertions(+), 21 deletions(-) diff --git a/docs/protocol/error-vocabulary.md b/docs/protocol/error-vocabulary.md index e4b7506..da74917 100644 --- a/docs/protocol/error-vocabulary.md +++ b/docs/protocol/error-vocabulary.md @@ -33,18 +33,32 @@ Examples: - `TRUST_BELOW_THRESHOLD` - `POLICY_DENIED` - `APPROVAL_REQUIRED` +- `APPROVAL_NOT_FOUND` - `SESSION_EXPIRED` - `SESSION_NOT_FOUND` - `ATTESTATION_INVALID` - `DHT_POINTER_TAMPERED` - `EVIDENCE_CHAIN_BROKEN` +- `EVIDENCE_EVENT_NOT_FOUND` +- `EVIDENCE_PRIVACY_MODE_INVALID` - `REVOCATION_ACTIVE` +- `REVOCATION_NOT_FOUND` - `INCIDENT_ACTIVE` +- `INCIDENT_NOT_FOUND` - `KILL_SWITCH_ACTIVE` +- `KILL_SWITCH_RULE_NOT_FOUND` - `VERSION_INCOMPATIBLE` +- `REQUEST_INVALID` Root `agentd` authority-critical failures return this envelope shape on the -`error` field for session issuance and invocation failures. Policy-blocked +`error` field for session issuance, invocation failures, approval lifecycle, +kill switch, revocation, incident, and evidence ledger failures. Policy-blocked session issuance maps kill switch, revocation, approval-required, and generic policy denial states to stable machine codes while preserving the full `policy.reason_codes` list in `error.details`. + +Root local API validation failures use `REQUEST_INVALID` unless the invalid +payload belongs to a more specific protocol family, such as `INCIDENT_INVALID` +or `EVIDENCE_PRIVACY_MODE_INVALID`. Missing root local resources use the +resource-specific `*_NOT_FOUND` codes so SDKs and CLI clients do not need to +parse human-readable strings. diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 9dd65b2..57149de 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -18,7 +18,8 @@ Last verified locally: 2026-05-30. - `pnpm agentd ` - `pnpm agentd:dev` - Canonical signing model for signed protocol objects. -- Typed error envelopes on important session and invocation failure paths. +- Typed error envelopes on session, invocation, approval, kill switch, + revocation, incident, and evidence failure paths. - Signed AgentCards and capability descriptors. - Candidate-only discovery across local, well-known, registry, relay, DHT, and federation-ready surfaces. @@ -49,7 +50,8 @@ Last verified locally: 2026-05-30. ## Production-Like - Canonical object signing and verification primitives. -- Typed error vocabulary and `ErrorEnvelope` response shape. +- Typed error vocabulary and `ErrorEnvelope` response shape, including root v2 + validation and resource lookup failures. - `agentd` scoped API key enforcement on protected mutation routes. - Postgres authority-store migration and health-check path for `agentd`. - SQLite local-state snapshot and mirror-table persistence for local inspection. diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 4dacef9..d599dfd 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -14,6 +14,7 @@ export type FidesErrorCategory = | 'incident' | 'kill_switch' | 'version' + | 'request' | 'internal' export type FidesErrorSeverity = 'info' | 'warning' | 'error' | 'critical' @@ -79,6 +80,12 @@ export const FIDES_ERROR_CODES = { retryable: true, message: 'Approval is required before execution', }, + APPROVAL_NOT_FOUND: { + category: 'approval', + severity: 'error', + retryable: false, + message: 'Approval request was not found', + }, SESSION_EXPIRED: { category: 'session', severity: 'error', @@ -127,12 +134,30 @@ export const FIDES_ERROR_CODES = { retryable: false, message: 'Evidence hash chain is broken', }, + EVIDENCE_EVENT_NOT_FOUND: { + category: 'evidence', + severity: 'error', + retryable: false, + message: 'Evidence event was not found', + }, + EVIDENCE_PRIVACY_MODE_INVALID: { + category: 'evidence', + severity: 'error', + retryable: false, + message: 'Evidence privacy mode is invalid', + }, REVOCATION_ACTIVE: { category: 'revocation', severity: 'critical', retryable: false, message: 'An active revocation blocks this action', }, + REVOCATION_NOT_FOUND: { + category: 'revocation', + severity: 'error', + retryable: false, + message: 'Revocation record was not found', + }, INCIDENT_ACTIVE: { category: 'incident', severity: 'critical', @@ -145,18 +170,36 @@ export const FIDES_ERROR_CODES = { retryable: false, message: 'Incident record is invalid', }, + INCIDENT_NOT_FOUND: { + category: 'incident', + severity: 'error', + retryable: false, + message: 'Incident record was not found', + }, KILL_SWITCH_ACTIVE: { category: 'kill_switch', severity: 'critical', retryable: false, message: 'Kill switch is active', }, + KILL_SWITCH_RULE_NOT_FOUND: { + category: 'kill_switch', + severity: 'error', + retryable: false, + message: 'Kill switch rule was not found', + }, VERSION_INCOMPATIBLE: { category: 'version', severity: 'error', retryable: false, message: 'Protocol versions are incompatible', }, + REQUEST_INVALID: { + category: 'request', + severity: 'error', + retryable: false, + message: 'Request payload is invalid', + }, } as const satisfies Record { severity: 'critical', }) }) + + it('covers root local API validation and lookup failures', () => { + expect(createErrorEnvelope('REQUEST_INVALID')).toMatchObject({ + code: 'REQUEST_INVALID', + category: 'request', + retryable: false, + }) + expect(createErrorEnvelope('APPROVAL_NOT_FOUND')).toMatchObject({ + code: 'APPROVAL_NOT_FOUND', + category: 'approval', + }) + expect(createErrorEnvelope('KILL_SWITCH_RULE_NOT_FOUND')).toMatchObject({ + code: 'KILL_SWITCH_RULE_NOT_FOUND', + category: 'kill_switch', + }) + expect(createErrorEnvelope('REVOCATION_NOT_FOUND')).toMatchObject({ + code: 'REVOCATION_NOT_FOUND', + category: 'revocation', + }) + expect(createErrorEnvelope('INCIDENT_NOT_FOUND')).toMatchObject({ + code: 'INCIDENT_NOT_FOUND', + category: 'incident', + }) + expect(createErrorEnvelope('EVIDENCE_EVENT_NOT_FOUND')).toMatchObject({ + code: 'EVIDENCE_EVENT_NOT_FOUND', + category: 'evidence', + }) + expect(createErrorEnvelope('EVIDENCE_PRIVACY_MODE_INVALID')).toMatchObject({ + code: 'EVIDENCE_PRIVACY_MODE_INVALID', + category: 'evidence', + }) + }) }) diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index 3b965e8..a4c65bf 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -87,6 +87,7 @@ import { type DHTPointerRecord, type IncidentRecord, type IncidentRecordV2, + type FidesErrorCode, type IdentityTrustAnchor, type KillSwitchRule, type DelegationToken, @@ -127,6 +128,20 @@ const REGISTRY_URL = process.env.REGISTRY_URL || 'http://localhost:7346' const TRUST_GRAPH_SERVICE_ID = 'trust-graph' const PROPAGATION_MAX_ATTEMPTS = parseInt(process.env.AGENTD_PROPAGATION_MAX_ATTEMPTS || '5', 10) +function localError( + code: FidesErrorCode, + message: string, + details?: Record, +): { error: ReturnType; authorityGranted: false } { + return { + error: createErrorEnvelope(code, { + message, + ...(details ? { details } : {}), + }), + authorityGranted: false, + } +} + const teeProvider = new RuntimeMockTEEProvider() const runtimeAttestationProvider = new CoreMockTEEProvider() const killSwitch = new InMemoryKillSwitch() @@ -1996,7 +2011,7 @@ app.post('/approvals', async (c) => { ? body.capabilityId : undefined if (!capability) { - return c.json({ error: 'capability is required' }, 400) + return c.json(localError('REQUEST_INVALID', 'capability is required', { field: 'capability' }), 400) } const approval = createApprovalRequest({ @@ -2049,7 +2064,7 @@ app.post('/approvals/:id/approve', async (c) => { const id = c.req.param('id') const approval = localApprovals.get(id) if (!approval) { - return c.json({ error: 'approval request not found', id }, 404) + return c.json(localError('APPROVAL_NOT_FOUND', 'approval request not found', { id }), 404) } const body = await c.req.json().catch(() => ({})) @@ -2094,7 +2109,7 @@ app.post('/approvals/:id/deny', async (c) => { const id = c.req.param('id') const approval = localApprovals.get(id) if (!approval) { - return c.json({ error: 'approval request not found', id }, 404) + return c.json(localError('APPROVAL_NOT_FOUND', 'approval request not found', { id }), 404) } const body = await c.req.json().catch(() => ({})) @@ -2149,12 +2164,12 @@ app.post('/killswitch', async (c) => { targetType !== 'principal' && targetType !== 'risk_class' ) { - return c.json({ error: 'targetType must be agent, publisher, capability, session, principal, or risk_class' }, 400) + return c.json(localError('REQUEST_INVALID', 'targetType must be agent, publisher, capability, session, principal, or risk_class', { field: 'targetType' }), 400) } const target = typeof body.target === 'string' ? body.target : undefined if (!target) { - return c.json({ error: 'target is required' }, 400) + return c.json(localError('REQUEST_INVALID', 'target is required', { field: 'target' }), 400) } const rule = createKillSwitchRule({ @@ -2201,7 +2216,7 @@ app.delete('/killswitch/:id', (c) => { const id = c.req.param('id') const rule = localKillSwitchRules.get(id) if (!rule) { - return c.json({ error: 'kill switch rule not found', id }, 404) + return c.json(localError('KILL_SWITCH_RULE_NOT_FOUND', 'kill switch rule not found', { id }), 404) } const { payload_hash: _payloadHash, ...rulePayload } = rule const disabledPayload = { ...rulePayload, enabled: false } @@ -2227,7 +2242,7 @@ app.post('/revocations', async (c) => { targetType !== 'attestation' && targetType !== 'publisher' ) { - return c.json({ error: 'targetType must be key, identity, agent, agent_card, capability, session, attestation, or publisher' }, 400) + return c.json(localError('REQUEST_INVALID', 'targetType must be key, identity, agent, agent_card, capability, session, attestation, or publisher', { field: 'targetType' }), 400) } const targetId = typeof body.targetId === 'string' @@ -2236,7 +2251,7 @@ app.post('/revocations', async (c) => { ? body.target_id : undefined if (!targetId) { - return c.json({ error: 'targetId is required' }, 400) + return c.json(localError('REQUEST_INVALID', 'targetId is required', { field: 'targetId' }), 400) } const record = createRevocationRecordV2({ @@ -2281,7 +2296,11 @@ app.get('/revocations/:id', (c) => { const id = c.req.param('id') const record = localRevocationRecords.get(id) ?? Array.from(localRevocationRecords.values()).find(item => item.target_id === id) if (!record) { - return c.json({ id, revoked: false }, 404) + return c.json({ + ...localError('REVOCATION_NOT_FOUND', 'revocation record not found', { id }), + id, + revoked: false, + }, 404) } return c.json({ id, revoked: isActiveLocalRevocation(record), record }) }) @@ -2290,7 +2309,7 @@ app.post('/incidents', async (c) => { const body = await c.req.json().catch(() => ({})) const severity = typeof body.severity === 'string' ? body.severity : undefined if (severity !== 'low' && severity !== 'medium' && severity !== 'high' && severity !== 'critical') { - return c.json({ error: 'severity must be low, medium, high, or critical' }, 400) + return c.json(localError('INCIDENT_INVALID', 'severity must be low, medium, high, or critical', { field: 'severity' }), 400) } const category = typeof body.category === 'string' ? body.category : undefined @@ -2304,7 +2323,7 @@ app.post('/incidents', async (c) => { category !== 'payment_error' && category !== 'suspicious_behavior' ) { - return c.json({ error: 'category is invalid' }, 400) + return c.json(localError('INCIDENT_INVALID', 'category is invalid', { field: 'category' }), 400) } const targetAgentId = typeof body.targetAgentId === 'string' @@ -2313,12 +2332,12 @@ app.post('/incidents', async (c) => { ? body.target_agent_id : undefined if (!targetAgentId) { - return c.json({ error: 'targetAgentId is required' }, 400) + return c.json(localError('INCIDENT_INVALID', 'targetAgentId is required', { field: 'targetAgentId' }), 400) } const description = typeof body.description === 'string' ? body.description : undefined if (!description) { - return c.json({ error: 'description is required' }, 400) + return c.json(localError('INCIDENT_INVALID', 'description is required', { field: 'description' }), 400) } const record = createIncidentRecordV2({ @@ -2367,7 +2386,7 @@ app.get('/incidents/:id', (c) => { const id = c.req.param('id') const record = localIncidentRecords.get(id) if (!record) { - return c.json({ error: 'incident record not found', id }, 404) + return c.json(localError('INCIDENT_NOT_FOUND', 'incident record not found', { id }), 404) } return c.json({ record }) }) @@ -2376,7 +2395,7 @@ app.post('/incidents/:id/resolve', async (c) => { const id = c.req.param('id') const record = localIncidentRecords.get(id) if (!record) { - return c.json({ error: 'incident record not found', id }, 404) + return c.json(localError('INCIDENT_NOT_FOUND', 'incident record not found', { id }), 404) } const body = await c.req.json().catch(() => ({})) const status = body.status === 'dismissed' || body.status === 'false_positive' ? body.status : 'resolved' @@ -3155,7 +3174,7 @@ app.post('/evidence', async (c) => { const type = typeof body.type === 'string' ? body.type : undefined const actor = typeof body.actor === 'string' ? body.actor : undefined if (!type || !actor) { - return c.json({ error: 'type and actor are required' }, 400) + return c.json(localError('REQUEST_INVALID', 'type and actor are required', { fields: ['type', 'actor'] }), 400) } const event = appendRootEvidence({ type: type as EvidenceEventV2Input['type'], @@ -3208,7 +3227,7 @@ app.post('/evidence/export', async (c) => { privacyMode !== 'redacted' && privacyMode !== 'hash_only' ) { - return c.json({ error: 'privacy_mode must be public, private, redacted, or hash_only' }, 400) + return c.json(localError('EVIDENCE_PRIVACY_MODE_INVALID', 'privacy_mode must be public, private, redacted, or hash_only', { field: 'privacy_mode' }), 400) } const includeMetadata = typeof body.include_metadata === 'boolean' ? body.include_metadata @@ -3234,7 +3253,7 @@ app.get('/evidence/:eventId', (c) => { const eventId = c.req.param('eventId') const event = localEvidenceEvents.find(item => item.event_id === eventId) if (!event) { - return c.json({ error: 'evidence event not found', eventId }, 404) + return c.json(localError('EVIDENCE_EVENT_NOT_FOUND', 'evidence event not found', { eventId }), 404) } return c.json({ event, authorityGranted: false }) }) diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index a62ea29..8458c9e 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -1441,6 +1441,163 @@ describe('Agentd Service Routes', () => { }, sessionId: 'sess_missing', }) + + const missingApprovalCapability = await app.request('/approvals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(missingApprovalCapability.status).toBe(400) + await expect(missingApprovalCapability.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + retryable: false, + details: { field: 'capability' }, + }, + }) + + const missingApproval = await app.request('/approvals/app_missing/approve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(missingApproval.status).toBe(404) + await expect(missingApproval.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'APPROVAL_NOT_FOUND', + category: 'approval', + retryable: false, + details: { id: 'app_missing' }, + }, + }) + + const invalidKillSwitch = await app.request('/killswitch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType: 'service', target: 'did:fides:agent:test' }), + }) + expect(invalidKillSwitch.status).toBe(400) + await expect(invalidKillSwitch.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + details: { field: 'targetType' }, + }, + }) + + const missingKillSwitch = await app.request('/killswitch/rule_missing', { + method: 'DELETE', + }) + expect(missingKillSwitch.status).toBe(404) + await expect(missingKillSwitch.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'KILL_SWITCH_RULE_NOT_FOUND', + category: 'kill_switch', + details: { id: 'rule_missing' }, + }, + }) + + const invalidRevocation = await app.request('/revocations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType: 'unknown', targetId: 'did:fides:agent:test' }), + }) + expect(invalidRevocation.status).toBe(400) + await expect(invalidRevocation.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + details: { field: 'targetType' }, + }, + }) + + const missingRevocation = await app.request('/revocations/rev_missing') + expect(missingRevocation.status).toBe(404) + await expect(missingRevocation.json()).resolves.toMatchObject({ + authorityGranted: false, + revoked: false, + error: { + code: 'REVOCATION_NOT_FOUND', + category: 'revocation', + details: { id: 'rev_missing' }, + }, + }) + + const invalidIncident = await app.request('/incidents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ severity: 'urgent' }), + }) + expect(invalidIncident.status).toBe(400) + await expect(invalidIncident.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'INCIDENT_INVALID', + category: 'incident', + details: { field: 'severity' }, + }, + }) + + const missingIncident = await app.request('/incidents/inc_missing/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(missingIncident.status).toBe(404) + await expect(missingIncident.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'INCIDENT_NOT_FOUND', + category: 'incident', + details: { id: 'inc_missing' }, + }, + }) + + const invalidEvidenceAppend = await app.request('/evidence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'policy.evaluated' }), + }) + expect(invalidEvidenceAppend.status).toBe(400) + await expect(invalidEvidenceAppend.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + }, + }) + + const invalidEvidenceExport = await app.request('/evidence/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ privacy_mode: 'raw' }), + }) + expect(invalidEvidenceExport.status).toBe(400) + await expect(invalidEvidenceExport.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'EVIDENCE_PRIVACY_MODE_INVALID', + category: 'evidence', + details: { field: 'privacy_mode' }, + }, + }) + + const missingEvidence = await app.request('/evidence/evt_missing') + expect(missingEvidence.status).toBe(404) + await expect(missingEvidence.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'EVIDENCE_EVENT_NOT_FOUND', + category: 'evidence', + details: { eventId: 'evt_missing' }, + }, + }) }) it('serves root approval request and decision lifecycle', async () => { From b1a13c7e4a85a9c5d00c5b41386f7927c545cf97 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:25:56 +0300 Subject: [PATCH 241/282] docs(api): document typed error envelopes --- docs/api/agentd.yaml | 74 +++++++++++++++++++++++ tests/e2e/agentd-openapi-contract.test.ts | 20 ++++++ 2 files changed, 94 insertions(+) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 8994451..7abfeff 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -1671,7 +1671,81 @@ components: required: [error] properties: error: + oneOf: + - type: string + description: Legacy string error message. + - $ref: "#/components/schemas/ErrorEnvelope" + authorityGranted: + type: boolean + description: Present on root v2 authority-sensitive failures. + + ErrorEnvelope: + type: object + required: [code, category, severity, retryable, message] + properties: + code: + type: string + enum: + - IDENTITY_INVALID_SIGNATURE + - IDENTITY_KEY_UNBOUND + - AGENT_CARD_INVALID_SIGNATURE + - AGENT_CARD_EXPIRED + - AGENT_CARD_REVOKED + - CAPABILITY_NOT_FOUND + - CAPABILITY_SCHEMA_INVALID + - TRUST_BELOW_THRESHOLD + - POLICY_DENIED + - APPROVAL_REQUIRED + - APPROVAL_NOT_FOUND + - SESSION_EXPIRED + - SESSION_NOT_FOUND + - SESSION_SCOPE_INVALID + - ATTESTATION_INVALID + - ATTESTATION_EXPIRED + - DHT_POINTER_TAMPERED + - DHT_POINTER_EXPIRED + - EVIDENCE_CHAIN_BROKEN + - EVIDENCE_EVENT_NOT_FOUND + - EVIDENCE_PRIVACY_MODE_INVALID + - REVOCATION_ACTIVE + - REVOCATION_NOT_FOUND + - INCIDENT_ACTIVE + - INCIDENT_INVALID + - INCIDENT_NOT_FOUND + - KILL_SWITCH_ACTIVE + - KILL_SWITCH_RULE_NOT_FOUND + - VERSION_INCOMPATIBLE + - REQUEST_INVALID + category: + type: string + enum: + - identity + - agent_card + - capability + - trust + - policy + - approval + - session + - attestation + - discovery + - dht + - evidence + - revocation + - incident + - kill_switch + - version + - request + - internal + severity: type: string + enum: [info, warning, error, critical] + retryable: + type: boolean + message: + type: string + details: + type: object + additionalProperties: true HealthResponse: type: object diff --git a/tests/e2e/agentd-openapi-contract.test.ts b/tests/e2e/agentd-openapi-contract.test.ts index c5b73da..df9d73b 100644 --- a/tests/e2e/agentd-openapi-contract.test.ts +++ b/tests/e2e/agentd-openapi-contract.test.ts @@ -264,6 +264,26 @@ describe('Agentd OpenAPI contract', () => { expect(extractSchemaPropertyBlock(openApi, 'LocalSessionResponse', 'versionNegotiation')).toContain('VersionNegotiationRecord') }) + it('documents typed ErrorEnvelope responses for root v2 failures', () => { + expect(extractSchemaRequired(openApi, 'ErrorEnvelope')).toEqual([ + 'code', + 'category', + 'severity', + 'retryable', + 'message', + ]) + const errorResponse = extractSchemaPropertyBlock(openApi, 'ErrorResponse', 'error') + expect(errorResponse).toContain('ErrorEnvelope') + const envelope = extractSchemaBlock(openApi, 'ErrorEnvelope') + expect(envelope).toContain('REQUEST_INVALID') + expect(envelope).toContain('APPROVAL_NOT_FOUND') + expect(envelope).toContain('KILL_SWITCH_RULE_NOT_FOUND') + expect(envelope).toContain('REVOCATION_NOT_FOUND') + expect(envelope).toContain('INCIDENT_NOT_FOUND') + expect(envelope).toContain('EVIDENCE_EVENT_NOT_FOUND') + expect(envelope).toContain('EVIDENCE_PRIVACY_MODE_INVALID') + }) + it('keeps root v2 runtime routes documented in OpenAPI', () => { const runtimeOperations = extractAgentdRuntimeRoutes(agentdSource) .filter(operation => operation.path.startsWith('/')) From fcb473d264bd9cd4d093b8d856f6d79c4230eeaf Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:27:09 +0300 Subject: [PATCH 242/282] feat(sdk): expose agentd typed errors --- packages/sdk/src/agentd/client.ts | 22 +++++++++++++++++++-- packages/sdk/test/agentd.test.ts | 33 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/agentd/client.ts b/packages/sdk/src/agentd/client.ts index 94c89eb..b8cd6e0 100644 --- a/packages/sdk/src/agentd/client.ts +++ b/packages/sdk/src/agentd/client.ts @@ -3,10 +3,12 @@ import { createIncidentRecord, createRevocationRecord, deriveEd25519PublicKeyHex, + isErrorEnvelope, signDelegationToken, signIncidentRecord, signRevocationRecord, type DelegationToken as CoreDelegationToken, + type ErrorEnvelope, type IncidentRecord as CoreIncidentRecord, type RevocationRecord as CoreRevocationRecord, } from '@fides/core' @@ -239,7 +241,8 @@ export class AgentdError extends Error { constructor( message: string, readonly status?: number, - readonly payload?: unknown + readonly payload?: unknown, + readonly error?: ErrorEnvelope ) { super(message) this.name = 'AgentdError' @@ -404,7 +407,9 @@ export class AgentdClient { const text = await response.text() const payload = text ? JSON.parse(text) : {} if (!response.ok) { - throw new AgentdError(`agentd request failed: ${response.status}`, response.status, payload) + const envelope = extractErrorEnvelope(payload) + const message = envelope?.message ?? extractStringError(payload) ?? `agentd request failed: ${response.status}` + throw new AgentdError(message, response.status, payload, envelope) } return payload as T } @@ -413,3 +418,16 @@ export class AgentdClient { return this.options.baseUrl.replace(/\/$/, '') } } + +function extractErrorEnvelope(payload: unknown): ErrorEnvelope | undefined { + if (isErrorEnvelope(payload)) return payload + if (!payload || typeof payload !== 'object') return undefined + const error = (payload as { error?: unknown }).error + return isErrorEnvelope(error) ? error : undefined +} + +function extractStringError(payload: unknown): string | undefined { + if (!payload || typeof payload !== 'object') return undefined + const error = (payload as { error?: unknown }).error + return typeof error === 'string' ? error : undefined +} diff --git a/packages/sdk/test/agentd.test.ts b/packages/sdk/test/agentd.test.ts index 305961f..5be544e 100644 --- a/packages/sdk/test/agentd.test.ts +++ b/packages/sdk/test/agentd.test.ts @@ -234,6 +234,39 @@ describe('AgentdClient', () => { } satisfies Partial) }) + it('throws typed agentd errors when responses carry ErrorEnvelope payloads', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 409, + text: async () => JSON.stringify({ + error: { + code: 'POLICY_DENIED', + category: 'policy', + severity: 'error', + retryable: false, + message: 'Policy denied the request', + details: { reason_codes: ['LOW_TRUST_HIGH_RISK'] }, + }, + authorityGranted: false, + }), + }) + + await expect(client.authorize({ + agentDid: 'did:fides:agent', + capabilityId: 'payments.execute', + })).rejects.toMatchObject({ + name: 'AgentdError', + status: 409, + message: 'Policy denied the request', + error: { + code: 'POLICY_DENIED', + category: 'policy', + retryable: false, + details: { reason_codes: ['LOW_TRUST_HIGH_RISK'] }, + }, + } satisfies Partial) + }) + it('creates signed sessions from authority inputs', async () => { mockFetch.mockResolvedValueOnce({ ok: true, From 2a31bf1e736038b50f34fabe9123f50ae8f9ba69 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:32:21 +0300 Subject: [PATCH 243/282] feat(agentd): type remaining root v2 errors --- docs/api/agentd.yaml | 4 + docs/protocol/error-vocabulary.md | 4 + docs/status/fides-v2-implementation-status.md | 5 +- packages/core/src/errors.ts | 24 +++ packages/core/test/errors.test.ts | 16 ++ services/agentd/src/index.ts | 94 ++++++----- services/agentd/test/routes.test.ts | 156 +++++++++++++++++- tests/e2e/agentd-openapi-contract.test.ts | 4 + 8 files changed, 259 insertions(+), 48 deletions(-) diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 7abfeff..e8545e7 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -1688,9 +1688,12 @@ components: enum: - IDENTITY_INVALID_SIGNATURE - IDENTITY_KEY_UNBOUND + - IDENTITY_NOT_FOUND - AGENT_CARD_INVALID_SIGNATURE - AGENT_CARD_EXPIRED - AGENT_CARD_REVOKED + - AGENT_CARD_NOT_FOUND + - AGENT_NOT_REGISTERED - CAPABILITY_NOT_FOUND - CAPABILITY_SCHEMA_INVALID - TRUST_BELOW_THRESHOLD @@ -1702,6 +1705,7 @@ components: - SESSION_SCOPE_INVALID - ATTESTATION_INVALID - ATTESTATION_EXPIRED + - ATTESTATION_NOT_FOUND - DHT_POINTER_TAMPERED - DHT_POINTER_EXPIRED - EVIDENCE_CHAIN_BROKEN diff --git a/docs/protocol/error-vocabulary.md b/docs/protocol/error-vocabulary.md index da74917..95a9a0e 100644 --- a/docs/protocol/error-vocabulary.md +++ b/docs/protocol/error-vocabulary.md @@ -27,8 +27,11 @@ Examples: - `IDENTITY_INVALID_SIGNATURE` - `IDENTITY_KEY_UNBOUND` +- `IDENTITY_NOT_FOUND` - `AGENT_CARD_INVALID_SIGNATURE` - `AGENT_CARD_EXPIRED` +- `AGENT_CARD_NOT_FOUND` +- `AGENT_NOT_REGISTERED` - `CAPABILITY_NOT_FOUND` - `TRUST_BELOW_THRESHOLD` - `POLICY_DENIED` @@ -37,6 +40,7 @@ Examples: - `SESSION_EXPIRED` - `SESSION_NOT_FOUND` - `ATTESTATION_INVALID` +- `ATTESTATION_NOT_FOUND` - `DHT_POINTER_TAMPERED` - `EVIDENCE_CHAIN_BROKEN` - `EVIDENCE_EVENT_NOT_FOUND` diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 57149de..480d3c8 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -18,8 +18,9 @@ Last verified locally: 2026-05-30. - `pnpm agentd ` - `pnpm agentd:dev` - Canonical signing model for signed protocol objects. -- Typed error envelopes on session, invocation, approval, kill switch, - revocation, incident, and evidence failure paths. +- Typed error envelopes on root v2 identity, AgentCard, discovery, trust, + reputation, policy, session, invocation, approval, kill switch, revocation, + incident, attestation, and evidence failure paths. - Signed AgentCards and capability descriptors. - Candidate-only discovery across local, well-known, registry, relay, DHT, and federation-ready surfaces. diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index d599dfd..5f0caf9 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -32,6 +32,12 @@ export const FIDES_ERROR_CODES = { retryable: false, message: 'Identity DID is not bound to the advertised public key', }, + IDENTITY_NOT_FOUND: { + category: 'identity', + severity: 'error', + retryable: false, + message: 'Identity was not found', + }, AGENT_CARD_INVALID_SIGNATURE: { category: 'agent_card', severity: 'error', @@ -50,6 +56,18 @@ export const FIDES_ERROR_CODES = { retryable: false, message: 'AgentCard is revoked', }, + AGENT_CARD_NOT_FOUND: { + category: 'agent_card', + severity: 'error', + retryable: false, + message: 'AgentCard was not found', + }, + AGENT_NOT_REGISTERED: { + category: 'discovery', + severity: 'error', + retryable: false, + message: 'Agent is not registered', + }, CAPABILITY_NOT_FOUND: { category: 'capability', severity: 'error', @@ -116,6 +134,12 @@ export const FIDES_ERROR_CODES = { retryable: true, message: 'Runtime attestation is expired', }, + ATTESTATION_NOT_FOUND: { + category: 'attestation', + severity: 'error', + retryable: false, + message: 'Attestation was not found', + }, DHT_POINTER_TAMPERED: { category: 'dht', severity: 'critical', diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts index fac7334..0120c77 100644 --- a/packages/core/test/errors.test.ts +++ b/packages/core/test/errors.test.ts @@ -62,6 +62,22 @@ describe('error envelopes', () => { category: 'request', retryable: false, }) + expect(createErrorEnvelope('IDENTITY_NOT_FOUND')).toMatchObject({ + code: 'IDENTITY_NOT_FOUND', + category: 'identity', + }) + expect(createErrorEnvelope('AGENT_CARD_NOT_FOUND')).toMatchObject({ + code: 'AGENT_CARD_NOT_FOUND', + category: 'agent_card', + }) + expect(createErrorEnvelope('AGENT_NOT_REGISTERED')).toMatchObject({ + code: 'AGENT_NOT_REGISTERED', + category: 'discovery', + }) + expect(createErrorEnvelope('ATTESTATION_NOT_FOUND')).toMatchObject({ + code: 'ATTESTATION_NOT_FOUND', + category: 'attestation', + }) expect(createErrorEnvelope('APPROVAL_NOT_FOUND')).toMatchObject({ code: 'APPROVAL_NOT_FOUND', category: 'approval', diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index a4c65bf..cc2d425 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -862,7 +862,7 @@ app.post('/identities', async (c) => { const body = await c.req.json().catch(() => ({})) const type = body.type if (type !== 'agent' && type !== 'publisher' && type !== 'principal') { - return c.json({ error: 'type must be agent, publisher, or principal' }, 400) + return c.json(localError('REQUEST_INVALID', 'type must be agent, publisher, or principal', { field: 'type' }), 400) } const record = await createLocalIdentity(type, { @@ -883,7 +883,7 @@ app.get('/identities/:id', (c) => { const id = c.req.param('id') const record = localIdentities.get(id) if (!record) { - return c.json({ error: 'identity not found', id }, 404) + return c.json(localError('IDENTITY_NOT_FOUND', 'identity not found', { id }), 404) } return c.json(safeIdentityRecord(record)) }) @@ -899,12 +899,12 @@ app.post('/agent-cards', async (c) => { ? body.identity.did : undefined if (!did) { - return c.json({ error: 'identity.did or agentId is required' }, 400) + return c.json(localError('REQUEST_INVALID', 'identity.did or agentId is required', { fields: ['identity.did', 'agentId'] }), 400) } const localIdentity = localIdentities.get(did) if (!localIdentity || localIdentity.type !== 'agent') { - return c.json({ error: 'agent identity not found in local daemon', did }, 404) + return c.json(localError('IDENTITY_NOT_FOUND', 'agent identity not found in local daemon', { did }), 404) } const capabilities = Array.isArray(body.capabilities) @@ -931,7 +931,7 @@ app.post('/agent-cards', async (c) => { : undefined const publisher = publisherId ? localIdentities.get(publisherId) : undefined if (publisherId && (!publisher || publisher.type !== 'publisher')) { - return c.json({ error: 'publisher identity not found in local daemon', publisherId }, 404) + return c.json(localError('IDENTITY_NOT_FOUND', 'publisher identity not found in local daemon', { publisherId }), 404) } const runtimeAttestationIds: string[] = Array.isArray(body.runtimeAttestationIds) ? body.runtimeAttestationIds.map(String) @@ -942,7 +942,7 @@ app.post('/agent-cards', async (c) => { .map((id: string) => localRuntimeAttestations.get(id)) .filter((attestation: RuntimeAttestation | undefined): attestation is RuntimeAttestation => Boolean(attestation)) if (runtimeAttestationIds.length !== runtimeAttestations.length) { - return c.json({ error: 'one or more runtime attestations were not found', runtimeAttestationIds }, 404) + return c.json(localError('ATTESTATION_NOT_FOUND', 'one or more runtime attestations were not found', { runtimeAttestationIds }), 404) } const card = normalizeAgentCard({ @@ -987,11 +987,11 @@ app.post('/agent-cards/:id/sign', async (c) => { const id = c.req.param('id') const card = localAgentCards.get(id) if (!card) { - return c.json({ error: 'AgentCard not found', id }, 404) + return c.json(localError('AGENT_CARD_NOT_FOUND', 'AgentCard not found', { id }), 404) } const identity = localIdentities.get(card.identity.did) if (!identity) { - return c.json({ error: 'AgentCard identity key not found', did: card.identity.did }, 404) + return c.json(localError('IDENTITY_NOT_FOUND', 'AgentCard identity key not found', { did: card.identity.did }), 404) } const signed = await signAgentCard(card, Buffer.from(identity.privateKeyHex, 'hex'), card.identity.did) @@ -1015,7 +1015,10 @@ app.post('/agent-cards/:id/verify', async (c) => { const card = localAgentCards.get(id) if (!card) { - return c.json({ valid: false, error: 'AgentCard not found', id }, 404) + return c.json({ + valid: false, + ...localError('AGENT_CARD_NOT_FOUND', 'AgentCard not found', { id }), + }, 404) } const validation = validateAgentCard(card) return c.json({ valid: validation.valid, signed: false, validation }) @@ -1025,7 +1028,7 @@ app.get('/agent-cards/:id', (c) => { const id = c.req.param('id') const card = localAgentCards.get(id) if (!card) { - return c.json({ error: 'AgentCard not found', id }, 404) + return c.json(localError('AGENT_CARD_NOT_FOUND', 'AgentCard not found', { id }), 404) } return c.json({ card, @@ -1046,19 +1049,19 @@ app.post('/agents/register', async (c) => { ? body.agent_id : undefined if (!cardId) { - return c.json({ error: 'agentCardId is required' }, 400) + return c.json(localError('REQUEST_INVALID', 'agentCardId is required', { field: 'agentCardId' }), 400) } const card = localAgentCards.get(cardId) if (!card) { - return c.json({ error: 'AgentCard not found', cardId }, 404) + return c.json(localError('AGENT_CARD_NOT_FOUND', 'AgentCard not found', { cardId }), 404) } const signedCard = localSignedAgentCards.get(card.id) if (!signedCard) { - return c.json({ error: 'Identity-bound signed AgentCard is required before registration', cardId }, 400) + return c.json(localError('AGENT_CARD_INVALID_SIGNATURE', 'Identity-bound signed AgentCard is required before registration', { cardId }), 400) } if (!await verifySignedAgentCardIdentity(signedCard)) { - return c.json({ error: 'Signed AgentCard is not bound to the advertised agent identity', cardId }, 400) + return c.json(localError('IDENTITY_KEY_UNBOUND', 'Signed AgentCard is not bound to the advertised agent identity', { cardId }), 400) } const record: LocalRegisteredAgent = { @@ -1087,7 +1090,7 @@ app.get('/agents/:id', (c) => { const id = c.req.param('id') const record = localAgents.get(id) if (!record) { - return c.json({ error: 'agent not registered', id }, 404) + return c.json(localError('AGENT_NOT_REGISTERED', 'agent not registered', { id }), 404) } return c.json({ @@ -1203,7 +1206,7 @@ function dhtPointerRecordOnly(pointer: Record): DHTPointerRecor async function localDiscoveryResult(body: Record, provider = 'local') { const capability = typeof body.capability === 'string' ? body.capability : undefined if (!capability) { - return { error: 'capability is required' as const } + return { error: localError('REQUEST_INVALID', 'capability is required', { field: 'capability' }) } } const rejectedCandidates: Array> = [] @@ -1341,21 +1344,21 @@ function appendDiscoveryEvidence>( app.post('/discover', async (c) => { const body = await c.req.json().catch(() => ({})) const result = await localDiscoveryResult(body) - if ('error' in result) return c.json({ error: result.error }, 400) + if ('error' in result) return c.json(result.error, 400) return c.json(appendDiscoveryEvidence('local', body, result)) }) app.post('/discover/local', async (c) => { const body = await c.req.json().catch(() => ({})) const result = await localDiscoveryResult(body, 'local') - if ('error' in result) return c.json({ error: result.error }, 400) + if ('error' in result) return c.json(result.error, 400) return c.json(appendDiscoveryEvidence('local', body, result)) }) app.post('/discover/well-known', async (c) => { const body = await c.req.json().catch(() => ({})) const result = await localDiscoveryResult(body, 'well-known') - if ('error' in result) return c.json({ error: result.error }, 400) + if ('error' in result) return c.json(result.error, 400) return c.json(appendDiscoveryEvidence('well-known', body, result)) }) @@ -1375,12 +1378,12 @@ app.post('/trust/evaluate', async (c) => { : undefined if (!agentId || !capability) { - return c.json({ error: 'agentId and capability are required' }, 400) + return c.json(localError('REQUEST_INVALID', 'agentId and capability are required', { fields: ['agentId', 'capability'] }), 400) } const trust = computeLocalTrustResult(agentId, capability) if (!trust) { - return c.json({ error: 'registered agent capability not found', agentId, capability }, 404) + return c.json(localError('CAPABILITY_NOT_FOUND', 'registered agent capability not found', { agentId, capability }), 404) } return c.json({ @@ -1410,12 +1413,12 @@ app.post('/reputation/update', async (c) => { : undefined if (!agentId || !capability) { - return c.json({ error: 'agentId and capability are required' }, 400) + return c.json(localError('REQUEST_INVALID', 'agentId and capability are required', { fields: ['agentId', 'capability'] }), 400) } const found = findLocalCapability(agentId, capability) if (!found) { - return c.json({ error: 'registered agent capability not found', agentId, capability }, 404) + return c.json(localError('CAPABILITY_NOT_FOUND', 'registered agent capability not found', { agentId, capability }), 404) } const reputation = computeCapabilityReputation({ @@ -1456,17 +1459,17 @@ app.post('/policy/evaluate', async (c) => { : undefined if (!targetAgentId || !capabilityId) { - return c.json({ error: 'agentId and capability are required' }, 400) + return c.json(localError('REQUEST_INVALID', 'agentId and capability are required', { fields: ['agentId', 'capability'] }), 400) } const found = findLocalCapability(targetAgentId, capabilityId) if (!found) { - return c.json({ error: 'registered agent capability not found', agentId: targetAgentId, capability: capabilityId }, 404) + return c.json(localError('CAPABILITY_NOT_FOUND', 'registered agent capability not found', { agentId: targetAgentId, capability: capabilityId }), 404) } const trustResult = computeLocalTrustResult(targetAgentId, capabilityId) if (!trustResult) { - return c.json({ error: 'trust result unavailable', agentId: targetAgentId, capability: capabilityId }, 404) + return c.json(localError('TRUST_BELOW_THRESHOLD', 'trust result unavailable', { agentId: targetAgentId, capability: capabilityId }), 404) } const policy = evaluateFidesPolicy({ @@ -1514,7 +1517,7 @@ app.post('/delegations', async (c) => { : [] if (!delegator || !delegatee || capabilities.length === 0) { - return c.json({ error: 'delegator, delegatee, and capabilities are required' }, 400) + return c.json(localError('REQUEST_INVALID', 'delegator, delegatee, and capabilities are required', { fields: ['delegator', 'delegatee', 'capabilities'] }), 400) } const expiresAt = typeof body.expiresAt === 'string' @@ -2417,7 +2420,7 @@ app.post('/attestations', async (c) => { ? body.agent_id : undefined if (!agentId) { - return c.json({ error: 'agentId is required' }, 400) + return c.json(localError('REQUEST_INVALID', 'agentId is required', { field: 'agentId' }), 400) } const codeHash = typeof body.codeHash === 'string' @@ -2436,7 +2439,7 @@ app.post('/attestations', async (c) => { ? body.policy_hash : undefined if (!codeHash || !runtimeHash || !policyHash) { - return c.json({ error: 'codeHash, runtimeHash, and policyHash are required' }, 400) + return c.json(localError('REQUEST_INVALID', 'codeHash, runtimeHash, and policyHash are required', { fields: ['codeHash', 'runtimeHash', 'policyHash'] }), 400) } const attestation = await runtimeAttestationProvider.issue({ @@ -2479,7 +2482,7 @@ app.get('/attestations/:id', (c) => { const id = c.req.param('id') const attestation = localRuntimeAttestations.get(id) if (!attestation) { - return c.json({ error: 'attestation not found', id }, 404) + return c.json(localError('ATTESTATION_NOT_FOUND', 'attestation not found', { id }), 404) } return c.json({ attestation }) }) @@ -2496,7 +2499,12 @@ app.post('/attestations/:id/verify', async (c) => { privacy_mode: 'hash_only', metadata: { attestation_id: id }, }) - return c.json({ id, valid: false, error: 'attestation not found', evidenceRefs: [failed.event_id], authorityGranted: false }, 404) + return c.json({ + id, + valid: false, + ...localError('ATTESTATION_NOT_FOUND', 'attestation not found', { id }), + evidenceRefs: [failed.event_id], + }, 404) } const valid = await runtimeAttestationProvider.verify(attestation) const event = appendRootEvidence({ @@ -2529,9 +2537,8 @@ function issueLocalIdentityAttestation(body: Record): { body: R return { status: 404, body: { - error: 'identity not found', + ...localError('IDENTITY_NOT_FOUND', 'identity not found', { identity: identityId }), identity: identityId, - authorityGranted: false, }, } } @@ -2541,9 +2548,8 @@ function issueLocalIdentityAttestation(body: Record): { body: R return { status: 400, body: { - error: 'attestation type and value are required', + ...localError('REQUEST_INVALID', 'attestation type and value are required', { fields: ['type', 'value'] }), identity: identityId, - authorityGranted: false, }, } } @@ -2654,7 +2660,7 @@ app.post('/dht/start', (c) => { app.post('/dht/publish', async (c) => { const body = await c.req.json() if (!body.capability) { - return c.json({ error: 'capability is required' }, 400) + return c.json(localError('REQUEST_INVALID', 'capability is required', { field: 'capability' }), 400) } const capability = String(body.capability) @@ -2678,7 +2684,7 @@ app.post('/dht/publish', async (c) => { if (card && identity) { if (!card.capabilities.some(candidate => candidate.id === capability)) { - return c.json({ error: 'AgentCard does not advertise capability', capability, cardId: card.id }, 400) + return c.json(localError('CAPABILITY_NOT_FOUND', 'AgentCard does not advertise capability', { capability, cardId: card.id }), 400) } const publisherId = typeof body.publisherId === 'string' ? body.publisherId @@ -2687,7 +2693,7 @@ app.post('/dht/publish', async (c) => { : card.publisher?.did ?? card.identity.did const publisherIdentity = localIdentities.get(publisherId) if (!publisherIdentity) { - return c.json({ error: 'DHT pointer publisher key not found', publisherId }, 404) + return c.json(localError('IDENTITY_NOT_FOUND', 'DHT pointer publisher key not found', { publisherId }), 404) } const pointer = await signDHTPointerRecord(createDHTPointerRecord({ @@ -2943,11 +2949,11 @@ app.post('/registry/publish', async (c) => { ? body.cardId : undefined if (!cardId) { - return c.json({ error: 'agentCardId is required' }, 400) + return c.json(localError('REQUEST_INVALID', 'agentCardId is required', { field: 'agentCardId' }), 400) } const record = await localRegistryRecordFor(cardId, body.mode === 'private' ? 'private' : 'public') if (!record) { - return c.json({ error: 'registered local AgentCard not found', cardId }, 404) + return c.json(localError('AGENT_CARD_NOT_FOUND', 'registered local AgentCard not found', { cardId }), 404) } localRegistryRecords.set(String(record.id), record) return c.json({ accepted: true, record, authorityGranted: false }, 201) @@ -3071,14 +3077,14 @@ app.post('/relay/register', async (c) => { ? body.agent_id : undefined if (!agentId) { - return c.json({ error: 'agentId is required' }, 400) + return c.json(localError('REQUEST_INVALID', 'agentId is required', { field: 'agentId' }), 400) } const record = await localRelayRecordFor( agentId, Array.isArray(body.endpointHints) ? body.endpointHints : [] ) if (!record) { - return c.json({ error: 'registered local agent not found', agentId }, 404) + return c.json(localError('AGENT_NOT_REGISTERED', 'registered local agent not found', { agentId }), 404) } localRelayRecords.set(agentId, record) return c.json({ accepted: true, record, authorityGranted: false }, 201) @@ -3147,12 +3153,12 @@ app.get('/.well-known/agents/*', (c) => { const rawId = c.req.path.slice('/.well-known/agents/'.length).replace(/\.json$/, '') const id = decodeURIComponent(rawId) if (!id) { - return c.json({ error: 'agent id is required' }, 400) + return c.json(localError('REQUEST_INVALID', 'agent id is required', { field: 'agentId' }), 400) } const registered = localAgents.get(id) const card = registered ? localAgentCards.get(registered.cardId) : undefined if (!registered || !card) { - return c.json({ error: 'registered local agent not found', agentId: id }, 404) + return c.json(localError('AGENT_NOT_REGISTERED', 'registered local agent not found', { agentId: id }), 404) } return c.json({ agentId: id, diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 8458c9e..272dacc 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -594,8 +594,13 @@ describe('Agentd Service Routes', () => { expect(registered.status).toBe(400) expect(await registered.json()).toMatchObject({ - error: 'Identity-bound signed AgentCard is required before registration', - cardId: identity.did, + authorityGranted: false, + error: { + code: 'AGENT_CARD_INVALID_SIGNATURE', + category: 'agent_card', + message: 'Identity-bound signed AgentCard is required before registration', + details: { cardId: identity.did }, + }, }) }) @@ -1442,6 +1447,153 @@ describe('Agentd Service Routes', () => { sessionId: 'sess_missing', }) + const invalidIdentity = await app.request('/identities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'service' }), + }) + expect(invalidIdentity.status).toBe(400) + await expect(invalidIdentity.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + details: { field: 'type' }, + }, + }) + + const missingIdentity = await app.request('/identities/did:fides:missing') + expect(missingIdentity.status).toBe(404) + await expect(missingIdentity.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'IDENTITY_NOT_FOUND', + category: 'identity', + details: { id: 'did:fides:missing' }, + }, + }) + + const invalidAgentCard = await app.request('/agent-cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(invalidAgentCard.status).toBe(400) + await expect(invalidAgentCard.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + details: { fields: ['identity.did', 'agentId'] }, + }, + }) + + const missingAgentCard = await app.request('/agent-cards/card_missing') + expect(missingAgentCard.status).toBe(404) + await expect(missingAgentCard.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'AGENT_CARD_NOT_FOUND', + category: 'agent_card', + details: { id: 'card_missing' }, + }, + }) + + const missingAgent = await app.request('/agents/did:fides:agent:missing') + expect(missingAgent.status).toBe(404) + await expect(missingAgent.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'AGENT_NOT_REGISTERED', + category: 'discovery', + }, + }) + + const invalidDiscovery = await app.request('/discover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(invalidDiscovery.status).toBe(400) + await expect(invalidDiscovery.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + details: { field: 'capability' }, + }, + }) + + const invalidTrust = await app.request('/trust/evaluate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentId: 'did:fides:agent' }), + }) + expect(invalidTrust.status).toBe(400) + await expect(invalidTrust.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + }, + }) + + const invalidAttestation = await app.request('/attestations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(invalidAttestation.status).toBe(400) + await expect(invalidAttestation.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + details: { field: 'agentId' }, + }, + }) + + const missingAttestation = await app.request('/attestations/att_missing') + expect(missingAttestation.status).toBe(404) + await expect(missingAttestation.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'ATTESTATION_NOT_FOUND', + category: 'attestation', + details: { id: 'att_missing' }, + }, + }) + + const invalidRegistryPublish = await app.request('/registry/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(invalidRegistryPublish.status).toBe(400) + await expect(invalidRegistryPublish.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + details: { field: 'agentCardId' }, + }, + }) + + const invalidRelayRegister = await app.request('/relay/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(invalidRelayRegister.status).toBe(400) + await expect(invalidRelayRegister.json()).resolves.toMatchObject({ + authorityGranted: false, + error: { + code: 'REQUEST_INVALID', + category: 'request', + details: { field: 'agentId' }, + }, + }) + const missingApprovalCapability = await app.request('/approvals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/tests/e2e/agentd-openapi-contract.test.ts b/tests/e2e/agentd-openapi-contract.test.ts index df9d73b..ad030a9 100644 --- a/tests/e2e/agentd-openapi-contract.test.ts +++ b/tests/e2e/agentd-openapi-contract.test.ts @@ -276,6 +276,10 @@ describe('Agentd OpenAPI contract', () => { expect(errorResponse).toContain('ErrorEnvelope') const envelope = extractSchemaBlock(openApi, 'ErrorEnvelope') expect(envelope).toContain('REQUEST_INVALID') + expect(envelope).toContain('IDENTITY_NOT_FOUND') + expect(envelope).toContain('AGENT_CARD_NOT_FOUND') + expect(envelope).toContain('AGENT_NOT_REGISTERED') + expect(envelope).toContain('ATTESTATION_NOT_FOUND') expect(envelope).toContain('APPROVAL_NOT_FOUND') expect(envelope).toContain('KILL_SWITCH_RULE_NOT_FOUND') expect(envelope).toContain('REVOCATION_NOT_FOUND') From 8b472a008d02a58b4d31d5e2bd87dc20779226ce Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:35:45 +0300 Subject: [PATCH 244/282] feat(cli): surface typed agentd errors --- docs/protocol/error-vocabulary.md | 4 ++ docs/status/fides-v2-implementation-status.md | 1 + packages/cli/src/commands/authority-utils.ts | 57 ++++++++++++------- packages/cli/test/commands.test.ts | 29 ++++++++++ 4 files changed, 70 insertions(+), 21 deletions(-) diff --git a/docs/protocol/error-vocabulary.md b/docs/protocol/error-vocabulary.md index 95a9a0e..849a8dc 100644 --- a/docs/protocol/error-vocabulary.md +++ b/docs/protocol/error-vocabulary.md @@ -66,3 +66,7 @@ payload belongs to a more specific protocol family, such as `INCIDENT_INVALID` or `EVIDENCE_PRIVACY_MODE_INVALID`. Missing root local resources use the resource-specific `*_NOT_FOUND` codes so SDKs and CLI clients do not need to parse human-readable strings. + +The CLI preserves typed root `agentd` failures when an HTTP response includes +an `ErrorEnvelope`, printing the stable code with the message, for example +`[POLICY_DENIED] Policy denied the request`. diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 480d3c8..84be5b1 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -58,6 +58,7 @@ Last verified locally: 2026-05-30. - SQLite local-state snapshot and mirror-table persistence for local inspection. - Revocation, incident, kill switch, session, and evidence policy hooks. - SDK type coverage for the main root v2 API responses. +- CLI HTTP helpers preserve root v2 `ErrorEnvelope` codes in command output. - OpenAPI route audit and response-shape contract coverage for root `agentd` demo, adversarial simulation, and non-authoritative discovery write responses. diff --git a/packages/cli/src/commands/authority-utils.ts b/packages/cli/src/commands/authority-utils.ts index c3284b9..f21a4af 100644 --- a/packages/cli/src/commands/authority-utils.ts +++ b/packages/cli/src/commands/authority-utils.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs' +import { isErrorEnvelope, type ErrorEnvelope } from '@fides/core' import * as ed from '@noble/ed25519' import { bytesToHex } from '@noble/hashes/utils' @@ -45,6 +46,38 @@ export async function derivePublicKeyHex(privateKeyHex: string): Promise return bytesToHex(publicKey) } +export class AgentdHttpError extends Error { + constructor( + readonly status: number, + readonly payload: unknown, + readonly error?: ErrorEnvelope + ) { + super(error ? `[${error.code}] ${error.message}` : `HTTP ${status}: ${JSON.stringify(payload)}`) + this.name = 'AgentdHttpError' + } +} + +function extractErrorEnvelope(payload: unknown): ErrorEnvelope | undefined { + if (isErrorEnvelope(payload)) return payload + if (!payload || typeof payload !== 'object') return undefined + const error = (payload as { error?: unknown }).error + return isErrorEnvelope(error) ? error : undefined +} + +async function parseJsonResponse(response: Response): Promise { + const text = await response.text() + return text ? JSON.parse(text) : {} +} + +async function requestJson(url: string, init: RequestInit): Promise { + const response = await fetch(url, init) + const payload = await parseJsonResponse(response) + if (!response.ok) { + throw new AgentdHttpError(response.status, payload, extractErrorEnvelope(payload)) + } + return payload +} + export async function postJson(url: string, body: unknown): Promise { const headers: Record = { 'Content-Type': 'application/json' } const apiKey = process.env.FIDES_API_KEY || process.env.SERVICE_API_KEY @@ -52,17 +85,11 @@ export async function postJson(url: string, body: unknown): Promise { headers['X-API-Key'] = apiKey } - const response = await fetch(url, { + return requestJson(url, { method: 'POST', headers, body: JSON.stringify(body), }) - const text = await response.text() - const payload = text ? JSON.parse(text) : {} - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${JSON.stringify(payload)}`) - } - return payload } export async function getJson(url: string): Promise { @@ -72,13 +99,7 @@ export async function getJson(url: string): Promise { headers['X-API-Key'] = apiKey } - const response = await fetch(url, { method: 'GET', headers }) - const text = await response.text() - const payload = text ? JSON.parse(text) : {} - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${JSON.stringify(payload)}`) - } - return payload + return requestJson(url, { method: 'GET', headers }) } export async function deleteJson(url: string): Promise { @@ -88,11 +109,5 @@ export async function deleteJson(url: string): Promise { headers['X-API-Key'] = apiKey } - const response = await fetch(url, { method: 'DELETE', headers }) - const text = await response.text() - const payload = text ? JSON.parse(text) : {} - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${JSON.stringify(payload)}`) - } - return payload + return requestJson(url, { method: 'DELETE', headers }) } diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 7e3e0b5..a50fa0c 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -2097,6 +2097,35 @@ describe('CLI Commands', () => { ); }); + it('prints typed agentd error codes for root v2 HTTP failures', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + error: { + code: 'REQUEST_INVALID', + category: 'request', + severity: 'error', + retryable: false, + message: 'agentCardId is required', + details: { field: 'agentCardId' }, + }, + authorityGranted: false, + }), { status: 400, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const { createRegistryCommand } = await import('../src/commands/registry.js'); + const cmd = createRegistryCommand(); + + await cmd.parseAsync([ + 'publish', + 'card_1', + '--agentd-url', + 'http://agentd.test/', + '--json', + ], { from: 'user' }); + + expect(console.error).toHaveBeenCalledWith('Error:', '[REQUEST_INVALID] agentCardId is required'); + expect(process.exitCode).toBe(1); + }); + it('dht publish supports signed local pointer inputs without an AgentCard URL', async () => { const mockFetch = vi.fn(async () => new Response(JSON.stringify({ accepted: true, From 292055d69c101909761d6d285bd192f1f9d81108 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:39:42 +0300 Subject: [PATCH 245/282] fix(cli): catch async entrypoint failures --- docs/status/fides-v2-implementation-status.md | 4 ++- packages/cli/src/index.ts | 7 ++++- packages/cli/test/entrypoint.test.ts | 30 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 packages/cli/test/entrypoint.test.ts diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 84be5b1..9e27497 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -58,7 +58,8 @@ Last verified locally: 2026-05-30. - SQLite local-state snapshot and mirror-table persistence for local inspection. - Revocation, incident, kill switch, session, and evidence policy hooks. - SDK type coverage for the main root v2 API responses. -- CLI HTTP helpers preserve root v2 `ErrorEnvelope` codes in command output. +- CLI HTTP helpers preserve root v2 `ErrorEnvelope` codes in command output, + and the CLI entrypoint catches async command failures with stable formatting. - OpenAPI route audit and response-shape contract coverage for root `agentd` demo, adversarial simulation, and non-authoritative discovery write responses. @@ -341,6 +342,7 @@ Observed manual smoke results: Recent v2 status/DX commits: - `2d4eb41 feat(delegation): bind session grants to protocol versions` +- `8b472a0 feat(cli): surface typed agentd errors` - `2ccaa52 test(agentd): cover provider discovery evidence refs` - `7c60719 docs: document discovery evidence events` - `22cd792 feat(agentd): emit discovery evidence events` diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 40fff3f..bf69c8f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -74,4 +74,9 @@ program.addCommand(createDemoCommand()); program.addCommand(createSimulateCommand()); program.addCommand(createInvokeCommand()); -program.parse(); +try { + await program.parseAsync(process.argv); +} catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/packages/cli/test/entrypoint.test.ts b/packages/cli/test/entrypoint.test.ts new file mode 100644 index 0000000..021ef6b --- /dev/null +++ b/packages/cli/test/entrypoint.test.ts @@ -0,0 +1,30 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('CLI entrypoint', () => { + it('formats uncaught async command failures instead of leaking rejections', () => { + const cliRoot = path.resolve(fileURLToPath(import.meta.url), '..', '..') + const result = spawnSync(process.execPath, [ + '--import', + 'tsx', + 'src/index.ts', + 'agents', + 'inspect', + 'did:fides:missing', + '--agentd-url', + 'http://127.0.0.1:9', + '--json', + ], { + cwd: cliRoot, + encoding: 'utf8', + stdio: 'pipe', + timeout: 5000, + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Error: fetch failed') + expect(result.stderr).not.toContain('UnhandledPromiseRejection') + }) +}) From 18a52f0359f1efed2503151b672479a86d4b6df2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:40:09 +0300 Subject: [PATCH 246/282] docs: refresh fides v2 dx status commits --- docs/status/fides-v2-implementation-status.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 9e27497..28810d9 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -341,8 +341,9 @@ Observed manual smoke results: Recent v2 status/DX commits: -- `2d4eb41 feat(delegation): bind session grants to protocol versions` +- `292055d fix(cli): catch async entrypoint failures` - `8b472a0 feat(cli): surface typed agentd errors` +- `2d4eb41 feat(delegation): bind session grants to protocol versions` - `2ccaa52 test(agentd): cover provider discovery evidence refs` - `7c60719 docs: document discovery evidence events` - `22cd792 feat(agentd): emit discovery evidence events` From 25bb2f6fcb7410fa5d16edf9d8c7e1f342f0e144 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:48:22 +0300 Subject: [PATCH 247/282] feat(packages): add protocol domain facades --- docs/status/fides-v2-implementation-status.md | 29 ++- packages/attestations/LICENSE | 21 ++ packages/attestations/README.md | 11 + packages/attestations/package.json | 18 ++ packages/attestations/src/index.ts | 12 + packages/attestations/tsconfig.json | 6 + packages/cards/LICENSE | 21 ++ packages/cards/README.md | 12 + packages/cards/package.json | 18 ++ packages/cards/src/index.ts | 19 ++ packages/cards/tsconfig.json | 6 + packages/crypto/LICENSE | 21 ++ packages/crypto/README.md | 15 ++ packages/crypto/package.json | 41 ++++ packages/crypto/src/index.ts | 7 + packages/crypto/tsconfig.json | 10 + packages/delegation/LICENSE | 21 ++ packages/delegation/README.md | 11 + packages/delegation/package.json | 18 ++ packages/delegation/src/index.ts | 19 ++ packages/delegation/tsconfig.json | 6 + packages/dht/LICENSE | 21 ++ packages/dht/README.md | 13 ++ packages/dht/package.json | 18 ++ packages/dht/src/index.ts | 8 + packages/dht/tsconfig.json | 6 + packages/identity/LICENSE | 21 ++ packages/identity/README.md | 16 ++ packages/identity/package.json | 41 ++++ packages/identity/src/index.ts | 24 ++ packages/identity/tsconfig.json | 10 + packages/incidents/LICENSE | 21 ++ packages/incidents/README.md | 12 + packages/incidents/package.json | 18 ++ packages/incidents/src/index.ts | 16 ++ packages/incidents/tsconfig.json | 6 + packages/invocation/LICENSE | 21 ++ packages/invocation/README.md | 11 + packages/invocation/package.json | 18 ++ packages/invocation/src/index.ts | 18 ++ packages/invocation/tsconfig.json | 6 + packages/registry/LICENSE | 21 ++ packages/registry/README.md | 11 + packages/registry/package.json | 18 ++ packages/registry/src/index.ts | 18 ++ packages/registry/tsconfig.json | 6 + packages/relay/LICENSE | 21 ++ packages/relay/README.md | 14 ++ packages/relay/package.json | 18 ++ packages/relay/src/index.ts | 3 + packages/relay/tsconfig.json | 6 + packages/reputation/LICENSE | 21 ++ packages/reputation/README.md | 14 ++ packages/reputation/package.json | 18 ++ packages/reputation/src/index.ts | 7 + packages/reputation/tsconfig.json | 6 + packages/revocation/LICENSE | 21 ++ packages/revocation/README.md | 12 + packages/revocation/package.json | 18 ++ packages/revocation/src/index.ts | 15 ++ packages/revocation/tsconfig.json | 6 + packages/trust/LICENSE | 21 ++ packages/trust/README.md | 13 ++ packages/trust/package.json | 18 ++ packages/trust/src/index.ts | 10 + packages/trust/tsconfig.json | 6 + pnpm-lock.yaml | 208 ++++++++++++++++++ scripts/public-packages.mjs | 13 ++ 68 files changed, 1224 insertions(+), 6 deletions(-) create mode 100644 packages/attestations/LICENSE create mode 100644 packages/attestations/README.md create mode 100644 packages/attestations/package.json create mode 100644 packages/attestations/src/index.ts create mode 100644 packages/attestations/tsconfig.json create mode 100644 packages/cards/LICENSE create mode 100644 packages/cards/README.md create mode 100644 packages/cards/package.json create mode 100644 packages/cards/src/index.ts create mode 100644 packages/cards/tsconfig.json create mode 100644 packages/crypto/LICENSE create mode 100644 packages/crypto/README.md create mode 100644 packages/crypto/package.json create mode 100644 packages/crypto/src/index.ts create mode 100644 packages/crypto/tsconfig.json create mode 100644 packages/delegation/LICENSE create mode 100644 packages/delegation/README.md create mode 100644 packages/delegation/package.json create mode 100644 packages/delegation/src/index.ts create mode 100644 packages/delegation/tsconfig.json create mode 100644 packages/dht/LICENSE create mode 100644 packages/dht/README.md create mode 100644 packages/dht/package.json create mode 100644 packages/dht/src/index.ts create mode 100644 packages/dht/tsconfig.json create mode 100644 packages/identity/LICENSE create mode 100644 packages/identity/README.md create mode 100644 packages/identity/package.json create mode 100644 packages/identity/src/index.ts create mode 100644 packages/identity/tsconfig.json create mode 100644 packages/incidents/LICENSE create mode 100644 packages/incidents/README.md create mode 100644 packages/incidents/package.json create mode 100644 packages/incidents/src/index.ts create mode 100644 packages/incidents/tsconfig.json create mode 100644 packages/invocation/LICENSE create mode 100644 packages/invocation/README.md create mode 100644 packages/invocation/package.json create mode 100644 packages/invocation/src/index.ts create mode 100644 packages/invocation/tsconfig.json create mode 100644 packages/registry/LICENSE create mode 100644 packages/registry/README.md create mode 100644 packages/registry/package.json create mode 100644 packages/registry/src/index.ts create mode 100644 packages/registry/tsconfig.json create mode 100644 packages/relay/LICENSE create mode 100644 packages/relay/README.md create mode 100644 packages/relay/package.json create mode 100644 packages/relay/src/index.ts create mode 100644 packages/relay/tsconfig.json create mode 100644 packages/reputation/LICENSE create mode 100644 packages/reputation/README.md create mode 100644 packages/reputation/package.json create mode 100644 packages/reputation/src/index.ts create mode 100644 packages/reputation/tsconfig.json create mode 100644 packages/revocation/LICENSE create mode 100644 packages/revocation/README.md create mode 100644 packages/revocation/package.json create mode 100644 packages/revocation/src/index.ts create mode 100644 packages/revocation/tsconfig.json create mode 100644 packages/trust/LICENSE create mode 100644 packages/trust/README.md create mode 100644 packages/trust/package.json create mode 100644 packages/trust/src/index.ts create mode 100644 packages/trust/tsconfig.json diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 28810d9..291d8bf 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -44,6 +44,9 @@ Last verified locally: 2026-05-30. discovery records, DHT records, registry records, relay records, trust results, reputation records, policy decisions, approvals, delegations, sessions, evidence events, revocations, incidents, and kill switch rules. +- Public target-structure facade packages for crypto, identity, attestations, + cards, trust, reputation, delegation, invocation, DHT, relay, registry, + revocation, and incidents. - Full local demo and adversarial simulation endpoints. - Public docs refreshed around `agentd`, `FidesClient`, candidate-only discovery, and authority-via-policy/session. @@ -117,19 +120,32 @@ Last verified locally: 2026-05-30. | Area | Current location | |------|------------------| | Protocol objects and signing | `packages/core` | +| Canonical JSON, hashing, signing facade | `packages/crypto` | +| Identity and trust anchors | `packages/identity` | +| Runtime attestations | `packages/attestations` | +| AgentCards and capabilities | `packages/cards` | | Evidence ledger | `packages/evidence` | +| Trust scoring | `packages/trust` | +| Reputation scoring | `packages/reputation` | | Policy evaluator | `packages/policy` | +| Delegation and sessions | `packages/delegation` | +| Invocation | `packages/invocation` | | Guard decision pipeline | `packages/guard` | | Runtime attestation and kill switch | `packages/runtime` | | Discovery providers | `packages/discovery` | +| DHT pointer records | `packages/dht` | +| Relay discovery facade | `packages/relay` | +| Registry and federation records | `packages/registry` | +| Revocation records | `packages/revocation` | +| Incident records | `packages/incidents` | | SDK | `packages/sdk` | | CLI | `packages/cli` | | Local daemon/API | `services/agentd` | | Adapters | `packages/adapters` | -Some target package boundaries from the v2 architecture remain consolidated in -existing packages. See `docs/architecture/implementation-plan.md` for the target -package structure. +Some target packages are currently domain facades over the TS-first core +implementation. This keeps public imports aligned with the v2 architecture +while preserving a single canonical protocol-object implementation. ## CLI Command Overview @@ -313,7 +329,8 @@ Observed manual smoke results: ## Known Limitations - The full pivot is not complete. -- The target package structure is not fully split into every final package. +- Several target package boundaries are public facades over `packages/core` + rather than independent implementations. - DHT, relay, registry, and federation are local mock/simulator surfaces rather than production networks. - Real TEE providers are adapter-ready but not implemented. @@ -329,8 +346,8 @@ Observed manual smoke results: - Keep full `pnpm verify` green before release. - Push `fides-v2-agent-trust-fabric` and open/update a PR. -- Normalize target package boundaries where the current monorepo is still - consolidated. +- Move facade internals into separate packages only when the split removes real + complexity or enables independent adapters. - Add real DHT, relay, registry, and federation adapters. - Add production TEE/build/container attestation providers. - Harden local key storage beyond prototype snapshot material. diff --git a/packages/attestations/LICENSE b/packages/attestations/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/attestations/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/attestations/README.md b/packages/attestations/README.md new file mode 100644 index 0000000..2510efe --- /dev/null +++ b/packages/attestations/README.md @@ -0,0 +1,11 @@ +# @fides/attestations + +Runtime attestation and TEE-ready provider entrypoints for FIDES v2. + +```typescript +import { MockTEEProvider, verifyRuntimeAttestation } from '@fides/attestations' +``` + +## License + +MIT diff --git a/packages/attestations/package.json b/packages/attestations/package.json new file mode 100644 index 0000000..2e7d4b1 --- /dev/null +++ b/packages/attestations/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/attestations", + "version": "0.1.0", + "description": "FIDES v2 runtime and trust attestation entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/attestations" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/core": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/attestations/src/index.ts b/packages/attestations/src/index.ts new file mode 100644 index 0000000..ce99b33 --- /dev/null +++ b/packages/attestations/src/index.ts @@ -0,0 +1,12 @@ +export { + MockTEEProvider, + NullAttestationProvider, + createRuntimeAttestation, + isRuntimeAttestationExpired, + verifyRuntimeAttestation, + type AttestationProvider, + type ContainerBuildAttestationProvider, + type RuntimeAttestation, + type RuntimeAttestationIssueInput, + type TeeAttestationProvider, +} from '@fides/core' diff --git a/packages/attestations/tsconfig.json b/packages/attestations/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/attestations/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/cards/LICENSE b/packages/cards/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/cards/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cards/README.md b/packages/cards/README.md new file mode 100644 index 0000000..ebd355d --- /dev/null +++ b/packages/cards/README.md @@ -0,0 +1,12 @@ +# @fides/cards + +AgentCard, capability descriptor, capability ontology, and risk taxonomy +entrypoints for FIDES v2. + +```typescript +import { signAgentCard, verifySignedAgentCard } from '@fides/cards' +``` + +## License + +MIT diff --git a/packages/cards/package.json b/packages/cards/package.json new file mode 100644 index 0000000..839c5c5 --- /dev/null +++ b/packages/cards/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/cards", + "version": "0.1.0", + "description": "FIDES v2 AgentCard and capability descriptor entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/cards" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/core": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/cards/src/index.ts b/packages/cards/src/index.ts new file mode 100644 index 0000000..b087f6a --- /dev/null +++ b/packages/cards/src/index.ts @@ -0,0 +1,19 @@ +export { + classifyCapabilityRisk, + createCapabilityDescriptor, + findCapabilityOntologyEntry, + normalizeAgentCard, + parseCapabilityId, + signAgentCard, + validateAgentCard, + verifySignedAgentCard, + verifySignedAgentCardIdentity, + type AgentCard, + type CapabilityControl, + type CapabilityDescriptor, + type CapabilityOntologyEntry, + type EndpointDescriptor, + type PolicyRequirement, + type SignedAgentCard, + type TransportDescriptor, +} from '@fides/core' diff --git a/packages/cards/tsconfig.json b/packages/cards/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/cards/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/crypto/LICENSE b/packages/crypto/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/crypto/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/crypto/README.md b/packages/crypto/README.md new file mode 100644 index 0000000..2fe9aaf --- /dev/null +++ b/packages/crypto/README.md @@ -0,0 +1,15 @@ +# @fides/crypto + +Canonical JSON, hashing, and signing entrypoints for FIDES v2. + +This package is a domain facade over the TS-first core implementation. It keeps +crypto-related imports stable while Rust adapters for canonicalization, hashing, +Merkle, DAG, or performance-critical primitives remain optional. + +```typescript +import { canonicalDigest, signObject } from '@fides/crypto' +``` + +## License + +MIT diff --git a/packages/crypto/package.json b/packages/crypto/package.json new file mode 100644 index 0000000..3c41a4c --- /dev/null +++ b/packages/crypto/package.json @@ -0,0 +1,41 @@ +{ + "name": "@fides/crypto", + "version": "0.1.0", + "description": "FIDES v2 canonical JSON, hashing, and signing entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/crypto" + }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { + "node": ">=22.0.0" + }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run --passWithNoTests", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts new file mode 100644 index 0000000..bdc7c03 --- /dev/null +++ b/packages/crypto/src/index.ts @@ -0,0 +1,7 @@ +export { + canonicalDigest, + canonicalJson, + signObject, + verifyObject, + type SignedObject, +} from '@fides/core' diff --git a/packages/crypto/tsconfig.json b/packages/crypto/tsconfig.json new file mode 100644 index 0000000..31b83a6 --- /dev/null +++ b/packages/crypto/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/delegation/LICENSE b/packages/delegation/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/delegation/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/delegation/README.md b/packages/delegation/README.md new file mode 100644 index 0000000..2900549 --- /dev/null +++ b/packages/delegation/README.md @@ -0,0 +1,11 @@ +# @fides/delegation + +DelegationToken and SessionGrant entrypoints for scoped FIDES v2 authority. + +```typescript +import { createDelegationToken, createSessionGrantV2 } from '@fides/delegation' +``` + +## License + +MIT diff --git a/packages/delegation/package.json b/packages/delegation/package.json new file mode 100644 index 0000000..2ad4c1a --- /dev/null +++ b/packages/delegation/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/delegation", + "version": "0.1.0", + "description": "FIDES v2 delegation token and session grant entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/delegation" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/core": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/delegation/src/index.ts b/packages/delegation/src/index.ts new file mode 100644 index 0000000..de529a5 --- /dev/null +++ b/packages/delegation/src/index.ts @@ -0,0 +1,19 @@ +export { + createDelegationToken, + createSessionGrant, + createSessionGrantV2, + isDelegationExpired, + isSessionGrantV2Expired, + signDelegationToken, + signSessionGrantV2, + validateDelegationToken, + validateSessionGrantV2, + verifyDelegationTokenSignature, + verifySignedSessionGrantV2, + verifySignedSessionGrantV2Issuer, + type DelegationToken, + type SignedDelegationToken, + type SignedSessionGrantV2, + type SessionGrant, + type SessionGrantV2, +} from '@fides/core' diff --git a/packages/delegation/tsconfig.json b/packages/delegation/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/delegation/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/dht/LICENSE b/packages/dht/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/dht/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dht/README.md b/packages/dht/README.md new file mode 100644 index 0000000..01e3825 --- /dev/null +++ b/packages/dht/README.md @@ -0,0 +1,13 @@ +# @fides/dht + +DHT pointer record entrypoints for FIDES v2 discovery. + +DHT records are pointers only. They are not trust or authority sources. + +```typescript +import { createDHTPointerRecord, verifyDHTPointerRecord } from '@fides/dht' +``` + +## License + +MIT diff --git a/packages/dht/package.json b/packages/dht/package.json new file mode 100644 index 0000000..2a3cfbf --- /dev/null +++ b/packages/dht/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/dht", + "version": "0.1.0", + "description": "FIDES v2 DHT pointer record entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/dht" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/core": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/dht/src/index.ts b/packages/dht/src/index.ts new file mode 100644 index 0000000..080a2a8 --- /dev/null +++ b/packages/dht/src/index.ts @@ -0,0 +1,8 @@ +export { + createDHTPointerRecord, + hashAgentCard, + hashCapability, + signDHTPointerRecord, + verifyDHTPointerRecord, + type DHTPointerRecord, +} from '@fides/core' diff --git a/packages/dht/tsconfig.json b/packages/dht/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/dht/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/identity/LICENSE b/packages/identity/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/identity/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/identity/README.md b/packages/identity/README.md new file mode 100644 index 0000000..240485c --- /dev/null +++ b/packages/identity/README.md @@ -0,0 +1,16 @@ +# @fides/identity + +Identity entrypoints for FIDES v2 agents, publishers, principals, and trust +anchors. + +```typescript +import { createAgentIdentity, createPublisherIdentity } from '@fides/identity' +``` + +Domains are optional. Domain verification is one trust anchor among GitHub, +email, package registry, wallet, passkey, organization invitation, runtime +attestation, build attestation, peer attestation, and evidence history. + +## License + +MIT diff --git a/packages/identity/package.json b/packages/identity/package.json new file mode 100644 index 0000000..267ec3f --- /dev/null +++ b/packages/identity/package.json @@ -0,0 +1,41 @@ +{ + "name": "@fides/identity", + "version": "0.1.0", + "description": "FIDES v2 agent, publisher, principal, and trust-anchor identity entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/identity" + }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { + "node": ">=22.0.0" + }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run --passWithNoTests", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/identity/src/index.ts b/packages/identity/src/index.ts new file mode 100644 index 0000000..0672b62 --- /dev/null +++ b/packages/identity/src/index.ts @@ -0,0 +1,24 @@ +export { + createAgentIdentity, + createPrincipalIdentity, + createPublisherIdentity, + createDomainVerificationChallenge, + createOrganizationDomainVerificationChallenge, + createPasskeyAuthenticationChallenge, + createPasskeyRegistrationChallenge, + createTrustAnchorDistribution, + didFromPublicKey, + isValidFidesDid, + publicKeyFromDid, + verifyDomainDid, + verifyOrganizationDomainDid, + verifyPasskeyAuthentication, + verifyPasskeyRegistration, + type AgentIdentity, + type GovernedTrustAnchor, + type IdentityTrustAnchor, + type PrincipalIdentity, + type PublisherIdentity, + type TrustAnchor, + type TrustAnchorType, +} from '@fides/core' diff --git a/packages/identity/tsconfig.json b/packages/identity/tsconfig.json new file mode 100644 index 0000000..31b83a6 --- /dev/null +++ b/packages/identity/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/incidents/LICENSE b/packages/incidents/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/incidents/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/incidents/README.md b/packages/incidents/README.md new file mode 100644 index 0000000..1dd62ab --- /dev/null +++ b/packages/incidents/README.md @@ -0,0 +1,12 @@ +# @fides/incidents + +Incident record entrypoints for FIDES v2 reporting, resolution, and trust +impact. + +```typescript +import { createIncidentRecordV2, aggregateIncidentImpact } from '@fides/incidents' +``` + +## License + +MIT diff --git a/packages/incidents/package.json b/packages/incidents/package.json new file mode 100644 index 0000000..5423a31 --- /dev/null +++ b/packages/incidents/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/incidents", + "version": "0.1.0", + "description": "FIDES v2 incident record entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/incidents" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/core": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/incidents/src/index.ts b/packages/incidents/src/index.ts new file mode 100644 index 0000000..9b9b934 --- /dev/null +++ b/packages/incidents/src/index.ts @@ -0,0 +1,16 @@ +export { + aggregateIncidentImpact, + createIncidentRecord, + createIncidentRecordV2, + resolveIncident, + resolveIncidentRecordV2, + signIncidentRecord, + signIncidentRecordV2, + verifyIncidentRecord, + verifySignedIncidentRecordV2, + verifySignedIncidentRecordV2Issuer, + type IncidentCategory, + type IncidentRecord, + type IncidentRecordV2, + type SignedIncidentRecordV2, +} from '@fides/core' diff --git a/packages/incidents/tsconfig.json b/packages/incidents/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/incidents/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/invocation/LICENSE b/packages/invocation/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/invocation/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/invocation/README.md b/packages/invocation/README.md new file mode 100644 index 0000000..1f6645a --- /dev/null +++ b/packages/invocation/README.md @@ -0,0 +1,11 @@ +# @fides/invocation + +Capability invocation entrypoints for FIDES v2 policy-before-execution flows. + +```typescript +import { createInvocationRequest, verifySignedInvocationResult } from '@fides/invocation' +``` + +## License + +MIT diff --git a/packages/invocation/package.json b/packages/invocation/package.json new file mode 100644 index 0000000..1760415 --- /dev/null +++ b/packages/invocation/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/invocation", + "version": "0.1.0", + "description": "FIDES v2 capability invocation entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/invocation" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/core": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/invocation/src/index.ts b/packages/invocation/src/index.ts new file mode 100644 index 0000000..179b7ac --- /dev/null +++ b/packages/invocation/src/index.ts @@ -0,0 +1,18 @@ +export { + createInvocationRequest, + createInvocationResult, + evaluateInvocationPreflight, + signInvocationRequest, + signInvocationResult, + validateInvocationRequestAgainstSessionGrant, + validateJsonSchemaValue, + verifySignedInvocationRequest, + verifySignedInvocationRequestIssuer, + verifySignedInvocationResult, + verifySignedInvocationResultIssuer, + type InvocationRequest, + type InvocationResult, + type InvocationStatus, + type SignedInvocationRequest, + type SignedInvocationResult, +} from '@fides/core' diff --git a/packages/invocation/tsconfig.json b/packages/invocation/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/invocation/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/registry/LICENSE b/packages/registry/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/registry/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/registry/README.md b/packages/registry/README.md new file mode 100644 index 0000000..83d9b43 --- /dev/null +++ b/packages/registry/README.md @@ -0,0 +1,11 @@ +# @fides/registry + +Registry and federation record entrypoints for FIDES v2. + +```typescript +import { createRegistryIndexRecord, createRegistryPeerRecord } from '@fides/registry' +``` + +## License + +MIT diff --git a/packages/registry/package.json b/packages/registry/package.json new file mode 100644 index 0000000..3ebb0ce --- /dev/null +++ b/packages/registry/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/registry", + "version": "0.1.0", + "description": "FIDES v2 registry and federation record entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/registry" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/core": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts new file mode 100644 index 0000000..4b6ecd4 --- /dev/null +++ b/packages/registry/src/index.ts @@ -0,0 +1,18 @@ +export { + createRegistryIndexRecord, + createRegistryPeerRecord, + isRegistryIndexRecordExpired, + isRegistryPeerRecordExpired, + signRegistryIndexRecord, + signRegistryPeerRecord, + verifySignedRegistryIndexRecord, + verifySignedRegistryIndexRecordIssuer, + verifySignedRegistryPeerRecord, + verifySignedRegistryPeerRecordIssuer, + type RegistryIndexRecord, + type RegistryMode, + type RegistryPeerRecord, + type RegistryPeeringMode, + type SignedRegistryIndexRecord, + type SignedRegistryPeerRecord, +} from '@fides/core' diff --git a/packages/registry/tsconfig.json b/packages/registry/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/registry/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/relay/LICENSE b/packages/relay/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/relay/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/relay/README.md b/packages/relay/README.md new file mode 100644 index 0000000..b430f3a --- /dev/null +++ b/packages/relay/README.md @@ -0,0 +1,14 @@ +# @fides/relay + +Relay discovery entrypoints for NAT-hidden FIDES v2 agents. + +Relay discovery supplies presence, rendezvous, endpoint hints, and signed +AgentCard references. It does not decide trust or grant authority. + +```typescript +import { RelayDiscoveryProvider } from '@fides/relay' +``` + +## License + +MIT diff --git a/packages/relay/package.json b/packages/relay/package.json new file mode 100644 index 0000000..2cc2504 --- /dev/null +++ b/packages/relay/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/relay", + "version": "0.1.0", + "description": "FIDES v2 relay discovery entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/relay" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/discovery": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts new file mode 100644 index 0000000..245ed2f --- /dev/null +++ b/packages/relay/src/index.ts @@ -0,0 +1,3 @@ +export { + RelayDiscoveryProvider, +} from '@fides/discovery' diff --git a/packages/relay/tsconfig.json b/packages/relay/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/relay/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/reputation/LICENSE b/packages/reputation/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/reputation/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/reputation/README.md b/packages/reputation/README.md new file mode 100644 index 0000000..a1544bd --- /dev/null +++ b/packages/reputation/README.md @@ -0,0 +1,14 @@ +# @fides/reputation + +Capability-specific reputation entrypoints for FIDES v2. + +Reputation is scoped to capability and context. There is no global popularity +score. + +```typescript +import { computeCapabilityReputation } from '@fides/reputation' +``` + +## License + +MIT diff --git a/packages/reputation/package.json b/packages/reputation/package.json new file mode 100644 index 0000000..9af5735 --- /dev/null +++ b/packages/reputation/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/reputation", + "version": "0.1.0", + "description": "FIDES v2 capability-specific reputation entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/reputation" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/core": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/reputation/src/index.ts b/packages/reputation/src/index.ts new file mode 100644 index 0000000..ab0d067 --- /dev/null +++ b/packages/reputation/src/index.ts @@ -0,0 +1,7 @@ +export { + computeCapabilityReputation, + createReputationRecord, + type ComputeCapabilityReputationInput, + type ReputationReason, + type ReputationRecord, +} from '@fides/core' diff --git a/packages/reputation/tsconfig.json b/packages/reputation/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/reputation/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/revocation/LICENSE b/packages/revocation/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/revocation/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/revocation/README.md b/packages/revocation/README.md new file mode 100644 index 0000000..0f7d3aa --- /dev/null +++ b/packages/revocation/README.md @@ -0,0 +1,12 @@ +# @fides/revocation + +Revocation record entrypoints for FIDES v2 identities, keys, AgentCards, +capabilities, sessions, attestations, and publishers. + +```typescript +import { createRevocationRecordV2, verifySignedRevocationRecordV2 } from '@fides/revocation' +``` + +## License + +MIT diff --git a/packages/revocation/package.json b/packages/revocation/package.json new file mode 100644 index 0000000..4ec06d7 --- /dev/null +++ b/packages/revocation/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/revocation", + "version": "0.1.0", + "description": "FIDES v2 revocation record entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/revocation" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/core": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/revocation/src/index.ts b/packages/revocation/src/index.ts new file mode 100644 index 0000000..2427424 --- /dev/null +++ b/packages/revocation/src/index.ts @@ -0,0 +1,15 @@ +export { + createRevocationRecord, + createRevocationRecordV2, + isRevocationValid, + markPropagated, + signRevocationRecord, + signRevocationRecordV2, + verifyRevocationRecord, + verifySignedRevocationRecordV2, + verifySignedRevocationRecordV2Issuer, + type RevocationRecord, + type RevocationRecordV2, + type RevocationTargetType, + type SignedRevocationRecordV2, +} from '@fides/core' diff --git a/packages/revocation/tsconfig.json b/packages/revocation/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/revocation/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/trust/LICENSE b/packages/trust/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/trust/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/trust/README.md b/packages/trust/README.md new file mode 100644 index 0000000..5d9f214 --- /dev/null +++ b/packages/trust/README.md @@ -0,0 +1,13 @@ +# @fides/trust + +Capability-specific trust scoring entrypoints for FIDES v2. + +Trust is a signal. Policy is the authority. + +```typescript +import { computeTrustResult } from '@fides/trust' +``` + +## License + +MIT diff --git a/packages/trust/package.json b/packages/trust/package.json new file mode 100644 index 0000000..122241d --- /dev/null +++ b/packages/trust/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fides/trust", + "version": "0.1.0", + "description": "FIDES v2 trust scoring entrypoints", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/trust" }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { "node": ">=22.0.0" }, + "files": ["dist", "README.md", "LICENSE"], + "sideEffects": false, + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, + "dependencies": { "@fides/core": "workspace:*" }, + "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } +} diff --git a/packages/trust/src/index.ts b/packages/trust/src/index.ts new file mode 100644 index 0000000..3ec0c25 --- /dev/null +++ b/packages/trust/src/index.ts @@ -0,0 +1,10 @@ +export { + computeTrustResult, + trustBandForScore, + type ComputeTrustResultInput, + type TrustBand, + type TrustReason, + type TrustReasonComponent, + type TrustResult, + type TrustScoreComponents, +} from '@fides/core' diff --git a/packages/trust/tsconfig.json b/packages/trust/tsconfig.json new file mode 100644 index 0000000..e256468 --- /dev/null +++ b/packages/trust/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fde361d..b4c5451 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,38 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.10) + packages/attestations: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + + packages/cards: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + packages/cli: dependencies: '@fides/core': @@ -121,6 +153,54 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.10) + packages/crypto: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + + packages/delegation: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + + packages/dht: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + packages/discovery: dependencies: '@fides/core': @@ -190,6 +270,54 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/node@22.19.10) + packages/identity: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + + packages/incidents: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + + packages/invocation: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + packages/policy: dependencies: '@fides/core': @@ -209,6 +337,70 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.10) + packages/registry: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + + packages/relay: + dependencies: + '@fides/discovery': + specifier: workspace:* + version: link:../discovery + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + + packages/reputation: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + + packages/revocation: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + packages/runtime: dependencies: '@fides/core': @@ -268,6 +460,22 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.10) + packages/trust: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + services/agentd: dependencies: '@fides/core': diff --git a/scripts/public-packages.mjs b/scripts/public-packages.mjs index 4bfe0c1..43a4abf 100644 --- a/scripts/public-packages.mjs +++ b/scripts/public-packages.mjs @@ -1,10 +1,23 @@ export const publicPackageDirs = [ 'packages/shared', 'packages/core', + 'packages/crypto', + 'packages/identity', + 'packages/attestations', + 'packages/cards', + 'packages/trust', + 'packages/reputation', 'packages/policy', + 'packages/delegation', + 'packages/invocation', 'packages/runtime', 'packages/discovery', + 'packages/dht', + 'packages/relay', + 'packages/registry', 'packages/evidence', + 'packages/revocation', + 'packages/incidents', 'packages/sdk', 'packages/cli', ] From b9b69671f5154985a9f3c31cf037980bf5c01a61 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:53:25 +0300 Subject: [PATCH 248/282] test(packages): cover protocol facade exports --- packages/attestations/package.json | 41 +++++++++++++++++---- packages/attestations/test/facade.test.ts | 18 ++++++++++ packages/cards/package.json | 41 +++++++++++++++++---- packages/cards/test/facade.test.ts | 11 ++++++ packages/crypto/package.json | 8 +++-- packages/crypto/test/facade.test.ts | 9 +++++ packages/delegation/package.json | 41 +++++++++++++++++---- packages/delegation/test/facade.test.ts | 17 +++++++++ packages/dht/package.json | 41 +++++++++++++++++---- packages/dht/test/facade.test.ts | 18 ++++++++++ packages/identity/package.json | 8 +++-- packages/identity/test/facade.test.ts | 12 +++++++ packages/incidents/package.json | 41 +++++++++++++++++---- packages/incidents/test/facade.test.ts | 18 ++++++++++ packages/invocation/package.json | 41 +++++++++++++++++---- packages/invocation/test/facade.test.ts | 44 +++++++++++++++++++++++ packages/registry/package.json | 41 +++++++++++++++++---- packages/registry/test/facade.test.ts | 20 +++++++++++ packages/relay/package.json | 41 +++++++++++++++++---- packages/relay/test/facade.test.ts | 10 ++++++ packages/reputation/package.json | 41 +++++++++++++++++---- packages/reputation/test/facade.test.ts | 16 +++++++++ packages/revocation/package.json | 41 +++++++++++++++++---- packages/revocation/test/facade.test.ts | 16 +++++++++ packages/trust/package.json | 41 +++++++++++++++++---- packages/trust/test/facade.test.ts | 27 ++++++++++++++ 26 files changed, 622 insertions(+), 81 deletions(-) create mode 100644 packages/attestations/test/facade.test.ts create mode 100644 packages/cards/test/facade.test.ts create mode 100644 packages/crypto/test/facade.test.ts create mode 100644 packages/delegation/test/facade.test.ts create mode 100644 packages/dht/test/facade.test.ts create mode 100644 packages/identity/test/facade.test.ts create mode 100644 packages/incidents/test/facade.test.ts create mode 100644 packages/invocation/test/facade.test.ts create mode 100644 packages/registry/test/facade.test.ts create mode 100644 packages/relay/test/facade.test.ts create mode 100644 packages/reputation/test/facade.test.ts create mode 100644 packages/revocation/test/facade.test.ts create mode 100644 packages/trust/test/facade.test.ts diff --git a/packages/attestations/package.json b/packages/attestations/package.json index 2e7d4b1..d579b75 100644 --- a/packages/attestations/package.json +++ b/packages/attestations/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/attestations" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/attestations" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/core": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/attestations/test/facade.test.ts b/packages/attestations/test/facade.test.ts new file mode 100644 index 0000000..1dea410 --- /dev/null +++ b/packages/attestations/test/facade.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { MockTEEProvider, verifyRuntimeAttestation } from '../src/index.js' + +describe('@fides/attestations facade', () => { + it('exports MockTEE issue and verify primitives', async () => { + const provider = new MockTEEProvider() + const hash = (value: string) => `sha256:${value.repeat(64).slice(0, 64)}` + const attestation = await provider.issue({ + agentId: 'did:fides:agent', + codeHash: hash('a'), + runtimeHash: hash('b'), + policyHash: hash('c'), + }) + + expect(attestation.provider).toBe('mock-tee') + expect(await verifyRuntimeAttestation(attestation, provider)).toBe(true) + }) +}) diff --git a/packages/cards/package.json b/packages/cards/package.json index 839c5c5..a854761 100644 --- a/packages/cards/package.json +++ b/packages/cards/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/cards" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/cards" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/core": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/cards/test/facade.test.ts b/packages/cards/test/facade.test.ts new file mode 100644 index 0000000..3d040af --- /dev/null +++ b/packages/cards/test/facade.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { classifyCapabilityRisk, createCapabilityDescriptor } from '../src/index.js' + +describe('@fides/cards facade', () => { + it('exports capability descriptor and risk taxonomy primitives', () => { + const descriptor = createCapabilityDescriptor({ id: 'payments.execute' }) + + expect(descriptor.id).toBe('payments.execute') + expect(classifyCapabilityRisk('payments.execute')).toBe('critical') + }) +}) diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 3c41a4c..41a7165 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -15,7 +15,11 @@ "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, "exports": { ".": { @@ -27,7 +31,7 @@ "scripts": { "build": "tsc", "lint": "tsc --noEmit", - "test": "vitest run --passWithNoTests", + "test": "vitest run", "clean": "rm -rf dist" }, "dependencies": { diff --git a/packages/crypto/test/facade.test.ts b/packages/crypto/test/facade.test.ts new file mode 100644 index 0000000..8ef1bd1 --- /dev/null +++ b/packages/crypto/test/facade.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { canonicalDigest, canonicalJson } from '../src/index.js' + +describe('@fides/crypto facade', () => { + it('exports canonical JSON and digest primitives', () => { + expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}') + expect(canonicalDigest({ ok: true })).toBeInstanceOf(Uint8Array) + }) +}) diff --git a/packages/delegation/package.json b/packages/delegation/package.json index 2ad4c1a..fe86c65 100644 --- a/packages/delegation/package.json +++ b/packages/delegation/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/delegation" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/delegation" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/core": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/delegation/test/facade.test.ts b/packages/delegation/test/facade.test.ts new file mode 100644 index 0000000..b769033 --- /dev/null +++ b/packages/delegation/test/facade.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { createDelegationToken, validateDelegationToken } from '../src/index.js' + +describe('@fides/delegation facade', () => { + it('exports delegation token primitives', () => { + const token = createDelegationToken({ + delegator: 'did:fides:principal', + delegatee: 'did:fides:agent', + capabilities: ['invoice.reconcile'], + constraints: { maxInvocations: 1 }, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + + expect(token.capabilities).toEqual(['invoice.reconcile']) + expect(validateDelegationToken({ ...token, signature: 'local-test-signature' }).valid).toBe(true) + }) +}) diff --git a/packages/dht/package.json b/packages/dht/package.json index 2a3cfbf..42a38f9 100644 --- a/packages/dht/package.json +++ b/packages/dht/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/dht" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/dht" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/core": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/dht/test/facade.test.ts b/packages/dht/test/facade.test.ts new file mode 100644 index 0000000..bf2f3ba --- /dev/null +++ b/packages/dht/test/facade.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { createDHTPointerRecord, hashCapability } from '../src/index.js' + +describe('@fides/dht facade', () => { + it('exports DHT pointer primitives', () => { + const pointer = createDHTPointerRecord({ + capability: 'invoice.reconcile', + agentId: 'did:fides:agent', + agentCardUrl: 'https://example.test/agent-card.json', + agentCardHash: 'sha256:card', + publisherId: 'did:fides:publisher', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + + expect(pointer.record_type).toBe('capability_pointer') + expect(pointer.capability_hash).toBe(hashCapability('invoice.reconcile')) + }) +}) diff --git a/packages/identity/package.json b/packages/identity/package.json index 267ec3f..8ff79c8 100644 --- a/packages/identity/package.json +++ b/packages/identity/package.json @@ -15,7 +15,11 @@ "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, "exports": { ".": { @@ -27,7 +31,7 @@ "scripts": { "build": "tsc", "lint": "tsc --noEmit", - "test": "vitest run --passWithNoTests", + "test": "vitest run", "clean": "rm -rf dist" }, "dependencies": { diff --git a/packages/identity/test/facade.test.ts b/packages/identity/test/facade.test.ts new file mode 100644 index 0000000..81cae9f --- /dev/null +++ b/packages/identity/test/facade.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { createAgentIdentity, isValidFidesDid } from '../src/index.js' + +describe('@fides/identity facade', () => { + it('exports identity creation and DID validation primitives', async () => { + const createdAt = '2026-05-30T00:00:00.000Z' + const { identity } = await createAgentIdentity({ createdAt }) + + expect(isValidFidesDid(identity.did)).toBe(true) + expect(identity.createdAt).toBe(createdAt) + }) +}) diff --git a/packages/incidents/package.json b/packages/incidents/package.json index 5423a31..0783a8e 100644 --- a/packages/incidents/package.json +++ b/packages/incidents/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/incidents" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/incidents" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/core": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/incidents/test/facade.test.ts b/packages/incidents/test/facade.test.ts new file mode 100644 index 0000000..89c8040 --- /dev/null +++ b/packages/incidents/test/facade.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { createIncidentRecordV2 } from '../src/index.js' + +describe('@fides/incidents facade', () => { + it('exports incident record primitives', () => { + const incident = createIncidentRecordV2({ + reporter: 'did:fides:reporter', + targetAgentId: 'did:fides:agent', + severity: 'high', + category: 'policy_violation', + description: 'Policy violation in facade contract test', + evidenceRefs: ['evidence_1'], + }) + + expect(incident.schema_version).toBe('fides.incident.record.v1') + expect(incident.category).toBe('policy_violation') + }) +}) diff --git a/packages/invocation/package.json b/packages/invocation/package.json index 1760415..e3194ec 100644 --- a/packages/invocation/package.json +++ b/packages/invocation/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/invocation" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/invocation" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/core": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/invocation/test/facade.test.ts b/packages/invocation/test/facade.test.ts new file mode 100644 index 0000000..da401f0 --- /dev/null +++ b/packages/invocation/test/facade.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { createInvocationRequest, createInvocationResult } from '../src/index.js' + +describe('@fides/invocation facade', () => { + it('exports invocation request and result primitives', () => { + const request = createInvocationRequest({ + issuer: 'did:fides:requester', + sessionGrant: { + schema_version: 'fides.session_grant.v1', + id: 'sess_1', + session_id: 'sess_1', + issuer: 'did:fides:issuer', + subject: 'did:fides:target', + requester_agent_id: 'did:fides:requester', + target_agent_id: 'did:fides:target', + principal_id: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['invoice:read'], + constraints: {}, + policy_hash: 'sha256:policy', + trust_result_hash: 'sha256:trust', + issued_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 60_000).toISOString(), + nonce: 'nonce', + audience: ['did:fides:target'], + supported_versions: ['fides.v2.0'], + negotiated_version: 'fides.v2.0', + payload_hash: 'sha256:payload', + signature: 'sig', + }, + input: { invoiceId: 'inv_123' }, + dryRun: true, + }) + const result = createInvocationResult({ + issuer: 'did:fides:target', + invocationRequestId: request.id, + status: 'dry_run', + output: { ok: true }, + }) + + expect(request.session_id).toBe('sess_1') + expect(result.invocation_request_id).toBe(request.id) + }) +}) diff --git a/packages/registry/package.json b/packages/registry/package.json index 3ebb0ce..56664f8 100644 --- a/packages/registry/package.json +++ b/packages/registry/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/registry" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/registry" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/core": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/registry/test/facade.test.ts b/packages/registry/test/facade.test.ts new file mode 100644 index 0000000..7be9dee --- /dev/null +++ b/packages/registry/test/facade.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { createRegistryIndexRecord } from '../src/index.js' + +describe('@fides/registry facade', () => { + it('exports registry index record primitives', () => { + const record = createRegistryIndexRecord({ + issuer: 'did:fides:registry', + mode: 'public', + agentCardId: 'card_1', + agentId: 'did:fides:agent', + capabilityIds: ['invoice.reconcile'], + agentCardHash: 'sha256:card', + registryUrl: 'https://registry.example.test', + supportedVersions: ['fides.v2.0'], + }) + + expect(record.schema_version).toBe('fides.registry.index.v1') + expect(record.capability_ids).toEqual(['invoice.reconcile']) + }) +}) diff --git a/packages/relay/package.json b/packages/relay/package.json index 2cc2504..77e2aca 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/relay" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/relay" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/discovery": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/discovery": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/relay/test/facade.test.ts b/packages/relay/test/facade.test.ts new file mode 100644 index 0000000..62551e8 --- /dev/null +++ b/packages/relay/test/facade.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { RelayDiscoveryProvider } from '../src/index.js' + +describe('@fides/relay facade', () => { + it('exports relay discovery provider', () => { + const provider = new RelayDiscoveryProvider({ relayUrl: 'https://relay.example.test' }) + + expect(provider.name).toBe('relay') + }) +}) diff --git a/packages/reputation/package.json b/packages/reputation/package.json index 9af5735..8662471 100644 --- a/packages/reputation/package.json +++ b/packages/reputation/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/reputation" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/reputation" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/core": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/reputation/test/facade.test.ts b/packages/reputation/test/facade.test.ts new file mode 100644 index 0000000..ef4a460 --- /dev/null +++ b/packages/reputation/test/facade.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { computeCapabilityReputation } from '../src/index.js' + +describe('@fides/reputation facade', () => { + it('exports capability-scoped reputation primitives', () => { + const reputation = computeCapabilityReputation({ + agentId: 'did:fides:agent', + capability: 'invoice.reconcile', + successfulInvocations: 10, + failedInvocations: 0, + }) + + expect(reputation.capability).toBe('invoice.reconcile') + expect(reputation.score).toBeGreaterThan(0) + }) +}) diff --git a/packages/revocation/package.json b/packages/revocation/package.json index 4ec06d7..5f179b1 100644 --- a/packages/revocation/package.json +++ b/packages/revocation/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/revocation" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/revocation" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/core": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/revocation/test/facade.test.ts b/packages/revocation/test/facade.test.ts new file mode 100644 index 0000000..0dd6e31 --- /dev/null +++ b/packages/revocation/test/facade.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { createRevocationRecordV2 } from '../src/index.js' + +describe('@fides/revocation facade', () => { + it('exports revocation record primitives', () => { + const record = createRevocationRecordV2({ + issuer: 'did:fides:issuer', + targetType: 'agent', + targetId: 'did:fides:agent', + reason: 'manual revoke', + }) + + expect(record.schema_version).toBe('fides.revocation.record.v1') + expect(record.target_type).toBe('agent') + }) +}) diff --git a/packages/trust/package.json b/packages/trust/package.json index 122241d..2816ed8 100644 --- a/packages/trust/package.json +++ b/packages/trust/package.json @@ -6,13 +6,40 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "license": "MIT", - "repository": { "type": "git", "url": "https://github.com/EfeDurmaz16/fides", "directory": "packages/trust" }, + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/trust" + }, "homepage": "https://github.com/EfeDurmaz16/fides#readme", - "engines": { "node": ">=22.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "sideEffects": false, - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } }, - "scripts": { "build": "tsc", "lint": "tsc --noEmit", "test": "vitest run --passWithNoTests", "clean": "rm -rf dist" }, - "dependencies": { "@fides/core": "workspace:*" }, - "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.2", "vitest": "^2.1.8" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/trust/test/facade.test.ts b/packages/trust/test/facade.test.ts new file mode 100644 index 0000000..7bfeb66 --- /dev/null +++ b/packages/trust/test/facade.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { computeTrustResult, trustBandForScore } from '../src/index.js' + +describe('@fides/trust facade', () => { + it('exports trust scoring primitives', () => { + const result = computeTrustResult({ + agentId: 'did:fides:agent', + capability: { id: 'calendar.schedule', riskLevel: 'low', requiredScopes: [] }, + components: { + identity: 1, + publisher: 1, + trustAnchors: 1, + capabilityFit: 1, + evidence: 1, + policyCompliance: 1, + runtimeSafety: 1, + peerAttestation: 1, + incidentPenalty: 0, + noveltyPenalty: 0, + contextBoundaryPenalty: 0, + }, + }) + + expect(result.band).toBe('verified') + expect(trustBandForScore(result.score)).toBe('verified') + }) +}) From c2ca6b76e20f67e0cb4d26b44973099dc1722eae Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:55:04 +0300 Subject: [PATCH 249/282] test(services): require existing test suites --- services/agentd/package.json | 2 +- services/discovery/package.json | 2 +- services/platform-api/package.json | 2 +- services/policy-engine/package.json | 2 +- services/registry/package.json | 2 +- services/relay/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/agentd/package.json b/services/agentd/package.json index 9443f71..88a79ea 100644 --- a/services/agentd/package.json +++ b/services/agentd/package.json @@ -10,7 +10,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "lint": "tsc --noEmit", - "test": "vitest run --passWithNoTests", + "test": "vitest run", "db:migrate": "tsx src/migrate.ts" }, "dependencies": { diff --git a/services/discovery/package.json b/services/discovery/package.json index ae063c1..53be12e 100644 --- a/services/discovery/package.json +++ b/services/discovery/package.json @@ -10,7 +10,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "lint": "tsc --noEmit", - "test": "vitest run --passWithNoTests", + "test": "vitest run", "test:watch": "vitest", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/migrate.ts" diff --git a/services/platform-api/package.json b/services/platform-api/package.json index 18e7cb2..85f767e 100644 --- a/services/platform-api/package.json +++ b/services/platform-api/package.json @@ -10,7 +10,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "lint": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run" }, "dependencies": { "@fides/core": "workspace:*", diff --git a/services/policy-engine/package.json b/services/policy-engine/package.json index 1c16817..6c89143 100644 --- a/services/policy-engine/package.json +++ b/services/policy-engine/package.json @@ -10,7 +10,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "lint": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run" }, "dependencies": { "@fides/policy": "workspace:*", diff --git a/services/registry/package.json b/services/registry/package.json index c66aa09..b6bf82e 100644 --- a/services/registry/package.json +++ b/services/registry/package.json @@ -10,7 +10,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "lint": "tsc --noEmit", - "test": "vitest run --passWithNoTests", + "test": "vitest run", "db:migrate": "tsx src/migrate.ts" }, "dependencies": { diff --git a/services/relay/package.json b/services/relay/package.json index 8e08243..0475180 100644 --- a/services/relay/package.json +++ b/services/relay/package.json @@ -10,7 +10,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "lint": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run" }, "dependencies": { "@fides/sdk": "workspace:*", From 2e0bbdf65f2276aa877eb8b46a139a0d8f7b4969 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:56:05 +0300 Subject: [PATCH 250/282] ci: use full verify gate --- .github/workflows/ci.yml | 19 ++----------------- .github/workflows/npm-publish.yml | 13 ++----------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7f3e35..0675f54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,23 +38,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Check package hygiene - run: pnpm package:hygiene - - - name: Build - run: pnpm build - - - name: Check public package packs - run: pnpm package:packcheck - - - name: Lint - run: pnpm lint - - - name: Typecheck - run: pnpm typecheck - - - name: Test - run: pnpm test + - name: Verify + run: pnpm verify env: DATABASE_URL: postgresql://fides:fides@localhost:5432/fides AGENTD_POSTGRES_TEST_REQUIRED: 'true' diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 178fb54..eb7e02a 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -26,17 +26,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Check package hygiene - run: pnpm package:hygiene - - - name: Build - run: pnpm build - - - name: Check public package packs - run: pnpm package:packcheck - - - name: Test - run: pnpm test + - name: Verify + run: pnpm verify - name: Publish public packages run: | From d719687804de957e8ddea8de49031c90fc440aac Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 21:59:22 +0300 Subject: [PATCH 251/282] test(cli): audit agentd command surface --- README.md | 2 + package.json | 3 +- scripts/audit-cli-surface.mjs | 89 +++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 scripts/audit-cli-surface.mjs diff --git a/README.md b/README.md index d4ed42c..ab3ec5b 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,8 @@ pnpm build | `pnpm lint` | Lint codebase | | `pnpm typecheck` | Type-check TypeScript | | `pnpm examples:typecheck` | Type-check example agents and demo manifests | +| `pnpm cli:audit` | Verify the implemented `agentd` CLI surface against the v2 command contract | +| `pnpm api:audit` | Verify documented `agentd` API routes against the implementation | | `pnpm dev` | Start services in watch mode | | `pnpm clean` | Clean build artifacts | | `pnpm demo` | Run the primitive-level v2 demo | diff --git a/package.json b/package.json index a218a15..12ccb66 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,10 @@ "typecheck": "turbo run typecheck", "examples:typecheck": "tsc -p examples/tsconfig.json", "api:audit": "node scripts/audit-agentd-api.mjs", + "cli:audit": "node scripts/audit-cli-surface.mjs", "package:hygiene": "node scripts/check-package-hygiene.mjs", "package:packcheck": "node scripts/check-public-package-packs.mjs", - "verify": "pnpm package:hygiene && pnpm api:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", + "verify": "pnpm package:hygiene && pnpm api:audit && pnpm cli:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", "verify:quick": "pnpm lint && pnpm test", "ci:local": "pnpm verify", "dev": "turbo run dev --parallel", diff --git a/scripts/audit-cli-surface.mjs b/scripts/audit-cli-surface.mjs new file mode 100644 index 0000000..cf50e98 --- /dev/null +++ b/scripts/audit-cli-surface.mjs @@ -0,0 +1,89 @@ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const cliEntrypoint = resolve(root, 'packages/cli/src/index.ts') + +const checks = [ + { + args: ['--help'], + contains: [ + 'init', + 'identity', + 'attest', + 'card', + 'register', + 'agents', + 'discover', + 'trust', + 'reputation', + 'graph', + 'policy', + 'session', + 'invoke', + 'approval', + 'evidence', + 'revoke', + 'incident', + 'killswitch', + 'registry', + 'relay', + 'dht', + 'demo', + 'simulate', + 'daemon', + ], + }, + { args: ['identity', '--help'], contains: ['create', 'list', 'show'] }, + { args: ['identity', 'create', '--help'], contains: ['--type ', '--agentd-url '] }, + { args: ['attest', '--help'], contains: ['github', 'email', 'domain', 'package', 'wallet', 'runtime'] }, + { args: ['card', '--help'], contains: ['create', 'sign', 'verify', 'inspect'] }, + { args: ['discover', '--help'], contains: ['--capability ', '--provider ', '--all-providers'] }, + { args: ['registry', '--help'], contains: ['start', 'publish', 'search'] }, + { args: ['relay', '--help'], contains: ['start', 'register', 'discover'] }, + { args: ['dht', '--help'], contains: ['start', 'publish', 'find'] }, + { args: ['trust', '--help'], contains: ['--capability '] }, + { args: ['reputation', '--help'], contains: ['update', 'get', '--capability '] }, + { args: ['graph', '--help'], contains: ['inspect'] }, + { args: ['policy', '--help'], contains: ['evaluate'] }, + { args: ['session', '--help'], contains: ['request', 'show', 'verify'] }, + { args: ['invoke', '--help'], contains: ['--capability ', '--input ', '--dry-run'] }, + { args: ['approval', '--help'], contains: ['request', 'list', 'approve', 'deny'] }, + { args: ['evidence', '--help'], contains: ['list', 'inspect', 'verify', 'export'] }, + { args: ['revoke', '--help'], contains: ['agent', 'key', 'identity', 'card', 'capability', 'session', 'attestation', 'publisher'] }, + { args: ['incident', '--help'], contains: ['report', 'list', 'inspect', 'resolve'] }, + { args: ['killswitch', '--help'], contains: ['enable', 'list', 'disable'] }, + { args: ['demo', '--help'], contains: ['run'] }, + { args: ['simulate', '--help'], contains: ['adversarial'] }, +] + +const errors = [] + +for (const check of checks) { + const result = spawnSync(process.execPath, ['--import', 'tsx', cliEntrypoint, ...check.args], { + cwd: root, + encoding: 'utf8', + stdio: 'pipe', + }) + const label = `agentd ${check.args.join(' ')}` + const output = `${result.stdout}\n${result.stderr}` + + if (result.status !== 0) { + errors.push(`${label} exited with ${result.status ?? 'null'}: ${output.trim()}`) + continue + } + + for (const expected of check.contains) { + if (!output.includes(expected)) { + errors.push(`${label} help output is missing "${expected}"`) + } + } +} + +if (errors.length > 0) { + console.error(errors.join('\n')) + process.exit(1) +} + +console.log(`CLI surface audit passed for ${checks.length} command surfaces.`) From 44bad51bc226dee8973bebc7867c3e39c906776d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:00:36 +0300 Subject: [PATCH 252/282] docs: refresh fides v2 status gates --- docs/status/fides-v2-implementation-status.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 291d8bf..d8d2780 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -17,6 +17,8 @@ Last verified locally: 2026-05-30. - `agentd` CLI command surface, plus root workspace scripts: - `pnpm agentd ` - `pnpm agentd:dev` +- CLI surface audit for the requested `agentd` command groups and critical + options. - Canonical signing model for signed protocol objects. - Typed error envelopes on root v2 identity, AgentCard, discovery, trust, reputation, policy, session, invocation, approval, kill switch, revocation, @@ -47,6 +49,8 @@ Last verified locally: 2026-05-30. - Public target-structure facade packages for crypto, identity, attestations, cards, trust, reputation, delegation, invocation, DHT, relay, registry, revocation, and incidents. +- Public facade packages include export contract tests and no longer depend on + empty-test fallback behavior. - Full local demo and adversarial simulation endpoints. - Public docs refreshed around `agentd`, `FidesClient`, candidate-only discovery, and authority-via-policy/session. @@ -66,8 +70,11 @@ Last verified locally: 2026-05-30. - OpenAPI route audit and response-shape contract coverage for root `agentd` demo, adversarial simulation, and non-authoritative discovery write responses. +- CLI command-surface audit for the requested root command groups. - OpenAPI contract coverage for evidence-producing discovery responses. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. +- CI and npm publish workflows use the same full `pnpm verify` gate used + locally. ## Working Prototype @@ -293,6 +300,7 @@ pnpm --filter @fides/e2e-tests test -- agentd-openapi-contract.test.ts pnpm --filter @fides/cli build pnpm package:hygiene pnpm api:audit +pnpm cli:audit pnpm smoke:agentd ``` @@ -358,6 +366,12 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `d719687 test(cli): audit agentd command surface` +- `2e0bbdf ci: use full verify gate` +- `c2ca6b7 test(services): require existing test suites` +- `b9b6967 test(packages): cover protocol facade exports` +- `25bb2f6 feat(packages): add protocol domain facades` +- `18a52f0 docs: refresh fides v2 dx status commits` - `292055d fix(cli): catch async entrypoint failures` - `8b472a0 feat(cli): surface typed agentd errors` - `2d4eb41 feat(delegation): bind session grants to protocol versions` From 7b7511ff291cd4f6ed60fcecedc2e36d2fd1a3da Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:05:29 +0300 Subject: [PATCH 253/282] test(examples): audit canonical agent catalog --- README.md | 1 + examples/README.md | 15 +++++ examples/agent-catalog.ts | 132 +++++++++++++++++++++++++++++++++++++ package.json | 3 +- scripts/audit-examples.mjs | 83 +++++++++++++++++++++++ 5 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 examples/agent-catalog.ts create mode 100644 scripts/audit-examples.mjs diff --git a/README.md b/README.md index ab3ec5b..10cfd6b 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,7 @@ pnpm build | `pnpm lint` | Lint codebase | | `pnpm typecheck` | Type-check TypeScript | | `pnpm examples:typecheck` | Type-check example agents and demo manifests | +| `pnpm examples:audit` | Verify canonical v2 example agents and capability/risk contracts | | `pnpm cli:audit` | Verify the implemented `agentd` CLI surface against the v2 command contract | | `pnpm api:audit` | Verify documented `agentd` API routes against the implementation | | `pnpm dev` | Start services in watch mode | diff --git a/examples/README.md b/examples/README.md index d15325e..cffee31 100644 --- a/examples/README.md +++ b/examples/README.md @@ -32,6 +32,21 @@ Type-check every example agent and demo contract: pnpm examples:typecheck ``` +Verify that the canonical v2 example catalog includes the requested agent +roles, capabilities, risk classes, and authority notes: + +```bash +pnpm examples:audit +``` + +The canonical catalog lives in `examples/agent-catalog.ts` and uses the v2 +capability names: + +- `calendar.schedule` +- `invoice.reconcile` +- `payments.prepare` +- `payments.execute` + ## Example Agents Each example is a self-contained script that demonstrates specific FIDES concepts. diff --git a/examples/agent-catalog.ts b/examples/agent-catalog.ts new file mode 100644 index 0000000..f8430b4 --- /dev/null +++ b/examples/agent-catalog.ts @@ -0,0 +1,132 @@ +import type { CapabilityDescriptor } from '@fides/core' + +export interface ExampleCapability { + id: string + riskLevel: CapabilityDescriptor['riskLevel'] + requiredScopes: string[] + dryRunSupported: boolean + humanApprovalSupported: boolean + policyProofSupported: boolean +} + +export interface ExampleAgentManifest { + id: string + name: string + role: 'calendar' | 'invoice' | 'payment' | 'requester' | 'malicious' + capabilities: ExampleCapability[] + authorityNotes: string[] +} + +export const exampleAgentCatalog: ExampleAgentManifest[] = [ + { + id: 'calendar-agent', + name: 'Calendar Agent', + role: 'calendar', + capabilities: [ + { + id: 'calendar.schedule', + riskLevel: 'low', + requiredScopes: ['calendar:write'], + dryRunSupported: true, + humanApprovalSupported: false, + policyProofSupported: true, + }, + ], + authorityNotes: [ + 'Local discovery returns this agent as a candidate only.', + 'A scoped SessionGrant is still required before invocation.', + ], + }, + { + id: 'invoice-agent', + name: 'Invoice Agent', + role: 'invoice', + capabilities: [ + { + id: 'invoice.reconcile', + riskLevel: 'medium', + requiredScopes: ['invoice:read'], + dryRunSupported: true, + humanApprovalSupported: true, + policyProofSupported: true, + }, + ], + authorityNotes: [ + 'Registry discovery is non-authoritative.', + 'Policy evaluation must run before invoice reconciliation.', + ], + }, + { + id: 'payment-agent', + name: 'Payment Agent', + role: 'payment', + capabilities: [ + { + id: 'payments.prepare', + riskLevel: 'high', + requiredScopes: ['payments:prepare'], + dryRunSupported: true, + humanApprovalSupported: true, + policyProofSupported: true, + }, + { + id: 'payments.execute', + riskLevel: 'critical', + requiredScopes: ['payments:execute'], + dryRunSupported: false, + humanApprovalSupported: true, + policyProofSupported: true, + }, + ], + authorityNotes: [ + 'Generic FIDES demos use payment preparation in dry-run mode.', + 'Payment execution remains Sardis-specific and must not execute in FIDES.', + ], + }, + { + id: 'requester-agent', + name: 'Requester Agent', + role: 'requester', + capabilities: [ + { + id: 'agent.request', + riskLevel: 'medium', + requiredScopes: ['agents:invoke'], + dryRunSupported: true, + humanApprovalSupported: true, + policyProofSupported: true, + }, + ], + authorityNotes: [ + 'Discovers candidates and requests scoped sessions.', + 'Does not treat discovery, identity, or trust score as authority.', + ], + }, + { + id: 'malicious-agent', + name: 'Malicious Agent', + role: 'malicious', + capabilities: [ + { + id: 'payments.execute', + riskLevel: 'critical', + requiredScopes: ['payments:execute'], + dryRunSupported: false, + humanApprovalSupported: false, + policyProofSupported: false, + }, + ], + authorityNotes: [ + 'Used by adversarial simulation for tampering, context laundering, revocation, and broken evidence-chain checks.', + 'Expected outcome is detection, trust penalty, policy denial, and evidence.', + ], + }, +] as const + +export function findExampleAgent(id: string): ExampleAgentManifest | undefined { + return exampleAgentCatalog.find(agent => agent.id === id) +} + +if (import.meta.url === `file://${process.argv[1]}`) { + console.log(JSON.stringify({ agents: exampleAgentCatalog }, null, 2)) +} diff --git a/package.json b/package.json index 12ccb66..a739202 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,12 @@ "lint": "turbo run lint", "typecheck": "turbo run typecheck", "examples:typecheck": "tsc -p examples/tsconfig.json", + "examples:audit": "node scripts/audit-examples.mjs", "api:audit": "node scripts/audit-agentd-api.mjs", "cli:audit": "node scripts/audit-cli-surface.mjs", "package:hygiene": "node scripts/check-package-hygiene.mjs", "package:packcheck": "node scripts/check-public-package-packs.mjs", - "verify": "pnpm package:hygiene && pnpm api:audit && pnpm cli:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", + "verify": "pnpm package:hygiene && pnpm api:audit && pnpm cli:audit && pnpm examples:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", "verify:quick": "pnpm lint && pnpm test", "ci:local": "pnpm verify", "dev": "turbo run dev --parallel", diff --git a/scripts/audit-examples.mjs b/scripts/audit-examples.mjs new file mode 100644 index 0000000..054a6fc --- /dev/null +++ b/scripts/audit-examples.mjs @@ -0,0 +1,83 @@ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const catalogPath = resolve(root, 'examples/agent-catalog.ts') + +const result = spawnSync(process.execPath, ['--import', 'tsx', catalogPath], { + cwd: root, + encoding: 'utf8', + stdio: 'pipe', +}) + +if (result.status !== 0) { + console.error(result.stderr || result.stdout) + process.exit(result.status ?? 1) +} + +const parsed = JSON.parse(result.stdout) +const agents = parsed.agents +const errors = [] + +const required = { + 'calendar-agent': [{ id: 'calendar.schedule', riskLevel: 'low' }], + 'invoice-agent': [{ id: 'invoice.reconcile', riskLevel: 'medium' }], + 'payment-agent': [ + { id: 'payments.prepare', riskLevel: 'high' }, + { id: 'payments.execute', riskLevel: 'critical' }, + ], + 'requester-agent': [{ id: 'agent.request', riskLevel: 'medium' }], + 'malicious-agent': [{ id: 'payments.execute', riskLevel: 'critical' }], +} + +for (const [agentId, capabilities] of Object.entries(required)) { + const agent = agents.find(entry => entry.id === agentId) + if (!agent) { + errors.push(`example catalog is missing ${agentId}`) + continue + } + + if (!Array.isArray(agent.authorityNotes) || agent.authorityNotes.length === 0) { + errors.push(`${agentId} is missing authority notes`) + } + + for (const capability of capabilities) { + const actual = agent.capabilities.find(entry => entry.id === capability.id) + if (!actual) { + errors.push(`${agentId} is missing ${capability.id}`) + continue + } + if (actual.riskLevel !== capability.riskLevel) { + errors.push(`${agentId} ${capability.id} risk is ${actual.riskLevel}, expected ${capability.riskLevel}`) + } + if (!Array.isArray(actual.requiredScopes) || actual.requiredScopes.length === 0) { + errors.push(`${agentId} ${capability.id} is missing required scopes`) + } + if (typeof actual.dryRunSupported !== 'boolean') { + errors.push(`${agentId} ${capability.id} is missing dryRunSupported`) + } + if (typeof actual.humanApprovalSupported !== 'boolean') { + errors.push(`${agentId} ${capability.id} is missing humanApprovalSupported`) + } + if (typeof actual.policyProofSupported !== 'boolean') { + errors.push(`${agentId} ${capability.id} is missing policyProofSupported`) + } + } +} + +const paymentAgent = agents.find(entry => entry.id === 'payment-agent') +if (paymentAgent?.capabilities.find(entry => entry.id === 'payments.execute')?.dryRunSupported !== false) { + errors.push('payment-agent payments.execute must not be marked dry-run supported in generic FIDES') +} + +if (!paymentAgent?.authorityNotes.some(note => note.includes('Sardis-specific'))) { + errors.push('payment-agent must document that execution remains Sardis-specific') +} + +if (errors.length > 0) { + console.error(errors.join('\n')) + process.exit(1) +} + +console.log(`Example catalog audit passed for ${agents.length} agents.`) From edfbff04c44475306f763059eca07ca897161e7a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:06:03 +0300 Subject: [PATCH 254/282] docs: record canonical example audit --- docs/status/fides-v2-implementation-status.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index d8d2780..873cf77 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -51,6 +51,9 @@ Last verified locally: 2026-05-30. revocation, and incidents. - Public facade packages include export contract tests and no longer depend on empty-test fallback behavior. +- Canonical v2 example agent catalog for calendar, invoice, payment, requester, + and malicious agents, with audited capability IDs, risk classes, required + scopes, and authority notes. - Full local demo and adversarial simulation endpoints. - Public docs refreshed around `agentd`, `FidesClient`, candidate-only discovery, and authority-via-policy/session. @@ -73,6 +76,8 @@ Last verified locally: 2026-05-30. - CLI command-surface audit for the requested root command groups. - OpenAPI contract coverage for evidence-producing discovery responses. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. +- Canonical example catalog audit for the requested demo agents and + capability/risk contracts. - CI and npm publish workflows use the same full `pnpm verify` gate used locally. @@ -289,6 +294,7 @@ Recently verified commands: ```bash pnpm verify +pnpm examples:audit pnpm examples:typecheck pnpm --filter @fides/sdk build pnpm --filter @fides/sdk test @@ -366,6 +372,8 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `7b7511f test(examples): audit canonical agent catalog` +- `44bad51 docs: refresh fides v2 status gates` - `d719687 test(cli): audit agentd command surface` - `2e0bbdf ci: use full verify gate` - `c2ca6b7 test(services): require existing test suites` From 99d40b27dd0acf33c30f0aec200604b22944a450 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:10:13 +0300 Subject: [PATCH 255/282] test(examples): enforce v2 capability names --- examples/README.md | 4 ++ examples/calendar-agent.ts | 26 ++++++------- examples/demo.ts | 18 ++++----- examples/invoice-agent.ts | 46 +++++++++++----------- examples/payment-agent.ts | 60 ++++++++++++----------------- examples/requester-agent.ts | 76 ++++++++++++++++--------------------- scripts/audit-examples.mjs | 26 +++++++++++++ 7 files changed, 133 insertions(+), 123 deletions(-) diff --git a/examples/README.md b/examples/README.md index cffee31..f4dee61 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,6 +47,10 @@ capability names: - `payments.prepare` - `payments.execute` +Standalone example scripts also use dot-separated v2 capability IDs. The +`examples:audit` gate rejects legacy `namespace:action` IDs for the canonical +calendar, invoice, payment, and email examples. + ## Example Agents Each example is a self-contained script that demonstrates specific FIDES concepts. diff --git a/examples/calendar-agent.ts b/examples/calendar-agent.ts index fc74d5d..33b1262 100644 --- a/examples/calendar-agent.ts +++ b/examples/calendar-agent.ts @@ -46,17 +46,17 @@ async function main() { const capabilities: CapabilityDescriptor[] = [ { - id: 'calendar:create', - name: 'Create Event', - description: 'Create a new calendar event', + id: 'calendar.schedule', + name: 'Schedule Event', + description: 'Schedule a new calendar event', inputSchema: { type: 'object', properties: { title: { type: 'string' }, date: { type: 'string' } }, required: ['title', 'date'] }, outputSchema: { type: 'object', properties: { eventId: { type: 'string' } } }, - riskLevel: 'medium', + riskLevel: 'low', requiresApproval: false, requiresRuntimeAttestation: false, }, { - id: 'calendar:list', + id: 'calendar.read', name: 'List Events', description: 'List calendar events for a date range', inputSchema: { type: 'object', properties: { start: { type: 'string' }, end: { type: 'string' } } }, @@ -66,7 +66,7 @@ async function main() { requiresRuntimeAttestation: false, }, { - id: 'calendar:delete', + id: 'calendar.delete', name: 'Delete Event', description: 'Delete a calendar event', inputSchema: { type: 'object', properties: { eventId: { type: 'string' } }, required: ['eventId'] }, @@ -85,7 +85,7 @@ async function main() { { url: 'https://calendar-agent.example.com/fides', protocol: 'https', - capabilities: ['calendar:create', 'calendar:list', 'calendar:delete'], + capabilities: ['calendar.schedule', 'calendar.read', 'calendar.delete'], auth: 'signature', }, ], @@ -126,7 +126,7 @@ async function main() { const discovered = await localDiscovery.discover({ schema_version: 'fides.discovery_query.v1', id: 'calendar-local-query', - capability: 'calendar:create', + capability: 'calendar.schedule', }) console.log(` Registered: ${resolved ? 'yes' : 'no'}`) console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) @@ -140,7 +140,7 @@ async function main() { const delegation = await signDelegationToken(createDelegationToken({ delegator: user.did, delegatee: calendarAgent.did, - capabilities: ['calendar:create', 'calendar:list'], + capabilities: ['calendar.schedule', 'calendar.read'], constraints: { maxActions: 50, allowedContexts: ['work', 'personal'], @@ -217,7 +217,7 @@ async function main() { type: 'capability_invoke', timestamp: new Date().toISOString(), actor: calendarAgent.did, - action: 'calendar:create', + action: 'calendar.schedule', target: 'team-standup', payload: { title: 'Team Standup', date: '2026-05-05T09:00:00Z' }, privacy: { level: 'redacted' as const }, @@ -227,7 +227,7 @@ async function main() { type: 'capability_invoke', timestamp: new Date().toISOString(), actor: calendarAgent.did, - action: 'calendar:list', + action: 'calendar.read', target: 'week-view', payload: { start: '2026-05-05', end: '2026-05-12' }, privacy: { level: 'hash_only' as const }, @@ -274,7 +274,7 @@ async function main() { const goodDecision = await evaluateGuard({ agentDid: calendarAgent.did, - capabilityId: 'calendar:create', + capabilityId: 'calendar.schedule', policy: calendarPolicy, context: { dailyEvents: 5, context: 'work' }, trust: goodTrust, @@ -297,7 +297,7 @@ async function main() { const killedDecision = await evaluateGuard({ agentDid: calendarAgent.did, - capabilityId: 'calendar:create', + capabilityId: 'calendar.schedule', policy: calendarPolicy, context: { dailyEvents: 5 }, trust: killedTrust, diff --git a/examples/demo.ts b/examples/demo.ts index 09b0a11..e6fcd3a 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -43,7 +43,7 @@ async function demo() { console.log('\nStep 2: Creating an AgentCard') const capabilities: CapabilityDescriptor[] = [ { - id: 'email:send', + id: 'email.send', name: 'Send Email', description: 'Send emails on behalf of a principal', inputSchema: { type: 'object', required: ['to', 'subject'] }, @@ -53,7 +53,7 @@ async function demo() { requiresRuntimeAttestation: true, }, { - id: 'calendar:create', + id: 'calendar.schedule', name: 'Create Calendar Event', description: 'Create calendar events on behalf of a principal', inputSchema: { type: 'object', required: ['title', 'start'] }, @@ -72,7 +72,7 @@ async function demo() { { url: 'https://alice.example.com/fides', protocol: 'https', - capabilities: ['email:send', 'calendar:create'], + capabilities: ['email.send', 'calendar.schedule'], auth: 'signature', }, ], @@ -93,7 +93,7 @@ async function demo() { const delegation = await signDelegationToken(createDelegationToken({ delegator: charlie.did, delegatee: alice.did, - capabilities: ['email:send', 'calendar:create'], + capabilities: ['email.send', 'calendar.schedule'], constraints: { maxActions: 10, maxSpend: '10.00', allowedContexts: ['work'] }, expiresAt: new Date(Date.now() + 3600_000).toISOString(), }), charliePrivateKey) @@ -128,8 +128,8 @@ async function demo() { console.log('\nStep 6: Appending evidence events') let chain = createEvidenceChain() for (const event of [ - { id: 'e1', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'email:send', payload: {}, privacy: { level: 'redacted' as const } }, - { id: 'e2', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'calendar:create', payload: {}, privacy: { level: 'hash_only' as const } }, + { id: 'e1', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'email.send', payload: {}, privacy: { level: 'redacted' as const } }, + { id: 'e2', type: 'invoke', timestamp: new Date().toISOString(), actor: alice.did, action: 'calendar.schedule', payload: {}, privacy: { level: 'hash_only' as const } }, { id: 'e3', type: 'policy', timestamp: new Date().toISOString(), actor: alice.did, action: 'evaluate', payload: {}, privacy: { level: 'public' as const } }, ]) { chain = appendEvidenceEvent(chain, event, localEvidenceSignature(event)) @@ -162,7 +162,7 @@ async function demo() { }) const goodDecision = await evaluateGuard({ agentDid: alice.did, - capabilityId: 'email:send', + capabilityId: 'email.send', policy, context: { requestCount: 10 }, trust: goodTrust, @@ -172,7 +172,7 @@ async function demo() { const badTrust = createTrustContext({ reputationScore: 0.05, killSwitchEngaged: false, recentIncidents: 10 }) const badDecision = await evaluateGuard({ agentDid: bob.did, - capabilityId: 'calendar:create', + capabilityId: 'calendar.schedule', policy, context: { requestCount: 10 }, trust: badTrust, @@ -182,7 +182,7 @@ async function demo() { const killSwitchTrust = createTrustContext({ reputationScore: 0.9, killSwitchEngaged: true, recentIncidents: 0 }) const killSwitchDecision = await evaluateGuard({ agentDid: alice.did, - capabilityId: 'email:send', + capabilityId: 'email.send', policy, context: { requestCount: 10 }, trust: killSwitchTrust, diff --git a/examples/invoice-agent.ts b/examples/invoice-agent.ts index c628ad1..7472f37 100644 --- a/examples/invoice-agent.ts +++ b/examples/invoice-agent.ts @@ -52,17 +52,17 @@ async function main() { const capabilities: CapabilityDescriptor[] = [ { - id: 'invoice:create', - name: 'Create Invoice', - description: 'Generate a new invoice from order data', + id: 'invoice.reconcile', + name: 'Reconcile Invoice', + description: 'Reconcile invoice data against orders and approvals', inputSchema: { type: 'object', properties: { orderId: { type: 'string' }, amount: { type: 'number' }, currency: { type: 'string' } }, required: ['orderId', 'amount'] }, outputSchema: { type: 'object', properties: { invoiceId: { type: 'string' } } }, - riskLevel: 'high', + riskLevel: 'medium', requiresApproval: false, requiresRuntimeAttestation: true, }, { - id: 'invoice:approve', + id: 'invoice.approve', name: 'Approve Invoice', description: 'Approve an invoice for payment', inputSchema: { type: 'object', properties: { invoiceId: { type: 'string' }, approverDid: { type: 'string' } }, required: ['invoiceId', 'approverDid'] }, @@ -72,7 +72,7 @@ async function main() { requiresRuntimeAttestation: true, }, { - id: 'invoice:list', + id: 'invoice.read', name: 'List Invoices', description: 'List invoices with optional filters', inputSchema: { type: 'object', properties: { status: { type: 'string' }, dateFrom: { type: 'string' } } }, @@ -91,7 +91,7 @@ async function main() { { url: 'https://invoice-agent.example.com/fides', protocol: 'https', - capabilities: ['invoice:create', 'invoice:approve', 'invoice:list'], + capabilities: ['invoice.reconcile', 'invoice.approve', 'invoice.read'], auth: 'signature', }, ], @@ -130,7 +130,7 @@ async function main() { const cfoDelegation = await signDelegationToken(createDelegationToken({ delegator: cfo.did, delegatee: invoiceAgent.did, - capabilities: ['invoice:create', 'invoice:approve'], + capabilities: ['invoice.reconcile', 'invoice.approve'], constraints: { maxActions: 100, maxSpend: '50000.00', @@ -154,7 +154,7 @@ async function main() { const mgrDelegation = await signDelegationToken(createDelegationToken({ delegator: financeManager.did, delegatee: invoiceAgent.did, - capabilities: ['invoice:list', 'invoice:create'], + capabilities: ['invoice.read', 'invoice.reconcile'], constraints: { maxActions: 50, maxSpend: '10000.00', @@ -178,7 +178,7 @@ async function main() { const discovered = await localDiscovery.discover({ schema_version: 'fides.discovery_query.v1', id: 'invoice-local-query', - capability: 'invoice:create', + capability: 'invoice.reconcile', }) console.log(` Registered: ${resolved ? 'yes' : 'no'}`) console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) @@ -221,13 +221,13 @@ async function main() { defaultAction: 'deny' as const, } satisfies PolicyBundle - // Scenario 1: High trust, normal invoice + // Scenario 1: High trust, normal reconciliation const normalResult = evaluatePolicy(invoicePolicy, { reputationScore: 0.9, invoiceAmount: 5000, suspiciousFlags: 0, }) - console.log(` High trust, $5,000 invoice: ${normalResult.decision}`) + console.log(` High trust, $5,000 invoice reconciliation: ${normalResult.decision}`) console.log(` Matched: ${normalResult.matchedRules.join(', ')}`) // Scenario 2: Large invoice requiring approval @@ -236,7 +236,7 @@ async function main() { invoiceAmount: 50000, suspiciousFlags: 0, }) - console.log(` High trust, $50,000 invoice: ${largeResult.decision}`) + console.log(` High trust, $50,000 invoice reconciliation: ${largeResult.decision}`) console.log(` Explanation: ${largeResult.explanation.decision}`) // Scenario 3: Fraud detected @@ -254,7 +254,7 @@ async function main() { invoiceAmount: 5000, suspiciousFlags: 0, }) - console.log(` Medium trust, $5,000 invoice: ${mediumResult.decision}`) + console.log(` Medium trust, $5,000 invoice reconciliation: ${mediumResult.decision}`) console.log() // ─── Step 7: Evidence Ledger (Audit Trail) ─────────────────── @@ -269,7 +269,7 @@ async function main() { type: 'delegation_created', timestamp: new Date().toISOString(), actor: cfo.did, - action: 'invoice:create', + action: 'invoice.reconcile', target: invoiceAgent.did, payload: { maxSpend: '50000.00', maxActions: 100 }, privacy: { level: 'hash_only' as const }, @@ -279,7 +279,7 @@ async function main() { type: 'capability_invoke', timestamp: new Date().toISOString(), actor: invoiceAgent.did, - action: 'invoice:create', + action: 'invoice.reconcile', target: 'INV-2026-001', payload: { orderId: 'ORD-123', amount: 5000, currency: 'USD' }, privacy: { level: 'redacted' as const }, @@ -289,7 +289,7 @@ async function main() { type: 'capability_invoke', timestamp: new Date().toISOString(), actor: invoiceAgent.did, - action: 'invoice:approve', + action: 'invoice.approve', target: 'INV-2026-001', payload: { approverDid: financeManager.did, approved: true }, privacy: { level: 'redacted' as const }, @@ -308,7 +308,7 @@ async function main() { type: 'capability_invoke', timestamp: new Date().toISOString(), actor: invoiceAgent.did, - action: 'invoice:create', + action: 'invoice.reconcile', target: 'INV-2026-002', payload: { orderId: 'ORD-456', amount: 50000, currency: 'USD' }, privacy: { level: 'redacted' as const }, @@ -318,7 +318,7 @@ async function main() { type: 'approval_required', timestamp: new Date().toISOString(), actor: invoiceAgent.did, - action: 'invoice:approve', + action: 'invoice.approve', target: 'INV-2026-002', payload: { reason: 'Amount exceeds $25,000 threshold', escalatedTo: cfo.did }, privacy: { level: 'public' as const }, @@ -356,13 +356,13 @@ async function main() { const goodDecision = await evaluateGuard({ agentDid: invoiceAgent.did, - capabilityId: 'invoice:create', + capabilityId: 'invoice.reconcile', policy: invoicePolicy, context: { invoiceAmount: 5000, suspiciousFlags: 0 }, trust: goodTrust, }) - console.log(` Scenario: Good agent, $5,000 invoice`) + console.log(` Scenario: Good agent, $5,000 invoice reconciliation`) console.log(` Decision: ${goodDecision.decision}`) console.log(` Explanation: ${goodDecision.explanation}`) console.log(` Factors: ${goodDecision.factors.length}`) @@ -376,7 +376,7 @@ async function main() { const lowDecision = await evaluateGuard({ agentDid: invoiceAgent.did, - capabilityId: 'invoice:create', + capabilityId: 'invoice.reconcile', policy: invoicePolicy, context: { invoiceAmount: 5000 }, trust: lowTrust, @@ -395,7 +395,7 @@ async function main() { console.log(' Demonstrated:') console.log(' ✅ Identity creation (agent + principals)') console.log(' ✅ AgentCard with financial capabilities') - console.log(' ✅ Financial risk classification (high risk)') + console.log(' ✅ Invoice risk classification (medium risk)') console.log(' ✅ Delegation with spending constraints') console.log(' ✅ Chain of authority (CFO + Finance Mgr)') console.log(' ✅ Policy evaluation (allow / approve-required / deny / dry-run)') diff --git a/examples/payment-agent.ts b/examples/payment-agent.ts index 775bdfc..f1de833 100644 --- a/examples/payment-agent.ts +++ b/examples/payment-agent.ts @@ -52,35 +52,25 @@ async function main() { const capabilities: CapabilityDescriptor[] = [ { - id: 'payment:charge', - name: 'Charge Payment', - description: 'Process a payment charge', + id: 'payments.prepare', + name: 'Prepare Payment', + description: 'Prepare a payment plan without executing funds movement', inputSchema: { type: 'object', properties: { amount: { type: 'number' }, currency: { type: 'string' }, customerId: { type: 'string' } }, required: ['amount', 'currency', 'customerId'] }, - outputSchema: { type: 'object', properties: { transactionId: { type: 'string' }, status: { type: 'string' } } }, + outputSchema: { type: 'object', properties: { preparationId: { type: 'string' }, status: { type: 'string' } } }, riskLevel: 'high', requiresApproval: true, requiresRuntimeAttestation: true, }, { - id: 'payment:refund', - name: 'Refund Payment', - description: 'Process a payment refund', - inputSchema: { type: 'object', properties: { transactionId: { type: 'string' }, amount: { type: 'number' } }, required: ['transactionId'] }, - outputSchema: { type: 'object', properties: { refundId: { type: 'string' }, status: { type: 'string' } } }, - riskLevel: 'high', + id: 'payments.execute', + name: 'Execute Payment', + description: 'Sardis-specific payment execution capability, modeled but not executed by generic FIDES', + inputSchema: { type: 'object', properties: { preparationId: { type: 'string' }, amount: { type: 'number' } }, required: ['preparationId'] }, + outputSchema: { type: 'object', properties: { executionId: { type: 'string' }, status: { type: 'string' } } }, + riskLevel: 'critical', requiresApproval: true, requiresRuntimeAttestation: true, }, - { - id: 'payment:status', - name: 'Check Payment Status', - description: 'Check the status of a payment transaction', - inputSchema: { type: 'object', properties: { transactionId: { type: 'string' } }, required: ['transactionId'] }, - outputSchema: { type: 'object', properties: { status: { type: 'string' }, amount: { type: 'number' } } }, - riskLevel: 'low', - requiresApproval: false, - requiresRuntimeAttestation: false, - }, ] const agentCard: AgentCard = { @@ -91,7 +81,7 @@ async function main() { { url: 'https://payment-agent.example.com/fides', protocol: 'https', - capabilities: ['payment:charge', 'payment:refund', 'payment:status'], + capabilities: ['payments.prepare', 'payments.execute'], auth: 'signature', }, ], @@ -129,7 +119,7 @@ async function main() { const merchantDelegation = await signDelegationToken(createDelegationToken({ delegator: merchant.did, delegatee: paymentAgent.did, - capabilities: ['payment:charge', 'payment:refund'], + capabilities: ['payments.prepare'], constraints: { maxActions: 1000, maxSpend: '100000.00', @@ -157,7 +147,7 @@ async function main() { const discovered = await localDiscovery.discover({ schema_version: 'fides.discovery_query.v1', id: 'payment-local-query', - capability: 'payment:charge', + capability: 'payments.prepare', }) console.log(` Registered: ${resolved ? 'yes' : 'no'}`) console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) @@ -259,7 +249,7 @@ async function main() { type: 'delegation_created', timestamp: new Date().toISOString(), actor: merchant.did, - action: 'payment:charge', + action: 'payments.prepare', target: paymentAgent.did, payload: { maxSpend: '100000.00', maxActions: 1000 }, privacy: { level: 'hash_only' as const }, @@ -269,7 +259,7 @@ async function main() { type: 'capability_invoke', timestamp: new Date().toISOString(), actor: paymentAgent.did, - action: 'payment:charge', + action: 'payments.prepare', target: 'TXN-001', payload: { amount: 500, currency: 'USD', customerId: customer.did }, privacy: { level: 'redacted' as const }, @@ -288,7 +278,7 @@ async function main() { type: 'capability_invoke', timestamp: new Date().toISOString(), actor: paymentAgent.did, - action: 'payment:charge', + action: 'payments.prepare', target: 'TXN-002', payload: { amount: 25000, currency: 'USD', customerId: customer.did }, privacy: { level: 'redacted' as const }, @@ -298,7 +288,7 @@ async function main() { type: 'approval_required', timestamp: new Date().toISOString(), actor: paymentAgent.did, - action: 'payment:charge', + action: 'payments.prepare', target: 'TXN-002', payload: { reason: 'Amount exceeds $10,000 threshold', escalatedTo: merchant.did }, privacy: { level: 'public' as const }, @@ -308,10 +298,10 @@ async function main() { type: 'capability_invoke', timestamp: new Date().toISOString(), actor: paymentAgent.did, - action: 'payment:refund', - target: 'REF-001', - payload: { transactionId: 'TXN-001', amount: 500 }, - privacy: { level: 'redacted' as const }, + action: 'payments.execute', + target: 'blocked-generic-fides', + payload: { reason: 'Payment execution remains Sardis-specific in generic FIDES examples' }, + privacy: { level: 'public' as const }, }, ] @@ -370,7 +360,7 @@ async function main() { const goodDecision = await evaluateGuard({ agentDid: paymentAgent.did, - capabilityId: 'payment:charge', + capabilityId: 'payments.prepare', policy: paymentPolicy, context: { paymentAmount: 500, fraudScore: 0.05, transactionsPerMinute: 2 }, trust: goodTrust, @@ -390,7 +380,7 @@ async function main() { const killedDecision = await evaluateGuard({ agentDid: paymentAgent.did, - capabilityId: 'payment:charge', + capabilityId: 'payments.prepare', policy: paymentPolicy, context: { paymentAmount: 500 }, trust: killedTrust, @@ -409,7 +399,7 @@ async function main() { const badDecision = await evaluateGuard({ agentDid: paymentAgent.did, - capabilityId: 'payment:charge', + capabilityId: 'payments.prepare', policy: paymentPolicy, context: { paymentAmount: 500 }, trust: badTrust, @@ -428,7 +418,7 @@ async function main() { console.log(' Demonstrated:') console.log(' ✅ Identity creation (agent + merchant + customer)') console.log(' ✅ AgentCard with payment capabilities') - console.log(' ✅ Critical risk classification (payment keywords)') + console.log(' ✅ Critical risk classification (payments.execute remains Sardis-specific)') console.log(' ✅ Delegation with spending constraints') console.log(' ✅ Policy evaluation (allow / approve-required / deny / dry-run)') console.log(' ✅ Fraud detection policies (fraud score, velocity)') diff --git a/examples/requester-agent.ts b/examples/requester-agent.ts index ca34037..5638ced 100644 --- a/examples/requester-agent.ts +++ b/examples/requester-agent.ts @@ -50,17 +50,17 @@ async function main() { calendarAgent.metadata = { name: 'Calendar Service' } const calendarCapabilities: CapabilityDescriptor[] = [ { - id: 'calendar:create', - name: 'Create Event', - description: 'Create a calendar event', + id: 'calendar.schedule', + name: 'Schedule Event', + description: 'Schedule a calendar event', inputSchema: { type: 'object', properties: { title: { type: 'string' }, date: { type: 'string' } }, required: ['title', 'date'] }, outputSchema: { type: 'object', properties: { eventId: { type: 'string' } } }, - riskLevel: 'medium', + riskLevel: 'low', requiresApproval: false, requiresRuntimeAttestation: false, }, { - id: 'calendar:list', + id: 'calendar.read', name: 'List Events', description: 'List calendar events', inputSchema: { type: 'object', properties: { start: { type: 'string' }, end: { type: 'string' } } }, @@ -75,7 +75,7 @@ async function main() { identity: calendarAgent, capabilities: calendarCapabilities, endpoints: [ - { url: 'https://calendar.example.com/fides', protocol: 'https', capabilities: ['calendar:create', 'calendar:list'], auth: 'signature' }, + { url: 'https://calendar.example.com/fides', protocol: 'https', capabilities: ['calendar.schedule', 'calendar.read'], auth: 'signature' }, ], policies: [{ requiresRuntimeAttestation: false, requiresApproval: false, minTrustScore: 0.5 }], createdAt: new Date().toISOString(), @@ -87,32 +87,22 @@ async function main() { paymentAgent.metadata = { name: 'Payment Service' } const paymentCapabilities: CapabilityDescriptor[] = [ { - id: 'payment:charge', - name: 'Charge Payment', - description: 'Process a payment charge', + id: 'payments.prepare', + name: 'Prepare Payment', + description: 'Prepare a payment plan without executing funds movement', inputSchema: { type: 'object', properties: { amount: { type: 'number' }, currency: { type: 'string' } }, required: ['amount', 'currency'] }, - outputSchema: { type: 'object', properties: { transactionId: { type: 'string' } } }, + outputSchema: { type: 'object', properties: { preparationId: { type: 'string' } } }, riskLevel: 'high', requiresApproval: true, requiresRuntimeAttestation: true, }, - { - id: 'payment:status', - name: 'Check Payment Status', - description: 'Check payment transaction status', - inputSchema: { type: 'object', properties: { transactionId: { type: 'string' } } }, - outputSchema: { type: 'object', properties: { status: { type: 'string' } } }, - riskLevel: 'low', - requiresApproval: false, - requiresRuntimeAttestation: false, - }, ] const paymentCard: AgentCard = { id: paymentAgent.did, identity: paymentAgent, capabilities: paymentCapabilities, endpoints: [ - { url: 'https://payment.example.com/fides', protocol: 'https', capabilities: ['payment:charge', 'payment:status'], auth: 'signature' }, + { url: 'https://payment.example.com/fides', protocol: 'https', capabilities: ['payments.prepare'], auth: 'signature' }, ], policies: [{ requiresRuntimeAttestation: true, requiresApproval: true, minTrustScore: 0.8 }], createdAt: new Date().toISOString(), @@ -124,17 +114,17 @@ async function main() { invoiceAgent.metadata = { name: 'Invoice Service' } const invoiceCapabilities: CapabilityDescriptor[] = [ { - id: 'invoice:create', - name: 'Create Invoice', - description: 'Generate an invoice', + id: 'invoice.reconcile', + name: 'Reconcile Invoice', + description: 'Reconcile an invoice', inputSchema: { type: 'object', properties: { orderId: { type: 'string' }, amount: { type: 'number' } }, required: ['orderId', 'amount'] }, outputSchema: { type: 'object', properties: { invoiceId: { type: 'string' } } }, - riskLevel: 'high', + riskLevel: 'medium', requiresApproval: false, requiresRuntimeAttestation: true, }, { - id: 'invoice:list', + id: 'invoice.read', name: 'List Invoices', description: 'List invoices', inputSchema: { type: 'object', properties: { status: { type: 'string' } } }, @@ -149,7 +139,7 @@ async function main() { identity: invoiceAgent, capabilities: invoiceCapabilities, endpoints: [ - { url: 'https://invoice.example.com/fides', protocol: 'https', capabilities: ['invoice:create', 'invoice:list'], auth: 'signature' }, + { url: 'https://invoice.example.com/fides', protocol: 'https', capabilities: ['invoice.reconcile', 'invoice.read'], auth: 'signature' }, ], policies: [{ requiresRuntimeAttestation: true, requiresApproval: false, minTrustScore: 0.7 }], createdAt: new Date().toISOString(), @@ -186,7 +176,7 @@ async function main() { const verifiedCalendarCandidates = await localDiscovery.discover({ schema_version: 'fides.discovery_query.v1', id: 'requester-calendar-query', - capability: 'calendar:create', + capability: 'calendar.schedule', }) console.log(` Total agents in discovery: ${allAgents.length}`) console.log(` Verified calendar candidates: ${verifiedCalendarCandidates.filter(candidate => candidate.verified).length}`) @@ -199,7 +189,7 @@ async function main() { const userDelegation = await signDelegationToken(createDelegationToken({ delegator: user.did, delegatee: requesterAgent.did, - capabilities: ['calendar:create', 'payment:charge', 'invoice:create'], + capabilities: ['calendar.schedule', 'payments.prepare', 'invoice.reconcile'], constraints: { maxActions: 20, maxSpend: '5000.00', @@ -296,7 +286,7 @@ async function main() { const calendarGuard = await evaluateGuard({ agentDid: calendarAgent.did, - capabilityId: 'calendar:create', + capabilityId: 'calendar.schedule', policy: requesterPolicy, context: { requestCount: 5, reputationScore: calendarTrustScore }, trust: calendarTrust, @@ -304,7 +294,7 @@ async function main() { console.log(` │ 1c. Guard decision: ${calendarGuard.decision}`) if (calendarGuard.decision === 'allow') { - console.log(` │ 1d. ✅ Invoked calendar:create successfully`) + console.log(` │ 1d. ✅ Invoked calendar.schedule successfully`) // Record evidence const calendarEvent = { @@ -312,7 +302,7 @@ async function main() { type: 'capability_invoke', timestamp: new Date().toISOString(), actor: requesterAgent.did, - action: 'calendar:create', + action: 'calendar.schedule', target: calendarAgent.did, payload: { title: 'Team Meeting', date: '2026-05-06T10:00:00Z' }, privacy: { level: 'redacted' as const }, @@ -324,7 +314,7 @@ async function main() { console.log(` │`) // Flow 2: Invoke payment service (high risk, high trust) - console.log(` ┌─ Flow 2: Process Payment`) + console.log(` ┌─ Flow 2: Prepare Payment`) console.log(` │`) const paymentProvider = await localDiscovery.resolve(paymentAgent.did) @@ -345,7 +335,7 @@ async function main() { const paymentGuard = await evaluateGuard({ agentDid: paymentAgent.did, - capabilityId: 'payment:charge', + capabilityId: 'payments.prepare', policy: requesterPolicy, context: { requestCount: 5, reputationScore: paymentTrustScore }, trust: paymentTrust, @@ -353,13 +343,13 @@ async function main() { console.log(` │ 2c. Guard decision: ${paymentGuard.decision}`) if (paymentGuard.decision === 'allow') { - console.log(` │ 2d. ✅ Invoked payment:charge successfully`) + console.log(` │ 2d. ✅ Invoked payments.prepare successfully`) const paymentEvent = { id: 'req-002', type: 'capability_invoke', timestamp: new Date().toISOString(), actor: requesterAgent.did, - action: 'payment:charge', + action: 'payments.prepare', target: paymentAgent.did, payload: { amount: 150, currency: 'USD' }, privacy: { level: 'redacted' as const }, @@ -370,8 +360,8 @@ async function main() { } console.log(` │`) - // Flow 3: Invoke invoice service (high risk, medium trust) - console.log(` ┌─ Flow 3: Create Invoice`) + // Flow 3: Invoke invoice service (medium risk, medium trust) + console.log(` ┌─ Flow 3: Reconcile Invoice`) console.log(` │`) const invoiceProvider = await localDiscovery.resolve(invoiceAgent.did) @@ -392,7 +382,7 @@ async function main() { const invoiceGuard = await evaluateGuard({ agentDid: invoiceAgent.did, - capabilityId: 'invoice:create', + capabilityId: 'invoice.reconcile', policy: requesterPolicy, context: { requestCount: 5, reputationScore: invoiceTrustScore }, trust: invoiceTrust, @@ -400,7 +390,7 @@ async function main() { console.log(` │ 3c. Guard decision: ${invoiceGuard.decision}`) if (invoiceGuard.decision === 'allow') { - console.log(` │ 3d. ✅ Invoked invoice:create successfully`) + console.log(` │ 3d. ✅ Invoked invoice.reconcile successfully`) } else if (invoiceGuard.decision === 'dry-run') { console.log(` │ 3d. ⚠️ Dry-run mode (trust score below optimal)`) } else { @@ -421,7 +411,7 @@ async function main() { actor: requesterAgent.did, action: 'evaluate', target: calendarAgent.did, - payload: { capability: 'calendar:create', decision: calendarGuard.decision }, + payload: { capability: 'calendar.schedule', decision: calendarGuard.decision }, privacy: { level: 'public' as const }, }, { @@ -431,7 +421,7 @@ async function main() { actor: requesterAgent.did, action: 'evaluate', target: paymentAgent.did, - payload: { capability: 'payment:charge', decision: paymentGuard.decision }, + payload: { capability: 'payments.prepare', decision: paymentGuard.decision }, privacy: { level: 'public' as const }, }, { @@ -441,7 +431,7 @@ async function main() { actor: requesterAgent.did, action: 'evaluate', target: invoiceAgent.did, - payload: { capability: 'invoice:create', decision: invoiceGuard.decision }, + payload: { capability: 'invoice.reconcile', decision: invoiceGuard.decision }, privacy: { level: 'public' as const }, }, ] diff --git a/scripts/audit-examples.mjs b/scripts/audit-examples.mjs index 054a6fc..0f21720 100644 --- a/scripts/audit-examples.mjs +++ b/scripts/audit-examples.mjs @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process' +import { readdirSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -75,6 +76,31 @@ if (!paymentAgent?.authorityNotes.some(note => note.includes('Sardis-specific')) errors.push('payment-agent must document that execution remains Sardis-specific') } +const forbiddenLegacyCapabilities = [ + 'calendar:create', + 'calendar:list', + 'calendar:delete', + 'invoice:create', + 'invoice:approve', + 'invoice:list', + 'payment:charge', + 'payment:refund', + 'payment:status', + 'email:send', +] +const exampleSourceFiles = readdirSync(resolve(root, 'examples')) + .filter(file => file.endsWith('.ts')) + .filter(file => file !== 'agent-catalog.ts') + +for (const file of exampleSourceFiles) { + const source = readFileSync(resolve(root, 'examples', file), 'utf8') + for (const capability of forbiddenLegacyCapabilities) { + if (source.includes(capability)) { + errors.push(`${file} still references legacy capability ${capability}`) + } + } +} + if (errors.length > 0) { console.error(errors.join('\n')) process.exit(1) From e42eece982d6b78ad97409baa161555b218d0e9f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:11:08 +0300 Subject: [PATCH 256/282] docs: record v2 example cleanup --- docs/status/fides-v2-implementation-status.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 873cf77..b8ac1a6 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -54,6 +54,9 @@ Last verified locally: 2026-05-30. - Canonical v2 example agent catalog for calendar, invoice, payment, requester, and malicious agents, with audited capability IDs, risk classes, required scopes, and authority notes. +- Standalone example scripts use dot-separated v2 capability IDs for calendar, + invoice, payment, and email flows; the example audit rejects the legacy + `namespace:action` IDs that previously remained in examples. - Full local demo and adversarial simulation endpoints. - Public docs refreshed around `agentd`, `FidesClient`, candidate-only discovery, and authority-via-policy/session. @@ -78,6 +81,8 @@ Last verified locally: 2026-05-30. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. - Canonical example catalog audit for the requested demo agents and capability/risk contracts. +- Example audit coverage rejects legacy standalone example capability names so + docs, demos, and scripts stay aligned with the v2 ontology. - CI and npm publish workflows use the same full `pnpm verify` gate used locally. @@ -372,6 +377,7 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `99d40b2 test(examples): enforce v2 capability names` - `7b7511f test(examples): audit canonical agent catalog` - `44bad51 docs: refresh fides v2 status gates` - `d719687 test(cli): audit agentd command surface` From 33a6de8137b51fa9447a641e410dda473af3d721 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:17:25 +0300 Subject: [PATCH 257/282] feat(packages): add daemon and runtime effect boundaries --- README.md | 2 + docs/status/fides-v2-implementation-status.md | 4 +- packages/daemon/LICENSE | 21 +++++++ packages/daemon/README.md | 16 +++++ packages/daemon/package.json | 45 +++++++++++++ packages/daemon/src/index.ts | 40 ++++++++++++ packages/daemon/test/facade.test.ts | 26 ++++++++ packages/daemon/tsconfig.json | 10 +++ packages/runtime-effect/LICENSE | 21 +++++++ packages/runtime-effect/README.md | 16 +++++ packages/runtime-effect/package.json | 45 +++++++++++++ packages/runtime-effect/src/index.ts | 63 +++++++++++++++++++ packages/runtime-effect/test/facade.test.ts | 31 +++++++++ packages/runtime-effect/tsconfig.json | 10 +++ pnpm-lock.yaml | 32 ++++++++++ scripts/public-packages.mjs | 2 + 16 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 packages/daemon/LICENSE create mode 100644 packages/daemon/README.md create mode 100644 packages/daemon/package.json create mode 100644 packages/daemon/src/index.ts create mode 100644 packages/daemon/test/facade.test.ts create mode 100644 packages/daemon/tsconfig.json create mode 100644 packages/runtime-effect/LICENSE create mode 100644 packages/runtime-effect/README.md create mode 100644 packages/runtime-effect/package.json create mode 100644 packages/runtime-effect/src/index.ts create mode 100644 packages/runtime-effect/test/facade.test.ts create mode 100644 packages/runtime-effect/tsconfig.json diff --git a/README.md b/README.md index 10cfd6b..69f792f 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,9 @@ intent/capability + constraints | `@fides/guard` | Guard decision engine combining trust, evidence, attestation, and policy into allow/deny decisions | | `@fides/evidence` | Evidence ledger with hash-chained events, Merkle root computation, and privacy levels | | `@fides/runtime` | Runtime attestation adapter interfaces, mock attestation, and kill switch (global/agent/capability/principal) | +| `@fides/runtime-effect` | Effect-ready internal workflow boundary using framework-agnostic protocol objects | | `@fides/discovery` | Discovery provider architecture with priority-based orchestration | +| `@fides/daemon` | Local daemon defaults, config paths, well-known endpoints, and SDK client factory | | `@fides/sdk` | TypeScript SDK for identity, RFC 9421 signing, trust graph, agentd authority APIs, and hosted registry APIs | | `@fides/shared` | Shared types, constants, and utilities | | `@fides/cli` | Command-line interface for agent management and diagnostics | diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index b8ac1a6..5c8c2c4 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -48,7 +48,7 @@ Last verified locally: 2026-05-30. sessions, evidence events, revocations, incidents, and kill switch rules. - Public target-structure facade packages for crypto, identity, attestations, cards, trust, reputation, delegation, invocation, DHT, relay, registry, - revocation, and incidents. + revocation, incidents, runtime-effect, and daemon. - Public facade packages include export contract tests and no longer depend on empty-test fallback behavior. - Canonical v2 example agent catalog for calendar, invoice, payment, requester, @@ -149,6 +149,7 @@ Last verified locally: 2026-05-30. | Invocation | `packages/invocation` | | Guard decision pipeline | `packages/guard` | | Runtime attestation and kill switch | `packages/runtime` | +| Effect-ready runtime workflow boundary | `packages/runtime-effect` | | Discovery providers | `packages/discovery` | | DHT pointer records | `packages/dht` | | Relay discovery facade | `packages/relay` | @@ -158,6 +159,7 @@ Last verified locally: 2026-05-30. | SDK | `packages/sdk` | | CLI | `packages/cli` | | Local daemon/API | `services/agentd` | +| Local daemon package boundary | `packages/daemon` | | Adapters | `packages/adapters` | Some target packages are currently domain facades over the TS-first core diff --git a/packages/daemon/LICENSE b/packages/daemon/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/daemon/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/daemon/README.md b/packages/daemon/README.md new file mode 100644 index 0000000..ec55c55 --- /dev/null +++ b/packages/daemon/README.md @@ -0,0 +1,16 @@ +# @fides/daemon + +Local daemon configuration and client boundary for FIDES v2. + +The production daemon implementation currently lives in `services/agentd`. +This package provides the publishable package boundary for tools and adapters +that need stable daemon defaults, local config paths, and a Promise-based client +factory. + +```typescript +import { createDaemonClient, defaultDaemonConfig } from '@fides/daemon' +``` + +## License + +MIT diff --git a/packages/daemon/package.json b/packages/daemon/package.json new file mode 100644 index 0000000..9b1c4f2 --- /dev/null +++ b/packages/daemon/package.json @@ -0,0 +1,45 @@ +{ + "name": "@fides/daemon", + "version": "0.1.0", + "description": "FIDES v2 local daemon configuration and API surface boundary", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/daemon" + }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts new file mode 100644 index 0000000..5b1b1b6 --- /dev/null +++ b/packages/daemon/src/index.ts @@ -0,0 +1,40 @@ +import { join } from 'node:path' +import { homedir } from 'node:os' +import { FidesClient, type FidesClientOptions } from '@fides/sdk' + +export interface DaemonConfig { + daemonUrl: string + configDir: string + databasePath: string + evidenceDir: string + logsDir: string +} + +export const DEFAULT_DAEMON_PORT = 7345 + +export function defaultDaemonConfig(homeDirectory = homedir()): DaemonConfig { + const configDir = join(homeDirectory, '.fides') + return { + daemonUrl: `http://localhost:${DEFAULT_DAEMON_PORT}`, + configDir, + databasePath: join(configDir, 'fides.sqlite'), + evidenceDir: join(configDir, 'evidence'), + logsDir: join(configDir, 'logs'), + } +} + +export function createDaemonClient(options: Partial = {}): FidesClient { + const defaults = defaultDaemonConfig() + return new FidesClient({ + daemonUrl: options.daemonUrl ?? defaults.daemonUrl, + apiKey: options.apiKey, + }) +} + +export function wellKnownDaemonEndpoints(baseUrl = defaultDaemonConfig().daemonUrl): string[] { + const normalized = baseUrl.replace(/\/$/, '') + return [ + `${normalized}/.well-known/fides.json`, + `${normalized}/.well-known/agents.json`, + ] +} diff --git a/packages/daemon/test/facade.test.ts b/packages/daemon/test/facade.test.ts new file mode 100644 index 0000000..a2b9789 --- /dev/null +++ b/packages/daemon/test/facade.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { createDaemonClient, defaultDaemonConfig, wellKnownDaemonEndpoints } from '../src/index.js' + +describe('@fides/daemon boundary', () => { + it('exposes local daemon defaults and well-known endpoints', () => { + const config = defaultDaemonConfig('/tmp/fides-home') + + expect(config.daemonUrl).toBe('http://localhost:7345') + expect(config.databasePath).toBe('/tmp/fides-home/.fides/fides.sqlite') + expect(config.evidenceDir).toBe('/tmp/fides-home/.fides/evidence') + expect(wellKnownDaemonEndpoints('http://agentd.test/')).toEqual([ + 'http://agentd.test/.well-known/fides.json', + 'http://agentd.test/.well-known/agents.json', + ]) + }) + + it('creates a Promise-based SDK client for the daemon surface', () => { + const client = createDaemonClient({ daemonUrl: 'http://agentd.test' }) + + expect(client).toMatchObject({ + identity: expect.any(Object), + discovery: expect.any(Object), + sessions: expect.any(Object), + }) + }) +}) diff --git a/packages/daemon/tsconfig.json b/packages/daemon/tsconfig.json new file mode 100644 index 0000000..31b83a6 --- /dev/null +++ b/packages/daemon/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/runtime-effect/LICENSE b/packages/runtime-effect/LICENSE new file mode 100644 index 0000000..f3eb8b4 --- /dev/null +++ b/packages/runtime-effect/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/runtime-effect/README.md b/packages/runtime-effect/README.md new file mode 100644 index 0000000..76e3c65 --- /dev/null +++ b/packages/runtime-effect/README.md @@ -0,0 +1,16 @@ +# @fides/runtime-effect + +Effect-ready internal runtime workflow boundary for FIDES v2. + +This package does not make protocol objects Effect-specific. It exposes plain +workflow descriptors and Promise-based runners so Effect services, layers, and +typed-error workflows can be added around the same framework-agnostic protocol +objects. + +```typescript +import { createRuntimeWorkflow, runRuntimeWorkflow } from '@fides/runtime-effect' +``` + +## License + +MIT diff --git a/packages/runtime-effect/package.json b/packages/runtime-effect/package.json new file mode 100644 index 0000000..d794962 --- /dev/null +++ b/packages/runtime-effect/package.json @@ -0,0 +1,45 @@ +{ + "name": "@fides/runtime-effect", + "version": "0.1.0", + "description": "FIDES v2 internal runtime workflow boundary for Effect-ready orchestration", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/runtime-effect" + }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fides/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/runtime-effect/src/index.ts b/packages/runtime-effect/src/index.ts new file mode 100644 index 0000000..e69cf10 --- /dev/null +++ b/packages/runtime-effect/src/index.ts @@ -0,0 +1,63 @@ +import type { + DiscoveryCandidate, + DiscoveryQuery, + InvocationPolicyDecisionLike, + SessionGrantV2, + TrustResult, +} from '@fides/core' + +export type RuntimeWorkflowStep = + | 'discover' + | 'verify_agent_card' + | 'evaluate_trust' + | 'evaluate_policy' + | 'issue_session' + | 'append_evidence' + +export interface RuntimeWorkflowContext { + query?: DiscoveryQuery + candidates?: DiscoveryCandidate[] + trustResult?: TrustResult + policyDecision?: InvocationPolicyDecisionLike + sessionGrant?: SessionGrantV2 + evidenceEvents?: unknown[] +} + +export interface RuntimeWorkflow { + id: string + name: string + steps: RuntimeWorkflowStep[] + context: RuntimeWorkflowContext +} + +export interface RuntimeWorkflowRunner { + run(workflow: RuntimeWorkflow): Promise +} + +export function createRuntimeWorkflow(input: { + id?: string + name?: string + steps?: RuntimeWorkflowStep[] + context?: RuntimeWorkflowContext +}): RuntimeWorkflow { + return { + id: input.id ?? crypto.randomUUID(), + name: input.name ?? 'fides-runtime-workflow', + steps: input.steps ?? [ + 'discover', + 'verify_agent_card', + 'evaluate_trust', + 'evaluate_policy', + 'issue_session', + 'append_evidence', + ], + context: input.context ?? {}, + } +} + +export async function runRuntimeWorkflow( + workflow: RuntimeWorkflow, + runner: RuntimeWorkflowRunner +): Promise { + return runner.run(workflow) +} diff --git a/packages/runtime-effect/test/facade.test.ts b/packages/runtime-effect/test/facade.test.ts new file mode 100644 index 0000000..7f70832 --- /dev/null +++ b/packages/runtime-effect/test/facade.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { createRuntimeWorkflow, runRuntimeWorkflow } from '../src/index.js' + +describe('@fides/runtime-effect boundary', () => { + it('creates a plain workflow descriptor without Effect-specific protocol objects', async () => { + const workflow = createRuntimeWorkflow({ + id: 'workflow-1', + context: { + query: { + schema_version: 'fides.discovery_query.v1', + id: 'query-1', + capability: 'invoice.reconcile', + }, + }, + }) + + expect(workflow.steps).toContain('evaluate_policy') + expect(workflow.context.query?.capability).toBe('invoice.reconcile') + + const result = await runRuntimeWorkflow(workflow, { + async run(current) { + return { + ...current.context, + evidenceEvents: [], + } + }, + }) + + expect(result.evidenceEvents).toEqual([]) + }) +}) diff --git a/packages/runtime-effect/tsconfig.json b/packages/runtime-effect/tsconfig.json new file mode 100644 index 0000000..31b83a6 --- /dev/null +++ b/packages/runtime-effect/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4c5451..f275195 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,22 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.10) + packages/daemon: + dependencies: + '@fides/sdk': + specifier: workspace:* + version: link:../sdk + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + packages/delegation: dependencies: '@fides/core': @@ -420,6 +436,22 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.10) + packages/runtime-effect: + dependencies: + '@fides/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.10 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.10) + packages/sdk: dependencies: '@fides/core': diff --git a/scripts/public-packages.mjs b/scripts/public-packages.mjs index 43a4abf..e84f86b 100644 --- a/scripts/public-packages.mjs +++ b/scripts/public-packages.mjs @@ -11,6 +11,7 @@ export const publicPackageDirs = [ 'packages/delegation', 'packages/invocation', 'packages/runtime', + 'packages/runtime-effect', 'packages/discovery', 'packages/dht', 'packages/relay', @@ -18,6 +19,7 @@ export const publicPackageDirs = [ 'packages/evidence', 'packages/revocation', 'packages/incidents', + 'packages/daemon', 'packages/sdk', 'packages/cli', ] From 221fb7a904cb0271acd02de16a5a06809efd4f4a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:17:59 +0300 Subject: [PATCH 258/282] docs: record daemon package boundary --- docs/status/fides-v2-implementation-status.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 5c8c2c4..7f357cb 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -379,6 +379,7 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `33a6de8 feat(packages): add daemon and runtime effect boundaries` - `99d40b2 test(examples): enforce v2 capability names` - `7b7511f test(examples): audit canonical agent catalog` - `44bad51 docs: refresh fides v2 status gates` From 86c719e732ce94cdd1e2ef2fe75c03369680a66a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:22:10 +0300 Subject: [PATCH 259/282] feat(packages): publish guard and adapters surfaces --- packages/guard/LICENSE | 21 +++++++++++++ packages/guard/README.md | 49 +++++++++++++++++++++++++++++++ packages/guard/package.json | 15 +++++++++- scripts/check-package-hygiene.mjs | 20 +++++++++++-- scripts/public-packages.mjs | 2 ++ 5 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 packages/guard/LICENSE create mode 100644 packages/guard/README.md diff --git a/packages/guard/LICENSE b/packages/guard/LICENSE new file mode 100644 index 0000000..6187f36 --- /dev/null +++ b/packages/guard/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 FIDES Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/guard/README.md b/packages/guard/README.md new file mode 100644 index 0000000..db2385a --- /dev/null +++ b/packages/guard/README.md @@ -0,0 +1,49 @@ +# @fides/guard + +Pre-execution guard decision engine for FIDES v2. + +`@fides/guard` combines the policy engine, trust context, runtime attestation +status, evidence chain status, revocation state, and kill-switch state into a +single allow/deny/approval/dry-run decision before an agent capability is +invoked. + +Discovery is intentionally not authority. A discovered agent must still pass the +guard path before execution. + +## Install + +```sh +pnpm add @fides/guard +``` + +## Usage + +```ts +import { createTrustContext, evaluateGuard } from '@fides/guard' + +const trust = createTrustContext({ + reputationScore: 0.9, + verifiedIdentity: true, + delegationValid: true, + attestationFresh: true, + policyAllowed: true, + evidenceChainValid: true, +}) + +const decision = await evaluateGuard({ + agentDid: 'did:fides:agent:invoice', + capabilityId: 'invoice.reconcile', + policy, + trust, +}) + +if (decision.decision !== 'allow') { + throw new Error(decision.explanation) +} +``` + +## Status + +This package is part of the FIDES v2 local-first TypeScript implementation. It +is production-shaped for the public SDK surface, while external policy, +attestation, registry, DHT, and relay integrations remain adapter-ready. diff --git a/packages/guard/package.json b/packages/guard/package.json index 4456105..7a7e486 100644 --- a/packages/guard/package.json +++ b/packages/guard/package.json @@ -1,13 +1,26 @@ { "name": "@fides/guard", "version": "0.1.0", - "private": true, + "description": "FIDES v2 pre-execution guard decision engine", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/EfeDurmaz16/fides", + "directory": "packages/guard" + }, + "homepage": "https://github.com/EfeDurmaz16/fides#readme", "engines": { "node": ">=22.0.0" }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "sideEffects": false, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/scripts/check-package-hygiene.mjs b/scripts/check-package-hygiene.mjs index f9269d0..6bf9a2e 100644 --- a/scripts/check-package-hygiene.mjs +++ b/scripts/check-package-hygiene.mjs @@ -1,12 +1,27 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' -import { publicPackageJsonPaths } from './public-packages.mjs' +import { publicPackageDirs, publicPackageJsonPaths } from './public-packages.mjs' const root = dirname(dirname(fileURLToPath(import.meta.url))) const requiredFileEntries = new Set(['README.md', 'LICENSE']) const errors = [] +const configuredPublicPackageDirs = new Set(publicPackageDirs) +const discoveredPublicPackageDirs = readdirSync(join(root, 'packages'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => `packages/${entry.name}`) + .filter((packageDir) => existsSync(join(root, packageDir, 'package.json'))) + .filter((packageDir) => { + const pkg = JSON.parse(readFileSync(join(root, packageDir, 'package.json'), 'utf8')) + return pkg.private !== true + }) + +for (const packageDir of discoveredPublicPackageDirs) { + if (!configuredPublicPackageDirs.has(packageDir)) { + errors.push(`${packageDir}/package.json is publishable but missing from scripts/public-packages.mjs`) + } +} for (const packagePath of publicPackageJsonPaths) { const absolutePath = join(root, packagePath) @@ -15,6 +30,7 @@ for (const packagePath of publicPackageJsonPaths) { const label = `${pkg.name} (${packagePath})` if (pkg.private) { + errors.push(`${label} is listed as publishable but marked private`) continue } diff --git a/scripts/public-packages.mjs b/scripts/public-packages.mjs index e84f86b..88fd5fb 100644 --- a/scripts/public-packages.mjs +++ b/scripts/public-packages.mjs @@ -19,6 +19,8 @@ export const publicPackageDirs = [ 'packages/evidence', 'packages/revocation', 'packages/incidents', + 'packages/adapters', + 'packages/guard', 'packages/daemon', 'packages/sdk', 'packages/cli', From ebce8df87ba685769c6af50640e5a25c134553b9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:22:24 +0300 Subject: [PATCH 260/282] docs: record public package surface gate --- docs/status/fides-v2-implementation-status.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 7f357cb..c290c2c 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -48,9 +48,12 @@ Last verified locally: 2026-05-30. sessions, evidence events, revocations, incidents, and kill switch rules. - Public target-structure facade packages for crypto, identity, attestations, cards, trust, reputation, delegation, invocation, DHT, relay, registry, - revocation, incidents, runtime-effect, and daemon. + revocation, incidents, adapters, guard, runtime-effect, and daemon. - Public facade packages include export contract tests and no longer depend on empty-test fallback behavior. +- Publishable package hygiene and dry-run pack checks now cover all 25 + non-private packages under `packages/*`, and fail if a publishable package is + omitted from the public package gate. - Canonical v2 example agent catalog for calendar, invoice, payment, requester, and malicious agents, with audited capability IDs, risk classes, required scopes, and authority notes. @@ -78,6 +81,8 @@ Last verified locally: 2026-05-30. responses. - CLI command-surface audit for the requested root command groups. - OpenAPI contract coverage for evidence-producing discovery responses. +- Publishable package gate for all non-private package manifests, including + README/LICENSE/package metadata and dry-run package contents. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. - Canonical example catalog audit for the requested demo agents and capability/risk contracts. @@ -379,6 +384,7 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `86c719e feat(packages): publish guard and adapters surfaces` - `33a6de8 feat(packages): add daemon and runtime effect boundaries` - `99d40b2 test(examples): enforce v2 capability names` - `7b7511f test(examples): audit canonical agent catalog` From cb14b167f611dfd579e62497637fc0ab352af485 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:26:45 +0300 Subject: [PATCH 261/282] test(examples): enforce target agent layout --- docs/status/fides-v2-implementation-status.md | 6 +- examples/README.md | 35 +- examples/calendar-agent.ts | 335 +----------- examples/calendar-agent/README.md | 18 + examples/calendar-agent/index.ts | 334 ++++++++++++ examples/invoice-agent.ts | 412 +-------------- examples/invoice-agent/README.md | 18 + examples/invoice-agent/index.ts | 411 ++++++++++++++ examples/malicious-agent.ts | 99 +--- examples/malicious-agent/README.md | 17 + examples/malicious-agent/index.ts | 98 ++++ examples/payment-agent.ts | 436 +-------------- examples/payment-agent/README.md | 19 + examples/payment-agent/index.ts | 435 +++++++++++++++ examples/requester-agent.ts | 500 +----------------- examples/requester-agent/README.md | 18 + examples/requester-agent/index.ts | 499 +++++++++++++++++ scripts/audit-examples.mjs | 36 +- 18 files changed, 1931 insertions(+), 1795 deletions(-) create mode 100644 examples/calendar-agent/README.md create mode 100644 examples/calendar-agent/index.ts create mode 100644 examples/invoice-agent/README.md create mode 100644 examples/invoice-agent/index.ts create mode 100644 examples/malicious-agent/README.md create mode 100644 examples/malicious-agent/index.ts create mode 100644 examples/payment-agent/README.md create mode 100644 examples/payment-agent/index.ts create mode 100644 examples/requester-agent/README.md create mode 100644 examples/requester-agent/index.ts diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index c290c2c..a0e08bf 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -57,9 +57,13 @@ Last verified locally: 2026-05-30. - Canonical v2 example agent catalog for calendar, invoice, payment, requester, and malicious agents, with audited capability IDs, risk classes, required scopes, and authority notes. +- Example agents now have target-structure directories with runnable + `index.ts` entrypoints and per-agent READMEs; legacy top-level + `examples/.ts` wrappers remain for compatibility. - Standalone example scripts use dot-separated v2 capability IDs for calendar, invoice, payment, and email flows; the example audit rejects the legacy - `namespace:action` IDs that previously remained in examples. + `namespace:action` IDs that previously remained in examples and verifies the + target example-agent directories exist. - Full local demo and adversarial simulation endpoints. - Public docs refreshed around `agentd`, `FidesClient`, candidate-only discovery, and authority-via-policy/session. diff --git a/examples/README.md b/examples/README.md index f4dee61..9171366 100644 --- a/examples/README.md +++ b/examples/README.md @@ -53,14 +53,15 @@ calendar, invoice, payment, and email examples. ## Example Agents -Each example is a self-contained script that demonstrates specific FIDES concepts. +Each example is a self-contained directory with a runnable `index.ts` entrypoint. +The top-level `examples/.ts` files remain as compatibility wrappers. Run any example with: ```bash -npx tsx examples/.ts +pnpm exec tsx examples//index.ts ``` -### calendar-agent.ts +### calendar-agent Calendar management agent demonstrating identity creation, policy evaluation, and evidence recording. @@ -74,10 +75,10 @@ Calendar management agent demonstrating identity creation, policy evaluation, an - Guard decision engine (good agent + kill switch scenarios) ```bash -npx tsx examples/calendar-agent.ts +pnpm exec tsx examples/calendar-agent/index.ts ``` -### invoice-agent.ts +### invoice-agent Invoice processing agent demonstrating financial risk classification and delegation chains. @@ -91,10 +92,10 @@ Invoice processing agent demonstrating financial risk classification and delegat - Guard decision engine (good trust + low trust scenarios) ```bash -npx tsx examples/invoice-agent.ts +pnpm exec tsx examples/invoice-agent/index.ts ``` -### payment-agent.ts +### payment-agent Payment processing agent demonstrating critical risk capabilities and kill switch operations. @@ -109,10 +110,10 @@ Payment processing agent demonstrating critical risk capabilities and kill switc - Guard decision engine (good / killed / bad trust scenarios) ```bash -npx tsx examples/payment-agent.ts +pnpm exec tsx examples/payment-agent/index.ts ``` -### requester-agent.ts +### requester-agent Agent that discovers and invokes other agents, demonstrating the full trust fabric flow. @@ -127,7 +128,21 @@ Agent that discovers and invokes other agents, demonstrating the full trust fabr - Capability risk classification across all providers ```bash -npx tsx examples/requester-agent.ts +pnpm exec tsx examples/requester-agent/index.ts +``` + +### malicious-agent + +Adversarial agent fixture used to exercise policy denial, trust penalties, +revocation, and evidence-backed detection. + +**Demonstrates:** +- Critical-risk malicious capability metadata +- Incident and revocation inputs for trust/policy paths +- Expected denial and detection behavior for adversarial simulation + +```bash +pnpm exec tsx examples/malicious-agent/index.ts ``` ## End-to-End Flow diff --git a/examples/calendar-agent.ts b/examples/calendar-agent.ts index 33b1262..a963911 100644 --- a/examples/calendar-agent.ts +++ b/examples/calendar-agent.ts @@ -1,334 +1 @@ -/** - * Calendar Agent — Manages calendar events - * - * Demonstrates: - * - Identity creation and AgentCard publishing - * - Policy evaluation for calendar access - * - Evidence recording for calendar operations - * - Guard decision engine with trust context - * - * Run: npx tsx examples/calendar-agent.ts - */ - -import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' -import type { AgentCard, CapabilityDescriptor } from '@fides/core' -import { classifyCapabilityRisk } from '@fides/core' -import { evaluatePolicy, type PolicyBundle } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' -import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' -import { evaluateGuard, createTrustContext } from '@fides/guard' -import { LocalDiscoveryProvider } from '@fides/discovery' - -async function main() { - console.log('═'.repeat(60)) - console.log(' Calendar Agent — FIDES Example') - console.log('═'.repeat(60)) - console.log() - - // ─── Step 1: Create Identity ───────────────────────────────── - console.log('📝 Step 1: Creating Agent Identity') - console.log('─'.repeat(40)) - - const { identity: calendarAgent, privateKey: calendarAgentPrivateKey } = await createAgentIdentity() - calendarAgent.metadata = { name: 'Calendar Assistant', version: '1.0.0' } - const { identity: user, privateKey: userPrivateKey } = await createPrincipalIdentity({ - type: 'individual', - displayName: 'Alice', - }) - - console.log(` Agent: ${calendarAgent.did}`) - console.log(` User: ${user.did}`) - console.log() - - // ─── Step 2: Create AgentCard ──────────────────────────────── - console.log('🃏 Step 2: Creating AgentCard') - console.log('─'.repeat(40)) - - const capabilities: CapabilityDescriptor[] = [ - { - id: 'calendar.schedule', - name: 'Schedule Event', - description: 'Schedule a new calendar event', - inputSchema: { type: 'object', properties: { title: { type: 'string' }, date: { type: 'string' } }, required: ['title', 'date'] }, - outputSchema: { type: 'object', properties: { eventId: { type: 'string' } } }, - riskLevel: 'low', - requiresApproval: false, - requiresRuntimeAttestation: false, - }, - { - id: 'calendar.read', - name: 'List Events', - description: 'List calendar events for a date range', - inputSchema: { type: 'object', properties: { start: { type: 'string' }, end: { type: 'string' } } }, - outputSchema: { type: 'array', items: { type: 'object' } }, - riskLevel: 'low', - requiresApproval: false, - requiresRuntimeAttestation: false, - }, - { - id: 'calendar.delete', - name: 'Delete Event', - description: 'Delete a calendar event', - inputSchema: { type: 'object', properties: { eventId: { type: 'string' } }, required: ['eventId'] }, - outputSchema: { type: 'object', properties: { success: { type: 'boolean' } } }, - riskLevel: 'high', - requiresApproval: true, - requiresRuntimeAttestation: false, - }, - ] - - const agentCard: AgentCard = { - id: calendarAgent.did, - identity: calendarAgent, - capabilities, - endpoints: [ - { - url: 'https://calendar-agent.example.com/fides', - protocol: 'https', - capabilities: ['calendar.schedule', 'calendar.read', 'calendar.delete'], - auth: 'signature', - }, - ], - policies: [ - { requiresRuntimeAttestation: false, requiresApproval: false, minTrustScore: 0.5 }, - ], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - - const validation = validateAgentCard(agentCard) - console.log(` Name: ${agentCard.identity.metadata!.name}`) - console.log(` Capabilities: ${agentCard.capabilities.map(c => c.id).join(', ')}`) - console.log(` Valid: ${validation.valid}`) - if (!validation.valid) { - console.log(` Errors: ${validation.errors.join(', ')}`) - } - console.log() - - // ─── Step 3: Capability Risk Classification ────────────────── - console.log('⚠️ Step 3: Capability Risk Classification') - console.log('─'.repeat(40)) - - for (const cap of agentCard.capabilities) { - const risk = classifyCapabilityRisk(cap.id) - console.log(` ${cap.id}: ${risk} (declared: ${cap.riskLevel})`) - } - console.log() - - // ─── Step 4: Register with Local Discovery ─────────────────── - console.log('🔍 Step 4: Registering with Local Discovery') - console.log('─'.repeat(40)) - - const localDiscovery = new LocalDiscoveryProvider() - const signedCard = await signAgentCard(agentCard, calendarAgentPrivateKey, calendarAgent.did) - await localDiscovery.register(signedCard) - const resolved = await localDiscovery.resolve(calendarAgent.did) - const discovered = await localDiscovery.discover({ - schema_version: 'fides.discovery_query.v1', - id: 'calendar-local-query', - capability: 'calendar.schedule', - }) - console.log(` Registered: ${resolved ? 'yes' : 'no'}`) - console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) - console.log(` Resolved name: ${resolved?.identity.metadata!.name}`) - console.log() - - // ─── Step 5: Delegation from User to Agent ─────────────────── - console.log('🔑 Step 5: User Delegates Calendar Access') - console.log('─'.repeat(40)) - - const delegation = await signDelegationToken(createDelegationToken({ - delegator: user.did, - delegatee: calendarAgent.did, - capabilities: ['calendar.schedule', 'calendar.read'], - constraints: { - maxActions: 50, - allowedContexts: ['work', 'personal'], - }, - expiresAt: new Date(Date.now() + 86400000).toISOString(), // 24h - }), userPrivateKey) - - const delegationValid = validateDelegationToken(delegation) - console.log(` Token ID: ${delegation.id}`) - console.log(` Delegator: ${delegation.delegator}`) - console.log(` Delegatee: ${delegation.delegatee}`) - console.log(` Capabilities: ${delegation.capabilities.join(', ')}`) - console.log(` Valid: ${delegationValid.valid}`) - console.log() - - // ─── Step 6: Policy Evaluation ─────────────────────────────── - console.log('📋 Step 6: Policy Evaluation for Calendar Access') - console.log('─'.repeat(40)) - - const calendarPolicy = { - id: 'calendar-policy', - version: '1.0.0', - rules: [ - { - id: 'trusted-user', - condition: { operator: 'gte', field: 'reputationScore', value: 0.7 }, - action: 'allow' as const, - explanation: 'User has sufficient trust score', - }, - { - id: 'rate-limit', - condition: { operator: 'gt', field: 'dailyEvents', value: 100 }, - action: 'deny' as const, - explanation: 'Daily event creation limit exceeded', - }, - { - id: 'business-hours', - condition: { operator: 'in', field: 'context', value: ['work'] }, - action: 'allow' as const, - explanation: 'Request within business hours context', - }, - ], - defaultAction: 'deny' as const, - } satisfies PolicyBundle - - // Scenario: trusted user, normal usage - const allowResult = evaluatePolicy(calendarPolicy, { - reputationScore: 0.85, - dailyEvents: 5, - context: 'work', - }) - console.log(` Trusted user, 5 events: ${allowResult.decision}`) - console.log(` Matched: ${allowResult.matchedRules.join(', ')}`) - - // Scenario: rate limit exceeded - const denyResult = evaluatePolicy(calendarPolicy, { - reputationScore: 0.85, - dailyEvents: 150, - context: 'work', - }) - console.log(` Trusted user, 150 events: ${denyResult.decision}`) - console.log(` Explanation: ${denyResult.explanation.decision}`) - console.log() - - // ─── Step 7: Evidence Ledger ───────────────────────────────── - console.log('📜 Step 7: Recording Evidence Events') - console.log('─'.repeat(40)) - - let evidenceChain = createEvidenceChain() - - const calendarEvents = [ - { - id: 'evt-001', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: calendarAgent.did, - action: 'calendar.schedule', - target: 'team-standup', - payload: { title: 'Team Standup', date: '2026-05-05T09:00:00Z' }, - privacy: { level: 'redacted' as const }, - }, - { - id: 'evt-002', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: calendarAgent.did, - action: 'calendar.read', - target: 'week-view', - payload: { start: '2026-05-05', end: '2026-05-12' }, - privacy: { level: 'hash_only' as const }, - }, - { - id: 'evt-003', - type: 'policy_eval', - timestamp: new Date().toISOString(), - actor: calendarAgent.did, - action: 'evaluate', - payload: { policy: 'calendar-policy', decision: allowResult.decision }, - privacy: { level: 'public' as const }, - }, - ] - - for (const evt of calendarEvents) { - evidenceChain = appendEvidenceEvent(evidenceChain, evt, localEvidenceSignature(evt)) - console.log(` Recorded: ${evt.type} — ${evt.action}`) - } - - const chainValid = verifyEvidenceChain(evidenceChain) - const merkleRoot = buildMerkleRoot(evidenceChain.events.map(e => e.hash)) - console.log(` Chain valid: ${chainValid}`) - console.log(` Events: ${evidenceChain.events.length}`) - console.log(` Merkle root: ${merkleRoot.slice(0, 16)}...`) - console.log() - - // ─── Step 8: Guard Decision Engine ─────────────────────────── - console.log('🛡️ Step 8: Guard Decision Engine') - console.log('─'.repeat(40)) - - const teeProvider = new MockTEEProvider() - const attestation = await teeProvider.attest(calendarAgent.did) - - // Good scenario - const goodTrust = createTrustContext({ - reputationScore: 0.85, - capabilityScore: 0.9, - attestation, - evidenceChain, - killSwitchEngaged: false, - recentIncidents: 0, - }) - - const goodDecision = await evaluateGuard({ - agentDid: calendarAgent.did, - capabilityId: 'calendar.schedule', - policy: calendarPolicy, - context: { dailyEvents: 5, context: 'work' }, - trust: goodTrust, - }) - - console.log(` Scenario: Good agent, normal usage`) - console.log(` Decision: ${goodDecision.decision}`) - console.log(` Explanation: ${goodDecision.explanation}`) - console.log(` Factors: ${goodDecision.factors.length}`) - - // Kill switch scenario - const killSwitch = new InMemoryKillSwitch() - killSwitch.engage({ type: 'agent', did: calendarAgent.did }) - - const killedTrust = createTrustContext({ - reputationScore: 0.85, - killSwitchEngaged: true, - recentIncidents: 0, - }) - - const killedDecision = await evaluateGuard({ - agentDid: calendarAgent.did, - capabilityId: 'calendar.schedule', - policy: calendarPolicy, - context: { dailyEvents: 5 }, - trust: killedTrust, - }) - - console.log(` Scenario: Kill switch engaged`) - console.log(` Decision: ${killedDecision.decision}`) - console.log(` Explanation: ${killedDecision.explanation}`) - - killSwitch.disengage({ type: 'agent', did: calendarAgent.did }) - console.log() - - // ─── Summary ───────────────────────────────────────────────── - console.log('═'.repeat(60)) - console.log(' Calendar Agent Demo Complete') - console.log('═'.repeat(60)) - console.log() - console.log(' Demonstrated:') - console.log(' ✅ Identity creation') - console.log(' ✅ AgentCard with calendar capabilities') - console.log(' ✅ Capability risk classification') - console.log(' ✅ Local discovery registration') - console.log(' ✅ Delegation from user to agent') - console.log(' ✅ Policy evaluation (allow + deny scenarios)') - console.log(' ✅ Evidence chain with Merkle root') - console.log(' ✅ Guard decision engine (good + kill switch)') - console.log() -} - -function localEvidenceSignature(event: unknown): string { - return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` -} - -main().catch(console.error) +import './calendar-agent/index.js' diff --git a/examples/calendar-agent/README.md b/examples/calendar-agent/README.md new file mode 100644 index 0000000..9609a87 --- /dev/null +++ b/examples/calendar-agent/README.md @@ -0,0 +1,18 @@ +# Calendar Agent + +Runnable FIDES v2 example agent for `calendar.schedule`. + +This example demonstrates local agent identity, signed AgentCard creation, local +discovery registration, scoped delegation, policy evaluation, hash-chained +evidence, and pre-execution guard evaluation. Discovery remains candidate-only; +authority still requires policy and a scoped grant before invocation. + +```bash +pnpm exec tsx examples/calendar-agent/index.ts +``` + +The legacy wrapper remains available: + +```bash +pnpm exec tsx examples/calendar-agent.ts +``` diff --git a/examples/calendar-agent/index.ts b/examples/calendar-agent/index.ts new file mode 100644 index 0000000..7278544 --- /dev/null +++ b/examples/calendar-agent/index.ts @@ -0,0 +1,334 @@ +/** + * Calendar Agent — Manages calendar events + * + * Demonstrates: + * - Identity creation and AgentCard publishing + * - Policy evaluation for calendar access + * - Evidence recording for calendar operations + * - Guard decision engine with trust context + * + * Run: pnpm exec tsx examples/calendar-agent/index.ts + */ + +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' +import type { AgentCard, CapabilityDescriptor } from '@fides/core' +import { classifyCapabilityRisk } from '@fides/core' +import { evaluatePolicy, type PolicyBundle } from '@fides/policy' +import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' +import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' +import { evaluateGuard, createTrustContext } from '@fides/guard' +import { LocalDiscoveryProvider } from '@fides/discovery' + +async function main() { + console.log('═'.repeat(60)) + console.log(' Calendar Agent — FIDES Example') + console.log('═'.repeat(60)) + console.log() + + // ─── Step 1: Create Identity ───────────────────────────────── + console.log('📝 Step 1: Creating Agent Identity') + console.log('─'.repeat(40)) + + const { identity: calendarAgent, privateKey: calendarAgentPrivateKey } = await createAgentIdentity() + calendarAgent.metadata = { name: 'Calendar Assistant', version: '1.0.0' } + const { identity: user, privateKey: userPrivateKey } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Alice', + }) + + console.log(` Agent: ${calendarAgent.did}`) + console.log(` User: ${user.did}`) + console.log() + + // ─── Step 2: Create AgentCard ──────────────────────────────── + console.log('🃏 Step 2: Creating AgentCard') + console.log('─'.repeat(40)) + + const capabilities: CapabilityDescriptor[] = [ + { + id: 'calendar.schedule', + name: 'Schedule Event', + description: 'Schedule a new calendar event', + inputSchema: { type: 'object', properties: { title: { type: 'string' }, date: { type: 'string' } }, required: ['title', 'date'] }, + outputSchema: { type: 'object', properties: { eventId: { type: 'string' } } }, + riskLevel: 'low', + requiresApproval: false, + requiresRuntimeAttestation: false, + }, + { + id: 'calendar.read', + name: 'List Events', + description: 'List calendar events for a date range', + inputSchema: { type: 'object', properties: { start: { type: 'string' }, end: { type: 'string' } } }, + outputSchema: { type: 'array', items: { type: 'object' } }, + riskLevel: 'low', + requiresApproval: false, + requiresRuntimeAttestation: false, + }, + { + id: 'calendar.delete', + name: 'Delete Event', + description: 'Delete a calendar event', + inputSchema: { type: 'object', properties: { eventId: { type: 'string' } }, required: ['eventId'] }, + outputSchema: { type: 'object', properties: { success: { type: 'boolean' } } }, + riskLevel: 'high', + requiresApproval: true, + requiresRuntimeAttestation: false, + }, + ] + + const agentCard: AgentCard = { + id: calendarAgent.did, + identity: calendarAgent, + capabilities, + endpoints: [ + { + url: 'https://calendar-agent.example.com/fides', + protocol: 'https', + capabilities: ['calendar.schedule', 'calendar.read', 'calendar.delete'], + auth: 'signature', + }, + ], + policies: [ + { requiresRuntimeAttestation: false, requiresApproval: false, minTrustScore: 0.5 }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + const validation = validateAgentCard(agentCard) + console.log(` Name: ${agentCard.identity.metadata!.name}`) + console.log(` Capabilities: ${agentCard.capabilities.map(c => c.id).join(', ')}`) + console.log(` Valid: ${validation.valid}`) + if (!validation.valid) { + console.log(` Errors: ${validation.errors.join(', ')}`) + } + console.log() + + // ─── Step 3: Capability Risk Classification ────────────────── + console.log('⚠️ Step 3: Capability Risk Classification') + console.log('─'.repeat(40)) + + for (const cap of agentCard.capabilities) { + const risk = classifyCapabilityRisk(cap.id) + console.log(` ${cap.id}: ${risk} (declared: ${cap.riskLevel})`) + } + console.log() + + // ─── Step 4: Register with Local Discovery ─────────────────── + console.log('🔍 Step 4: Registering with Local Discovery') + console.log('─'.repeat(40)) + + const localDiscovery = new LocalDiscoveryProvider() + const signedCard = await signAgentCard(agentCard, calendarAgentPrivateKey, calendarAgent.did) + await localDiscovery.register(signedCard) + const resolved = await localDiscovery.resolve(calendarAgent.did) + const discovered = await localDiscovery.discover({ + schema_version: 'fides.discovery_query.v1', + id: 'calendar-local-query', + capability: 'calendar.schedule', + }) + console.log(` Registered: ${resolved ? 'yes' : 'no'}`) + console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) + console.log(` Resolved name: ${resolved?.identity.metadata!.name}`) + console.log() + + // ─── Step 5: Delegation from User to Agent ─────────────────── + console.log('🔑 Step 5: User Delegates Calendar Access') + console.log('─'.repeat(40)) + + const delegation = await signDelegationToken(createDelegationToken({ + delegator: user.did, + delegatee: calendarAgent.did, + capabilities: ['calendar.schedule', 'calendar.read'], + constraints: { + maxActions: 50, + allowedContexts: ['work', 'personal'], + }, + expiresAt: new Date(Date.now() + 86400000).toISOString(), // 24h + }), userPrivateKey) + + const delegationValid = validateDelegationToken(delegation) + console.log(` Token ID: ${delegation.id}`) + console.log(` Delegator: ${delegation.delegator}`) + console.log(` Delegatee: ${delegation.delegatee}`) + console.log(` Capabilities: ${delegation.capabilities.join(', ')}`) + console.log(` Valid: ${delegationValid.valid}`) + console.log() + + // ─── Step 6: Policy Evaluation ─────────────────────────────── + console.log('📋 Step 6: Policy Evaluation for Calendar Access') + console.log('─'.repeat(40)) + + const calendarPolicy = { + id: 'calendar-policy', + version: '1.0.0', + rules: [ + { + id: 'trusted-user', + condition: { operator: 'gte', field: 'reputationScore', value: 0.7 }, + action: 'allow' as const, + explanation: 'User has sufficient trust score', + }, + { + id: 'rate-limit', + condition: { operator: 'gt', field: 'dailyEvents', value: 100 }, + action: 'deny' as const, + explanation: 'Daily event creation limit exceeded', + }, + { + id: 'business-hours', + condition: { operator: 'in', field: 'context', value: ['work'] }, + action: 'allow' as const, + explanation: 'Request within business hours context', + }, + ], + defaultAction: 'deny' as const, + } satisfies PolicyBundle + + // Scenario: trusted user, normal usage + const allowResult = evaluatePolicy(calendarPolicy, { + reputationScore: 0.85, + dailyEvents: 5, + context: 'work', + }) + console.log(` Trusted user, 5 events: ${allowResult.decision}`) + console.log(` Matched: ${allowResult.matchedRules.join(', ')}`) + + // Scenario: rate limit exceeded + const denyResult = evaluatePolicy(calendarPolicy, { + reputationScore: 0.85, + dailyEvents: 150, + context: 'work', + }) + console.log(` Trusted user, 150 events: ${denyResult.decision}`) + console.log(` Explanation: ${denyResult.explanation.decision}`) + console.log() + + // ─── Step 7: Evidence Ledger ───────────────────────────────── + console.log('📜 Step 7: Recording Evidence Events') + console.log('─'.repeat(40)) + + let evidenceChain = createEvidenceChain() + + const calendarEvents = [ + { + id: 'evt-001', + type: 'capability_invoke', + timestamp: new Date().toISOString(), + actor: calendarAgent.did, + action: 'calendar.schedule', + target: 'team-standup', + payload: { title: 'Team Standup', date: '2026-05-05T09:00:00Z' }, + privacy: { level: 'redacted' as const }, + }, + { + id: 'evt-002', + type: 'capability_invoke', + timestamp: new Date().toISOString(), + actor: calendarAgent.did, + action: 'calendar.read', + target: 'week-view', + payload: { start: '2026-05-05', end: '2026-05-12' }, + privacy: { level: 'hash_only' as const }, + }, + { + id: 'evt-003', + type: 'policy_eval', + timestamp: new Date().toISOString(), + actor: calendarAgent.did, + action: 'evaluate', + payload: { policy: 'calendar-policy', decision: allowResult.decision }, + privacy: { level: 'public' as const }, + }, + ] + + for (const evt of calendarEvents) { + evidenceChain = appendEvidenceEvent(evidenceChain, evt, localEvidenceSignature(evt)) + console.log(` Recorded: ${evt.type} — ${evt.action}`) + } + + const chainValid = verifyEvidenceChain(evidenceChain) + const merkleRoot = buildMerkleRoot(evidenceChain.events.map(e => e.hash)) + console.log(` Chain valid: ${chainValid}`) + console.log(` Events: ${evidenceChain.events.length}`) + console.log(` Merkle root: ${merkleRoot.slice(0, 16)}...`) + console.log() + + // ─── Step 8: Guard Decision Engine ─────────────────────────── + console.log('🛡️ Step 8: Guard Decision Engine') + console.log('─'.repeat(40)) + + const teeProvider = new MockTEEProvider() + const attestation = await teeProvider.attest(calendarAgent.did) + + // Good scenario + const goodTrust = createTrustContext({ + reputationScore: 0.85, + capabilityScore: 0.9, + attestation, + evidenceChain, + killSwitchEngaged: false, + recentIncidents: 0, + }) + + const goodDecision = await evaluateGuard({ + agentDid: calendarAgent.did, + capabilityId: 'calendar.schedule', + policy: calendarPolicy, + context: { dailyEvents: 5, context: 'work' }, + trust: goodTrust, + }) + + console.log(` Scenario: Good agent, normal usage`) + console.log(` Decision: ${goodDecision.decision}`) + console.log(` Explanation: ${goodDecision.explanation}`) + console.log(` Factors: ${goodDecision.factors.length}`) + + // Kill switch scenario + const killSwitch = new InMemoryKillSwitch() + killSwitch.engage({ type: 'agent', did: calendarAgent.did }) + + const killedTrust = createTrustContext({ + reputationScore: 0.85, + killSwitchEngaged: true, + recentIncidents: 0, + }) + + const killedDecision = await evaluateGuard({ + agentDid: calendarAgent.did, + capabilityId: 'calendar.schedule', + policy: calendarPolicy, + context: { dailyEvents: 5 }, + trust: killedTrust, + }) + + console.log(` Scenario: Kill switch engaged`) + console.log(` Decision: ${killedDecision.decision}`) + console.log(` Explanation: ${killedDecision.explanation}`) + + killSwitch.disengage({ type: 'agent', did: calendarAgent.did }) + console.log() + + // ─── Summary ───────────────────────────────────────────────── + console.log('═'.repeat(60)) + console.log(' Calendar Agent Demo Complete') + console.log('═'.repeat(60)) + console.log() + console.log(' Demonstrated:') + console.log(' ✅ Identity creation') + console.log(' ✅ AgentCard with calendar capabilities') + console.log(' ✅ Capability risk classification') + console.log(' ✅ Local discovery registration') + console.log(' ✅ Delegation from user to agent') + console.log(' ✅ Policy evaluation (allow + deny scenarios)') + console.log(' ✅ Evidence chain with Merkle root') + console.log(' ✅ Guard decision engine (good + kill switch)') + console.log() +} + +function localEvidenceSignature(event: unknown): string { + return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +} + +main().catch(console.error) diff --git a/examples/invoice-agent.ts b/examples/invoice-agent.ts index 7472f37..a7df5f8 100644 --- a/examples/invoice-agent.ts +++ b/examples/invoice-agent.ts @@ -1,411 +1 @@ -/** - * Invoice Agent — Handles invoice processing - * - * Demonstrates: - * - Identity creation and AgentCard publishing - * - Delegation with financial constraints - * - Capability risk classification (financial = high risk) - * - Policy evaluation with approval-required rules - * - Evidence recording for audit trail - * - * Run: npx tsx examples/invoice-agent.ts - */ - -import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' -import type { AgentCard, CapabilityDescriptor } from '@fides/core' -import { classifyCapabilityRisk } from '@fides/core' -import { evaluatePolicy, type PolicyBundle } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' -import { MockTEEProvider } from '@fides/runtime' -import { evaluateGuard, createTrustContext } from '@fides/guard' -import { LocalDiscoveryProvider } from '@fides/discovery' - -async function main() { - console.log('═'.repeat(60)) - console.log(' Invoice Agent — FIDES Example') - console.log('═'.repeat(60)) - console.log() - - // ─── Step 1: Create Identities ─────────────────────────────── - console.log('📝 Step 1: Creating Identities') - console.log('─'.repeat(40)) - - const { identity: invoiceAgent, privateKey: invoiceAgentPrivateKey } = await createAgentIdentity() - invoiceAgent.metadata = { name: 'Invoice Processor', version: '1.0.0' } - const { identity: financeManager, privateKey: financeManagerPrivateKey } = await createPrincipalIdentity({ - type: 'individual', - displayName: 'Finance Manager', - }) - const { identity: cfo, privateKey: cfoPrivateKey } = await createPrincipalIdentity({ - type: 'individual', - displayName: 'CFO', - }) - - console.log(` Invoice Agent: ${invoiceAgent.did}`) - console.log(` Finance Mgr: ${financeManager.did}`) - console.log(` CFO: ${cfo.did}`) - console.log() - - // ─── Step 2: Create AgentCard ──────────────────────────────── - console.log('🃏 Step 2: Creating AgentCard') - console.log('─'.repeat(40)) - - const capabilities: CapabilityDescriptor[] = [ - { - id: 'invoice.reconcile', - name: 'Reconcile Invoice', - description: 'Reconcile invoice data against orders and approvals', - inputSchema: { type: 'object', properties: { orderId: { type: 'string' }, amount: { type: 'number' }, currency: { type: 'string' } }, required: ['orderId', 'amount'] }, - outputSchema: { type: 'object', properties: { invoiceId: { type: 'string' } } }, - riskLevel: 'medium', - requiresApproval: false, - requiresRuntimeAttestation: true, - }, - { - id: 'invoice.approve', - name: 'Approve Invoice', - description: 'Approve an invoice for payment', - inputSchema: { type: 'object', properties: { invoiceId: { type: 'string' }, approverDid: { type: 'string' } }, required: ['invoiceId', 'approverDid'] }, - outputSchema: { type: 'object', properties: { approved: { type: 'boolean' } } }, - riskLevel: 'high', - requiresApproval: true, - requiresRuntimeAttestation: true, - }, - { - id: 'invoice.read', - name: 'List Invoices', - description: 'List invoices with optional filters', - inputSchema: { type: 'object', properties: { status: { type: 'string' }, dateFrom: { type: 'string' } } }, - outputSchema: { type: 'array', items: { type: 'object' } }, - riskLevel: 'low', - requiresApproval: false, - requiresRuntimeAttestation: false, - }, - ] - - const agentCard: AgentCard = { - id: invoiceAgent.did, - identity: invoiceAgent, - capabilities, - endpoints: [ - { - url: 'https://invoice-agent.example.com/fides', - protocol: 'https', - capabilities: ['invoice.reconcile', 'invoice.approve', 'invoice.read'], - auth: 'signature', - }, - ], - policies: [ - { requiresRuntimeAttestation: true, requiresApproval: true, minTrustScore: 0.7 }, - ], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - - const validation = validateAgentCard(agentCard) - console.log(` Name: ${agentCard.identity.metadata!.name}`) - console.log(` Capabilities: ${agentCard.capabilities.map(c => c.id).join(', ')}`) - console.log(` Valid: ${validation.valid}`) - if (!validation.valid) { - console.log(` Errors: ${validation.errors.join(', ')}`) - } - console.log() - - // ─── Step 3: Financial Risk Classification ─────────────────── - console.log('⚠️ Step 3: Financial Risk Classification') - console.log('─'.repeat(40)) - - for (const cap of agentCard.capabilities) { - const risk = classifyCapabilityRisk(cap.id) - const isHighRisk = cap.riskLevel === 'high' || risk === 'high' || risk === 'critical' - console.log(` ${cap.id}: ${risk} (declared: ${cap.riskLevel}) ${isHighRisk ? '⚡ HIGH RISK' : ''}`) - } - console.log() - - // ─── Step 4: Delegation with Financial Constraints ─────────── - console.log('🔑 Step 4: Delegation with Financial Constraints') - console.log('─'.repeat(40)) - - // CFO delegates invoice processing to the agent with spending limits - const cfoDelegation = await signDelegationToken(createDelegationToken({ - delegator: cfo.did, - delegatee: invoiceAgent.did, - capabilities: ['invoice.reconcile', 'invoice.approve'], - constraints: { - maxActions: 100, - maxSpend: '50000.00', - allowedContexts: ['business'], - forbiddenContexts: ['personal', 'test'], - }, - expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(), // 7 days - }), cfoPrivateKey) - - const cfoValid = validateDelegationToken(cfoDelegation) - console.log(` Token ID: ${cfoDelegation.id}`) - console.log(` CFO → Agent: ${cfoDelegation.delegator} → ${cfoDelegation.delegatee}`) - console.log(` Max spend: $${cfoDelegation.constraints.maxSpend}`) - console.log(` Max actions: ${cfoDelegation.constraints.maxActions}`) - console.log(` Allowed: ${cfoDelegation.constraints.allowedContexts?.join(', ')}`) - console.log(` Forbidden: ${cfoDelegation.constraints.forbiddenContexts?.join(', ')}`) - console.log(` Valid: ${cfoValid.valid}`) - console.log() - - // Finance manager also delegates (chain of authority) - const mgrDelegation = await signDelegationToken(createDelegationToken({ - delegator: financeManager.did, - delegatee: invoiceAgent.did, - capabilities: ['invoice.read', 'invoice.reconcile'], - constraints: { - maxActions: 50, - maxSpend: '10000.00', - allowedContexts: ['business'], - }, - expiresAt: new Date(Date.now() + 86400000).toISOString(), // 24h - }), financeManagerPrivateKey) - - console.log(` Finance Mgr → Agent: ${mgrDelegation.delegator} → ${mgrDelegation.delegatee}`) - console.log(` Max spend: $${mgrDelegation.constraints.maxSpend}`) - console.log() - - // ─── Step 5: Register with Local Discovery ─────────────────── - console.log('🔍 Step 5: Registering with Local Discovery') - console.log('─'.repeat(40)) - - const localDiscovery = new LocalDiscoveryProvider() - const signedCard = await signAgentCard(agentCard, invoiceAgentPrivateKey, invoiceAgent.did) - await localDiscovery.register(signedCard) - const resolved = await localDiscovery.resolve(invoiceAgent.did) - const discovered = await localDiscovery.discover({ - schema_version: 'fides.discovery_query.v1', - id: 'invoice-local-query', - capability: 'invoice.reconcile', - }) - console.log(` Registered: ${resolved ? 'yes' : 'no'}`) - console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) - console.log(` Resolved: ${resolved?.identity.metadata!.name}`) - console.log() - - // ─── Step 6: Policy Evaluation ─────────────────────────────── - console.log('📋 Step 6: Policy Evaluation for Invoice Processing') - console.log('─'.repeat(40)) - - const invoicePolicy = { - id: 'invoice-policy', - version: '1.0.0', - rules: [ - { - id: 'high-trust-allow', - condition: { operator: 'gte', field: 'reputationScore', value: 0.8 }, - action: 'allow' as const, - explanation: 'High trust agent approved for invoice operations', - }, - { - id: 'large-invoice-approval', - condition: { operator: 'gt', field: 'invoiceAmount', value: 25000 }, - action: 'approve-required' as const, - explanation: 'Invoices over $25,000 require CFO approval', - }, - { - id: 'fraud-detection', - condition: { operator: 'gt', field: 'suspiciousFlags', value: 0 }, - action: 'deny' as const, - explanation: 'Suspicious activity detected — invoice blocked', - }, - { - id: 'medium-trust-warn', - condition: { operator: 'gte', field: 'reputationScore', value: 0.5 }, - action: 'dry-run' as const, - explanation: 'Medium trust — processing in dry-run mode', - }, - ], - defaultAction: 'deny' as const, - } satisfies PolicyBundle - - // Scenario 1: High trust, normal reconciliation - const normalResult = evaluatePolicy(invoicePolicy, { - reputationScore: 0.9, - invoiceAmount: 5000, - suspiciousFlags: 0, - }) - console.log(` High trust, $5,000 invoice reconciliation: ${normalResult.decision}`) - console.log(` Matched: ${normalResult.matchedRules.join(', ')}`) - - // Scenario 2: Large invoice requiring approval - const largeResult = evaluatePolicy(invoicePolicy, { - reputationScore: 0.9, - invoiceAmount: 50000, - suspiciousFlags: 0, - }) - console.log(` High trust, $50,000 invoice reconciliation: ${largeResult.decision}`) - console.log(` Explanation: ${largeResult.explanation.decision}`) - - // Scenario 3: Fraud detected - const fraudResult = evaluatePolicy(invoicePolicy, { - reputationScore: 0.9, - invoiceAmount: 5000, - suspiciousFlags: 2, - }) - console.log(` High trust, fraud flags: ${fraudResult.decision}`) - console.log(` Explanation: ${fraudResult.explanation.decision}`) - - // Scenario 4: Medium trust - const mediumResult = evaluatePolicy(invoicePolicy, { - reputationScore: 0.6, - invoiceAmount: 5000, - suspiciousFlags: 0, - }) - console.log(` Medium trust, $5,000 invoice reconciliation: ${mediumResult.decision}`) - console.log() - - // ─── Step 7: Evidence Ledger (Audit Trail) ─────────────────── - console.log('📜 Step 7: Evidence Ledger — Audit Trail') - console.log('─'.repeat(40)) - - let evidenceChain = createEvidenceChain() - - const auditEvents = [ - { - id: 'audit-001', - type: 'delegation_created', - timestamp: new Date().toISOString(), - actor: cfo.did, - action: 'invoice.reconcile', - target: invoiceAgent.did, - payload: { maxSpend: '50000.00', maxActions: 100 }, - privacy: { level: 'hash_only' as const }, - }, - { - id: 'audit-002', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: invoiceAgent.did, - action: 'invoice.reconcile', - target: 'INV-2026-001', - payload: { orderId: 'ORD-123', amount: 5000, currency: 'USD' }, - privacy: { level: 'redacted' as const }, - }, - { - id: 'audit-003', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: invoiceAgent.did, - action: 'invoice.approve', - target: 'INV-2026-001', - payload: { approverDid: financeManager.did, approved: true }, - privacy: { level: 'redacted' as const }, - }, - { - id: 'audit-004', - type: 'policy_eval', - timestamp: new Date().toISOString(), - actor: invoiceAgent.did, - action: 'evaluate', - payload: { policy: 'invoice-policy', decision: normalResult.decision, invoiceAmount: 5000 }, - privacy: { level: 'public' as const }, - }, - { - id: 'audit-005', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: invoiceAgent.did, - action: 'invoice.reconcile', - target: 'INV-2026-002', - payload: { orderId: 'ORD-456', amount: 50000, currency: 'USD' }, - privacy: { level: 'redacted' as const }, - }, - { - id: 'audit-006', - type: 'approval_required', - timestamp: new Date().toISOString(), - actor: invoiceAgent.did, - action: 'invoice.approve', - target: 'INV-2026-002', - payload: { reason: 'Amount exceeds $25,000 threshold', escalatedTo: cfo.did }, - privacy: { level: 'public' as const }, - }, - ] - - for (const evt of auditEvents) { - evidenceChain = appendEvidenceEvent(evidenceChain, evt, localEvidenceSignature(evt)) - console.log(` Recorded: ${evt.type} — ${evt.action} → ${evt.target}`) - } - - const chainValid = verifyEvidenceChain(evidenceChain) - const merkleRoot = buildMerkleRoot(evidenceChain.events.map(e => e.hash)) - console.log(` Chain valid: ${chainValid}`) - console.log(` Events: ${evidenceChain.events.length}`) - console.log(` Merkle root: ${merkleRoot.slice(0, 16)}...`) - console.log() - - // ─── Step 8: Guard Decision Engine ─────────────────────────── - console.log('🛡️ Step 8: Guard Decision Engine') - console.log('─'.repeat(40)) - - const teeProvider = new MockTEEProvider() - const attestation = await teeProvider.attest(invoiceAgent.did) - - // Good scenario - const goodTrust = createTrustContext({ - reputationScore: 0.9, - capabilityScore: 0.95, - attestation, - evidenceChain, - killSwitchEngaged: false, - recentIncidents: 0, - }) - - const goodDecision = await evaluateGuard({ - agentDid: invoiceAgent.did, - capabilityId: 'invoice.reconcile', - policy: invoicePolicy, - context: { invoiceAmount: 5000, suspiciousFlags: 0 }, - trust: goodTrust, - }) - - console.log(` Scenario: Good agent, $5,000 invoice reconciliation`) - console.log(` Decision: ${goodDecision.decision}`) - console.log(` Explanation: ${goodDecision.explanation}`) - console.log(` Factors: ${goodDecision.factors.length}`) - - // Low trust scenario - const lowTrust = createTrustContext({ - reputationScore: 0.2, - killSwitchEngaged: false, - recentIncidents: 3, - }) - - const lowDecision = await evaluateGuard({ - agentDid: invoiceAgent.did, - capabilityId: 'invoice.reconcile', - policy: invoicePolicy, - context: { invoiceAmount: 5000 }, - trust: lowTrust, - }) - - console.log(` Scenario: Low trust agent (0.2)`) - console.log(` Decision: ${lowDecision.decision}`) - console.log(` Explanation: ${lowDecision.explanation}`) - console.log() - - // ─── Summary ───────────────────────────────────────────────── - console.log('═'.repeat(60)) - console.log(' Invoice Agent Demo Complete') - console.log('═'.repeat(60)) - console.log() - console.log(' Demonstrated:') - console.log(' ✅ Identity creation (agent + principals)') - console.log(' ✅ AgentCard with financial capabilities') - console.log(' ✅ Invoice risk classification (medium risk)') - console.log(' ✅ Delegation with spending constraints') - console.log(' ✅ Chain of authority (CFO + Finance Mgr)') - console.log(' ✅ Policy evaluation (allow / approve-required / deny / dry-run)') - console.log(' ✅ Evidence chain for audit trail') - console.log(' ✅ Guard decision engine (good + low trust)') - console.log() -} - -function localEvidenceSignature(event: unknown): string { - return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` -} - -main().catch(console.error) +import './invoice-agent/index.js' diff --git a/examples/invoice-agent/README.md b/examples/invoice-agent/README.md new file mode 100644 index 0000000..beb13b6 --- /dev/null +++ b/examples/invoice-agent/README.md @@ -0,0 +1,18 @@ +# Invoice Agent + +Runnable FIDES v2 example agent for `invoice.reconcile`. + +This example demonstrates medium-risk capability handling, delegation +constraints, policy decisions, evidence records, and guard evaluation for an +invoice workflow. It models discovery as a candidate source, not an authority +grant. + +```bash +pnpm exec tsx examples/invoice-agent/index.ts +``` + +The legacy wrapper remains available: + +```bash +pnpm exec tsx examples/invoice-agent.ts +``` diff --git a/examples/invoice-agent/index.ts b/examples/invoice-agent/index.ts new file mode 100644 index 0000000..fab3694 --- /dev/null +++ b/examples/invoice-agent/index.ts @@ -0,0 +1,411 @@ +/** + * Invoice Agent — Handles invoice processing + * + * Demonstrates: + * - Identity creation and AgentCard publishing + * - Delegation with financial constraints + * - Capability risk classification (financial = high risk) + * - Policy evaluation with approval-required rules + * - Evidence recording for audit trail + * + * Run: pnpm exec tsx examples/invoice-agent/index.ts + */ + +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' +import type { AgentCard, CapabilityDescriptor } from '@fides/core' +import { classifyCapabilityRisk } from '@fides/core' +import { evaluatePolicy, type PolicyBundle } from '@fides/policy' +import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' +import { MockTEEProvider } from '@fides/runtime' +import { evaluateGuard, createTrustContext } from '@fides/guard' +import { LocalDiscoveryProvider } from '@fides/discovery' + +async function main() { + console.log('═'.repeat(60)) + console.log(' Invoice Agent — FIDES Example') + console.log('═'.repeat(60)) + console.log() + + // ─── Step 1: Create Identities ─────────────────────────────── + console.log('📝 Step 1: Creating Identities') + console.log('─'.repeat(40)) + + const { identity: invoiceAgent, privateKey: invoiceAgentPrivateKey } = await createAgentIdentity() + invoiceAgent.metadata = { name: 'Invoice Processor', version: '1.0.0' } + const { identity: financeManager, privateKey: financeManagerPrivateKey } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Finance Manager', + }) + const { identity: cfo, privateKey: cfoPrivateKey } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'CFO', + }) + + console.log(` Invoice Agent: ${invoiceAgent.did}`) + console.log(` Finance Mgr: ${financeManager.did}`) + console.log(` CFO: ${cfo.did}`) + console.log() + + // ─── Step 2: Create AgentCard ──────────────────────────────── + console.log('🃏 Step 2: Creating AgentCard') + console.log('─'.repeat(40)) + + const capabilities: CapabilityDescriptor[] = [ + { + id: 'invoice.reconcile', + name: 'Reconcile Invoice', + description: 'Reconcile invoice data against orders and approvals', + inputSchema: { type: 'object', properties: { orderId: { type: 'string' }, amount: { type: 'number' }, currency: { type: 'string' } }, required: ['orderId', 'amount'] }, + outputSchema: { type: 'object', properties: { invoiceId: { type: 'string' } } }, + riskLevel: 'medium', + requiresApproval: false, + requiresRuntimeAttestation: true, + }, + { + id: 'invoice.approve', + name: 'Approve Invoice', + description: 'Approve an invoice for payment', + inputSchema: { type: 'object', properties: { invoiceId: { type: 'string' }, approverDid: { type: 'string' } }, required: ['invoiceId', 'approverDid'] }, + outputSchema: { type: 'object', properties: { approved: { type: 'boolean' } } }, + riskLevel: 'high', + requiresApproval: true, + requiresRuntimeAttestation: true, + }, + { + id: 'invoice.read', + name: 'List Invoices', + description: 'List invoices with optional filters', + inputSchema: { type: 'object', properties: { status: { type: 'string' }, dateFrom: { type: 'string' } } }, + outputSchema: { type: 'array', items: { type: 'object' } }, + riskLevel: 'low', + requiresApproval: false, + requiresRuntimeAttestation: false, + }, + ] + + const agentCard: AgentCard = { + id: invoiceAgent.did, + identity: invoiceAgent, + capabilities, + endpoints: [ + { + url: 'https://invoice-agent.example.com/fides', + protocol: 'https', + capabilities: ['invoice.reconcile', 'invoice.approve', 'invoice.read'], + auth: 'signature', + }, + ], + policies: [ + { requiresRuntimeAttestation: true, requiresApproval: true, minTrustScore: 0.7 }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + const validation = validateAgentCard(agentCard) + console.log(` Name: ${agentCard.identity.metadata!.name}`) + console.log(` Capabilities: ${agentCard.capabilities.map(c => c.id).join(', ')}`) + console.log(` Valid: ${validation.valid}`) + if (!validation.valid) { + console.log(` Errors: ${validation.errors.join(', ')}`) + } + console.log() + + // ─── Step 3: Financial Risk Classification ─────────────────── + console.log('⚠️ Step 3: Financial Risk Classification') + console.log('─'.repeat(40)) + + for (const cap of agentCard.capabilities) { + const risk = classifyCapabilityRisk(cap.id) + const isHighRisk = cap.riskLevel === 'high' || risk === 'high' || risk === 'critical' + console.log(` ${cap.id}: ${risk} (declared: ${cap.riskLevel}) ${isHighRisk ? '⚡ HIGH RISK' : ''}`) + } + console.log() + + // ─── Step 4: Delegation with Financial Constraints ─────────── + console.log('🔑 Step 4: Delegation with Financial Constraints') + console.log('─'.repeat(40)) + + // CFO delegates invoice processing to the agent with spending limits + const cfoDelegation = await signDelegationToken(createDelegationToken({ + delegator: cfo.did, + delegatee: invoiceAgent.did, + capabilities: ['invoice.reconcile', 'invoice.approve'], + constraints: { + maxActions: 100, + maxSpend: '50000.00', + allowedContexts: ['business'], + forbiddenContexts: ['personal', 'test'], + }, + expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(), // 7 days + }), cfoPrivateKey) + + const cfoValid = validateDelegationToken(cfoDelegation) + console.log(` Token ID: ${cfoDelegation.id}`) + console.log(` CFO → Agent: ${cfoDelegation.delegator} → ${cfoDelegation.delegatee}`) + console.log(` Max spend: $${cfoDelegation.constraints.maxSpend}`) + console.log(` Max actions: ${cfoDelegation.constraints.maxActions}`) + console.log(` Allowed: ${cfoDelegation.constraints.allowedContexts?.join(', ')}`) + console.log(` Forbidden: ${cfoDelegation.constraints.forbiddenContexts?.join(', ')}`) + console.log(` Valid: ${cfoValid.valid}`) + console.log() + + // Finance manager also delegates (chain of authority) + const mgrDelegation = await signDelegationToken(createDelegationToken({ + delegator: financeManager.did, + delegatee: invoiceAgent.did, + capabilities: ['invoice.read', 'invoice.reconcile'], + constraints: { + maxActions: 50, + maxSpend: '10000.00', + allowedContexts: ['business'], + }, + expiresAt: new Date(Date.now() + 86400000).toISOString(), // 24h + }), financeManagerPrivateKey) + + console.log(` Finance Mgr → Agent: ${mgrDelegation.delegator} → ${mgrDelegation.delegatee}`) + console.log(` Max spend: $${mgrDelegation.constraints.maxSpend}`) + console.log() + + // ─── Step 5: Register with Local Discovery ─────────────────── + console.log('🔍 Step 5: Registering with Local Discovery') + console.log('─'.repeat(40)) + + const localDiscovery = new LocalDiscoveryProvider() + const signedCard = await signAgentCard(agentCard, invoiceAgentPrivateKey, invoiceAgent.did) + await localDiscovery.register(signedCard) + const resolved = await localDiscovery.resolve(invoiceAgent.did) + const discovered = await localDiscovery.discover({ + schema_version: 'fides.discovery_query.v1', + id: 'invoice-local-query', + capability: 'invoice.reconcile', + }) + console.log(` Registered: ${resolved ? 'yes' : 'no'}`) + console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) + console.log(` Resolved: ${resolved?.identity.metadata!.name}`) + console.log() + + // ─── Step 6: Policy Evaluation ─────────────────────────────── + console.log('📋 Step 6: Policy Evaluation for Invoice Processing') + console.log('─'.repeat(40)) + + const invoicePolicy = { + id: 'invoice-policy', + version: '1.0.0', + rules: [ + { + id: 'high-trust-allow', + condition: { operator: 'gte', field: 'reputationScore', value: 0.8 }, + action: 'allow' as const, + explanation: 'High trust agent approved for invoice operations', + }, + { + id: 'large-invoice-approval', + condition: { operator: 'gt', field: 'invoiceAmount', value: 25000 }, + action: 'approve-required' as const, + explanation: 'Invoices over $25,000 require CFO approval', + }, + { + id: 'fraud-detection', + condition: { operator: 'gt', field: 'suspiciousFlags', value: 0 }, + action: 'deny' as const, + explanation: 'Suspicious activity detected — invoice blocked', + }, + { + id: 'medium-trust-warn', + condition: { operator: 'gte', field: 'reputationScore', value: 0.5 }, + action: 'dry-run' as const, + explanation: 'Medium trust — processing in dry-run mode', + }, + ], + defaultAction: 'deny' as const, + } satisfies PolicyBundle + + // Scenario 1: High trust, normal reconciliation + const normalResult = evaluatePolicy(invoicePolicy, { + reputationScore: 0.9, + invoiceAmount: 5000, + suspiciousFlags: 0, + }) + console.log(` High trust, $5,000 invoice reconciliation: ${normalResult.decision}`) + console.log(` Matched: ${normalResult.matchedRules.join(', ')}`) + + // Scenario 2: Large invoice requiring approval + const largeResult = evaluatePolicy(invoicePolicy, { + reputationScore: 0.9, + invoiceAmount: 50000, + suspiciousFlags: 0, + }) + console.log(` High trust, $50,000 invoice reconciliation: ${largeResult.decision}`) + console.log(` Explanation: ${largeResult.explanation.decision}`) + + // Scenario 3: Fraud detected + const fraudResult = evaluatePolicy(invoicePolicy, { + reputationScore: 0.9, + invoiceAmount: 5000, + suspiciousFlags: 2, + }) + console.log(` High trust, fraud flags: ${fraudResult.decision}`) + console.log(` Explanation: ${fraudResult.explanation.decision}`) + + // Scenario 4: Medium trust + const mediumResult = evaluatePolicy(invoicePolicy, { + reputationScore: 0.6, + invoiceAmount: 5000, + suspiciousFlags: 0, + }) + console.log(` Medium trust, $5,000 invoice reconciliation: ${mediumResult.decision}`) + console.log() + + // ─── Step 7: Evidence Ledger (Audit Trail) ─────────────────── + console.log('📜 Step 7: Evidence Ledger — Audit Trail') + console.log('─'.repeat(40)) + + let evidenceChain = createEvidenceChain() + + const auditEvents = [ + { + id: 'audit-001', + type: 'delegation_created', + timestamp: new Date().toISOString(), + actor: cfo.did, + action: 'invoice.reconcile', + target: invoiceAgent.did, + payload: { maxSpend: '50000.00', maxActions: 100 }, + privacy: { level: 'hash_only' as const }, + }, + { + id: 'audit-002', + type: 'capability_invoke', + timestamp: new Date().toISOString(), + actor: invoiceAgent.did, + action: 'invoice.reconcile', + target: 'INV-2026-001', + payload: { orderId: 'ORD-123', amount: 5000, currency: 'USD' }, + privacy: { level: 'redacted' as const }, + }, + { + id: 'audit-003', + type: 'capability_invoke', + timestamp: new Date().toISOString(), + actor: invoiceAgent.did, + action: 'invoice.approve', + target: 'INV-2026-001', + payload: { approverDid: financeManager.did, approved: true }, + privacy: { level: 'redacted' as const }, + }, + { + id: 'audit-004', + type: 'policy_eval', + timestamp: new Date().toISOString(), + actor: invoiceAgent.did, + action: 'evaluate', + payload: { policy: 'invoice-policy', decision: normalResult.decision, invoiceAmount: 5000 }, + privacy: { level: 'public' as const }, + }, + { + id: 'audit-005', + type: 'capability_invoke', + timestamp: new Date().toISOString(), + actor: invoiceAgent.did, + action: 'invoice.reconcile', + target: 'INV-2026-002', + payload: { orderId: 'ORD-456', amount: 50000, currency: 'USD' }, + privacy: { level: 'redacted' as const }, + }, + { + id: 'audit-006', + type: 'approval_required', + timestamp: new Date().toISOString(), + actor: invoiceAgent.did, + action: 'invoice.approve', + target: 'INV-2026-002', + payload: { reason: 'Amount exceeds $25,000 threshold', escalatedTo: cfo.did }, + privacy: { level: 'public' as const }, + }, + ] + + for (const evt of auditEvents) { + evidenceChain = appendEvidenceEvent(evidenceChain, evt, localEvidenceSignature(evt)) + console.log(` Recorded: ${evt.type} — ${evt.action} → ${evt.target}`) + } + + const chainValid = verifyEvidenceChain(evidenceChain) + const merkleRoot = buildMerkleRoot(evidenceChain.events.map(e => e.hash)) + console.log(` Chain valid: ${chainValid}`) + console.log(` Events: ${evidenceChain.events.length}`) + console.log(` Merkle root: ${merkleRoot.slice(0, 16)}...`) + console.log() + + // ─── Step 8: Guard Decision Engine ─────────────────────────── + console.log('🛡️ Step 8: Guard Decision Engine') + console.log('─'.repeat(40)) + + const teeProvider = new MockTEEProvider() + const attestation = await teeProvider.attest(invoiceAgent.did) + + // Good scenario + const goodTrust = createTrustContext({ + reputationScore: 0.9, + capabilityScore: 0.95, + attestation, + evidenceChain, + killSwitchEngaged: false, + recentIncidents: 0, + }) + + const goodDecision = await evaluateGuard({ + agentDid: invoiceAgent.did, + capabilityId: 'invoice.reconcile', + policy: invoicePolicy, + context: { invoiceAmount: 5000, suspiciousFlags: 0 }, + trust: goodTrust, + }) + + console.log(` Scenario: Good agent, $5,000 invoice reconciliation`) + console.log(` Decision: ${goodDecision.decision}`) + console.log(` Explanation: ${goodDecision.explanation}`) + console.log(` Factors: ${goodDecision.factors.length}`) + + // Low trust scenario + const lowTrust = createTrustContext({ + reputationScore: 0.2, + killSwitchEngaged: false, + recentIncidents: 3, + }) + + const lowDecision = await evaluateGuard({ + agentDid: invoiceAgent.did, + capabilityId: 'invoice.reconcile', + policy: invoicePolicy, + context: { invoiceAmount: 5000 }, + trust: lowTrust, + }) + + console.log(` Scenario: Low trust agent (0.2)`) + console.log(` Decision: ${lowDecision.decision}`) + console.log(` Explanation: ${lowDecision.explanation}`) + console.log() + + // ─── Summary ───────────────────────────────────────────────── + console.log('═'.repeat(60)) + console.log(' Invoice Agent Demo Complete') + console.log('═'.repeat(60)) + console.log() + console.log(' Demonstrated:') + console.log(' ✅ Identity creation (agent + principals)') + console.log(' ✅ AgentCard with financial capabilities') + console.log(' ✅ Invoice risk classification (medium risk)') + console.log(' ✅ Delegation with spending constraints') + console.log(' ✅ Chain of authority (CFO + Finance Mgr)') + console.log(' ✅ Policy evaluation (allow / approve-required / deny / dry-run)') + console.log(' ✅ Evidence chain for audit trail') + console.log(' ✅ Guard decision engine (good + low trust)') + console.log() +} + +function localEvidenceSignature(event: unknown): string { + return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +} + +main().catch(console.error) diff --git a/examples/malicious-agent.ts b/examples/malicious-agent.ts index 8dc139f..5a2b006 100644 --- a/examples/malicious-agent.ts +++ b/examples/malicious-agent.ts @@ -1,98 +1 @@ -/** - * Malicious Agent example. - * - * Demonstrates adversarial metadata that FIDES should discover only as a - * candidate, then penalize or deny through verification, trust, revocation, - * incident, and policy checks. - * - * Run: pnpm exec tsx examples/malicious-agent.ts - */ - -import { - createCapabilityDescriptor, - createIncidentRecordV2, - computeCapabilityReputation, - computeTrustResult, - evaluateInvocationPreflight, -} from '@fides/core' - -function main() { - const capability = createCapabilityDescriptor({ - id: 'payments.execute', - requiredScopes: ['payments:execute'], - supportedControls: ['human_approval', 'runtime_attestation', 'policy_proof'], - }) - - const incident = createIncidentRecordV2({ - reporter: 'did:fides:principal', - targetAgentId: 'did:fides:malicious-agent', - severity: 'critical', - category: 'unauthorized_action', - description: 'Agent attempted to launder a payment execution as a low-risk calendar action.', - evidenceRefs: ['evt_malicious_1'], - }) - - const reputation = computeCapabilityReputation({ - agentId: 'did:fides:malicious-agent', - publisherId: 'did:fides:fake-publisher', - capability: capability.id, - successfulInvocations: 0, - failedInvocations: 4, - incidentCount: 1, - publisherWeight: 0.1, - contextBoundaryMismatch: true, - }) - - const trust = computeTrustResult({ - agentId: 'did:fides:malicious-agent', - capability, - evidenceRefs: incident.evidence_refs, - components: { - identity: 0.2, - publisher: 0.1, - trustAnchors: 0, - capabilityFit: 0.4, - evidence: 0.1, - policyCompliance: 0, - runtimeSafety: 0, - peerAttestation: 0.1, - incidentPenalty: incident.trust_penalty, - noveltyPenalty: 0.4, - contextBoundaryPenalty: reputation.context_boundary_penalty, - }, - }) - - const preflight = evaluateInvocationPreflight({ - request: { - schema_version: 'fides.invocation.request.v1', - id: 'inv_req_malicious', - issuer: 'did:fides:requester', - subject: 'did:fides:malicious-agent', - session_id: 'missing-session', - requester_agent_id: 'did:fides:requester', - target_agent_id: 'did:fides:malicious-agent', - principal_id: 'did:fides:principal', - capability: capability.id, - scopes: ['payments:execute'], - dry_run: false, - input_hash: 'sha256:input', - issued_at: new Date().toISOString(), - payload_hash: 'sha256:payload', - }, - policyDecision: { - decision: 'deny', - reason_codes: ['REVOCATION_ACTIVE', 'TRUST_BELOW_THRESHOLD'], - }, - }) - - console.log(JSON.stringify({ - agent: 'did:fides:malicious-agent', - capability: capability.id, - incident, - reputation, - trust, - preflight, - }, null, 2)) -} - -main() +import './malicious-agent/index.js' diff --git a/examples/malicious-agent/README.md b/examples/malicious-agent/README.md new file mode 100644 index 0000000..c389ea8 --- /dev/null +++ b/examples/malicious-agent/README.md @@ -0,0 +1,17 @@ +# Malicious Agent + +Runnable FIDES v2 adversarial example agent. + +This example provides intentionally unsafe capability metadata used to exercise +trust penalties, policy denial, revocation handling, and evidence-backed +explainability in the adversarial simulation path. + +```bash +pnpm exec tsx examples/malicious-agent/index.ts +``` + +The legacy wrapper remains available: + +```bash +pnpm exec tsx examples/malicious-agent.ts +``` diff --git a/examples/malicious-agent/index.ts b/examples/malicious-agent/index.ts new file mode 100644 index 0000000..b7c9845 --- /dev/null +++ b/examples/malicious-agent/index.ts @@ -0,0 +1,98 @@ +/** + * Malicious Agent example. + * + * Demonstrates adversarial metadata that FIDES should discover only as a + * candidate, then penalize or deny through verification, trust, revocation, + * incident, and policy checks. + * + * Run: pnpm exec tsx examples/malicious-agent/index.ts + */ + +import { + createCapabilityDescriptor, + createIncidentRecordV2, + computeCapabilityReputation, + computeTrustResult, + evaluateInvocationPreflight, +} from '@fides/core' + +function main() { + const capability = createCapabilityDescriptor({ + id: 'payments.execute', + requiredScopes: ['payments:execute'], + supportedControls: ['human_approval', 'runtime_attestation', 'policy_proof'], + }) + + const incident = createIncidentRecordV2({ + reporter: 'did:fides:principal', + targetAgentId: 'did:fides:malicious-agent', + severity: 'critical', + category: 'unauthorized_action', + description: 'Agent attempted to launder a payment execution as a low-risk calendar action.', + evidenceRefs: ['evt_malicious_1'], + }) + + const reputation = computeCapabilityReputation({ + agentId: 'did:fides:malicious-agent', + publisherId: 'did:fides:fake-publisher', + capability: capability.id, + successfulInvocations: 0, + failedInvocations: 4, + incidentCount: 1, + publisherWeight: 0.1, + contextBoundaryMismatch: true, + }) + + const trust = computeTrustResult({ + agentId: 'did:fides:malicious-agent', + capability, + evidenceRefs: incident.evidence_refs, + components: { + identity: 0.2, + publisher: 0.1, + trustAnchors: 0, + capabilityFit: 0.4, + evidence: 0.1, + policyCompliance: 0, + runtimeSafety: 0, + peerAttestation: 0.1, + incidentPenalty: incident.trust_penalty, + noveltyPenalty: 0.4, + contextBoundaryPenalty: reputation.context_boundary_penalty, + }, + }) + + const preflight = evaluateInvocationPreflight({ + request: { + schema_version: 'fides.invocation.request.v1', + id: 'inv_req_malicious', + issuer: 'did:fides:requester', + subject: 'did:fides:malicious-agent', + session_id: 'missing-session', + requester_agent_id: 'did:fides:requester', + target_agent_id: 'did:fides:malicious-agent', + principal_id: 'did:fides:principal', + capability: capability.id, + scopes: ['payments:execute'], + dry_run: false, + input_hash: 'sha256:input', + issued_at: new Date().toISOString(), + payload_hash: 'sha256:payload', + }, + policyDecision: { + decision: 'deny', + reason_codes: ['REVOCATION_ACTIVE', 'TRUST_BELOW_THRESHOLD'], + }, + }) + + console.log(JSON.stringify({ + agent: 'did:fides:malicious-agent', + capability: capability.id, + incident, + reputation, + trust, + preflight, + }, null, 2)) +} + +main() diff --git a/examples/payment-agent.ts b/examples/payment-agent.ts index f1de833..d367e49 100644 --- a/examples/payment-agent.ts +++ b/examples/payment-agent.ts @@ -1,435 +1 @@ -/** - * Payment Agent — Processes payments - * - * Demonstrates: - * - Identity creation and AgentCard publishing - * - Critical risk capability classification - * - Guard decision engine with trust context - * - Kill switch engagement for fraud detection - * - Evidence recording for payment audit trail - * - * Run: npx tsx examples/payment-agent.ts - */ - -import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' -import type { AgentCard, CapabilityDescriptor } from '@fides/core' -import { classifyCapabilityRisk } from '@fides/core' -import { evaluatePolicy, type PolicyBundle } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' -import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' -import { evaluateGuard, createTrustContext } from '@fides/guard' -import { LocalDiscoveryProvider } from '@fides/discovery' - -async function main() { - console.log('═'.repeat(60)) - console.log(' Payment Agent — FIDES Example') - console.log('═'.repeat(60)) - console.log() - - // ─── Step 1: Create Identities ─────────────────────────────── - console.log('📝 Step 1: Creating Identities') - console.log('─'.repeat(40)) - - const { identity: paymentAgent, privateKey: paymentAgentPrivateKey } = await createAgentIdentity() - paymentAgent.metadata = { name: 'Payment Processor', version: '1.0.0' } - const { identity: merchant, privateKey: merchantPrivateKey } = await createPrincipalIdentity({ - type: 'organization', - displayName: 'ACME Corp', - }) - const { identity: customer } = await createPrincipalIdentity({ - type: 'individual', - displayName: 'Bob Customer', - }) - - console.log(` Payment Agent: ${paymentAgent.did}`) - console.log(` Merchant: ${merchant.did}`) - console.log(` Customer: ${customer.did}`) - console.log() - - // ─── Step 2: Create AgentCard ──────────────────────────────── - console.log('🃏 Step 2: Creating AgentCard') - console.log('─'.repeat(40)) - - const capabilities: CapabilityDescriptor[] = [ - { - id: 'payments.prepare', - name: 'Prepare Payment', - description: 'Prepare a payment plan without executing funds movement', - inputSchema: { type: 'object', properties: { amount: { type: 'number' }, currency: { type: 'string' }, customerId: { type: 'string' } }, required: ['amount', 'currency', 'customerId'] }, - outputSchema: { type: 'object', properties: { preparationId: { type: 'string' }, status: { type: 'string' } } }, - riskLevel: 'high', - requiresApproval: true, - requiresRuntimeAttestation: true, - }, - { - id: 'payments.execute', - name: 'Execute Payment', - description: 'Sardis-specific payment execution capability, modeled but not executed by generic FIDES', - inputSchema: { type: 'object', properties: { preparationId: { type: 'string' }, amount: { type: 'number' } }, required: ['preparationId'] }, - outputSchema: { type: 'object', properties: { executionId: { type: 'string' }, status: { type: 'string' } } }, - riskLevel: 'critical', - requiresApproval: true, - requiresRuntimeAttestation: true, - }, - ] - - const agentCard: AgentCard = { - id: paymentAgent.did, - identity: paymentAgent, - capabilities, - endpoints: [ - { - url: 'https://payment-agent.example.com/fides', - protocol: 'https', - capabilities: ['payments.prepare', 'payments.execute'], - auth: 'signature', - }, - ], - policies: [ - { requiresRuntimeAttestation: true, requiresApproval: true, minTrustScore: 0.8 }, - ], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - - const validation = validateAgentCard(agentCard) - console.log(` Name: ${agentCard.identity.metadata!.name}`) - console.log(` Capabilities: ${agentCard.capabilities.map(c => c.id).join(', ')}`) - console.log(` Valid: ${validation.valid}`) - if (!validation.valid) { - console.log(` Errors: ${validation.errors.join(', ')}`) - } - console.log() - - // ─── Step 3: Critical Risk Classification ──────────────────── - console.log('⚠️ Step 3: Critical Risk Classification') - console.log('─'.repeat(40)) - - for (const cap of agentCard.capabilities) { - const risk = classifyCapabilityRisk(cap.id) - const riskLabel = risk === 'critical' ? '🔴 CRITICAL' : risk === 'high' ? '🟠 HIGH' : risk === 'medium' ? '🟡 MEDIUM' : '🟢 LOW' - console.log(` ${cap.id}: ${risk} (declared: ${cap.riskLevel}) ${riskLabel}`) - } - console.log() - - // ─── Step 4: Delegation from Merchant ──────────────────────── - console.log('🔑 Step 4: Merchant Delegates Payment Access') - console.log('─'.repeat(40)) - - const merchantDelegation = await signDelegationToken(createDelegationToken({ - delegator: merchant.did, - delegatee: paymentAgent.did, - capabilities: ['payments.prepare'], - constraints: { - maxActions: 1000, - maxSpend: '100000.00', - allowedContexts: ['production'], - forbiddenContexts: ['test', 'staging'], - }, - expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), // 30 days - }), merchantPrivateKey) - - const delegationValid = validateDelegationToken(merchantDelegation) - console.log(` Token ID: ${merchantDelegation.id}`) - console.log(` Merchant → Agent: ${merchantDelegation.delegator} → ${merchantDelegation.delegatee}`) - console.log(` Max spend: $${merchantDelegation.constraints.maxSpend}`) - console.log(` Valid: ${delegationValid.valid}`) - console.log() - - // ─── Step 5: Register with Local Discovery ─────────────────── - console.log('🔍 Step 5: Registering with Local Discovery') - console.log('─'.repeat(40)) - - const localDiscovery = new LocalDiscoveryProvider() - const signedCard = await signAgentCard(agentCard, paymentAgentPrivateKey, paymentAgent.did) - await localDiscovery.register(signedCard) - const resolved = await localDiscovery.resolve(paymentAgent.did) - const discovered = await localDiscovery.discover({ - schema_version: 'fides.discovery_query.v1', - id: 'payment-local-query', - capability: 'payments.prepare', - }) - console.log(` Registered: ${resolved ? 'yes' : 'no'}`) - console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) - console.log(` Resolved: ${resolved?.identity.metadata!.name}`) - console.log() - - // ─── Step 6: Policy Evaluation ─────────────────────────────── - console.log('📋 Step 6: Policy Evaluation for Payment Processing') - console.log('─'.repeat(40)) - - const paymentPolicy = { - id: 'payment-policy', - version: '1.0.0', - rules: [ - { - id: 'high-trust-payment', - condition: { operator: 'gte', field: 'reputationScore', value: 0.9 }, - action: 'allow' as const, - explanation: 'High trust agent approved for payment operations', - }, - { - id: 'large-payment-approval', - condition: { operator: 'gt', field: 'paymentAmount', value: 10000 }, - action: 'approve-required' as const, - explanation: 'Payments over $10,000 require manual approval', - }, - { - id: 'fraud-block', - condition: { operator: 'gt', field: 'fraudScore', value: 0.7 }, - action: 'deny' as const, - explanation: 'High fraud score — payment blocked', - }, - { - id: 'velocity-check', - condition: { operator: 'gt', field: 'transactionsPerMinute', value: 10 }, - action: 'deny' as const, - explanation: 'Transaction velocity exceeded', - }, - { - id: 'medium-trust-dry-run', - condition: { operator: 'gte', field: 'reputationScore', value: 0.6 }, - action: 'dry-run' as const, - explanation: 'Medium trust — payment processed in dry-run mode', - }, - ], - defaultAction: 'deny' as const, - } satisfies PolicyBundle - - // Scenario 1: High trust, normal payment - const normalResult = evaluatePolicy(paymentPolicy, { - reputationScore: 0.95, - paymentAmount: 500, - fraudScore: 0.05, - transactionsPerMinute: 2, - }) - console.log(` High trust, $500 payment: ${normalResult.decision}`) - console.log(` Matched: ${normalResult.matchedRules.join(', ')}`) - - // Scenario 2: Large payment requiring approval - const largeResult = evaluatePolicy(paymentPolicy, { - reputationScore: 0.95, - paymentAmount: 25000, - fraudScore: 0.05, - transactionsPerMinute: 1, - }) - console.log(` High trust, $25,000 payment: ${largeResult.decision}`) - console.log(` Explanation: ${largeResult.explanation.decision}`) - - // Scenario 3: Fraud detected - const fraudResult = evaluatePolicy(paymentPolicy, { - reputationScore: 0.95, - paymentAmount: 500, - fraudScore: 0.85, - transactionsPerMinute: 2, - }) - console.log(` High trust, fraud score 0.85: ${fraudResult.decision}`) - console.log(` Explanation: ${fraudResult.explanation.decision}`) - - // Scenario 4: Velocity exceeded - const velocityResult = evaluatePolicy(paymentPolicy, { - reputationScore: 0.95, - paymentAmount: 100, - fraudScore: 0.05, - transactionsPerMinute: 15, - }) - console.log(` High trust, 15 tx/min: ${velocityResult.decision}`) - console.log(` Explanation: ${velocityResult.explanation.decision}`) - console.log() - - // ─── Step 7: Evidence Ledger (Payment Audit Trail) ────────── - console.log('📜 Step 7: Evidence Ledger — Payment Audit Trail') - console.log('─'.repeat(40)) - - let evidenceChain = createEvidenceChain() - - const paymentEvents = [ - { - id: 'pay-001', - type: 'delegation_created', - timestamp: new Date().toISOString(), - actor: merchant.did, - action: 'payments.prepare', - target: paymentAgent.did, - payload: { maxSpend: '100000.00', maxActions: 1000 }, - privacy: { level: 'hash_only' as const }, - }, - { - id: 'pay-002', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: paymentAgent.did, - action: 'payments.prepare', - target: 'TXN-001', - payload: { amount: 500, currency: 'USD', customerId: customer.did }, - privacy: { level: 'redacted' as const }, - }, - { - id: 'pay-003', - type: 'policy_eval', - timestamp: new Date().toISOString(), - actor: paymentAgent.did, - action: 'evaluate', - payload: { policy: 'payment-policy', decision: normalResult.decision, fraudScore: 0.05 }, - privacy: { level: 'public' as const }, - }, - { - id: 'pay-004', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: paymentAgent.did, - action: 'payments.prepare', - target: 'TXN-002', - payload: { amount: 25000, currency: 'USD', customerId: customer.did }, - privacy: { level: 'redacted' as const }, - }, - { - id: 'pay-005', - type: 'approval_required', - timestamp: new Date().toISOString(), - actor: paymentAgent.did, - action: 'payments.prepare', - target: 'TXN-002', - payload: { reason: 'Amount exceeds $10,000 threshold', escalatedTo: merchant.did }, - privacy: { level: 'public' as const }, - }, - { - id: 'pay-006', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: paymentAgent.did, - action: 'payments.execute', - target: 'blocked-generic-fides', - payload: { reason: 'Payment execution remains Sardis-specific in generic FIDES examples' }, - privacy: { level: 'public' as const }, - }, - ] - - for (const evt of paymentEvents) { - evidenceChain = appendEvidenceEvent(evidenceChain, evt, localEvidenceSignature(evt)) - console.log(` Recorded: ${evt.type} — ${evt.action} → ${evt.target}`) - } - - const chainValid = verifyEvidenceChain(evidenceChain) - const merkleRoot = buildMerkleRoot(evidenceChain.events.map(e => e.hash)) - console.log(` Chain valid: ${chainValid}`) - console.log(` Events: ${evidenceChain.events.length}`) - console.log(` Merkle root: ${merkleRoot.slice(0, 16)}...`) - console.log() - - // ─── Step 8: Kill Switch — Fraud Detection ─────────────────── - console.log('🛑 Step 8: Kill Switch — Fraud Detection') - console.log('─'.repeat(40)) - - const killSwitch = new InMemoryKillSwitch() - - console.log(` Initial state: engaged=${killSwitch.isEngaged({ type: 'agent', did: paymentAgent.did })}`) - - // Simulate fraud detection triggering kill switch - console.log(` ⚡ Fraud detected — engaging kill switch...`) - killSwitch.engage({ type: 'agent', did: paymentAgent.did }) - console.log(` After engagement: engaged=${killSwitch.isEngaged({ type: 'agent', did: paymentAgent.did })}`) - - // Verify global kill also works - killSwitch.engage({ type: 'global' }) - console.log(` Global kill engaged: ${killSwitch.isEngaged({ type: 'global' })}`) - console.log(` Agent still killed: ${killSwitch.isEngaged({ type: 'agent', did: paymentAgent.did })}`) - - // Disengage and verify recovery - killSwitch.disengage({ type: 'global' }) - killSwitch.disengage({ type: 'agent', did: paymentAgent.did }) - console.log(` After disengage: engaged=${killSwitch.isEngaged({ type: 'agent', did: paymentAgent.did })}`) - console.log() - - // ─── Step 9: Guard Decision Engine ─────────────────────────── - console.log('🛡️ Step 9: Guard Decision Engine') - console.log('─'.repeat(40)) - - const teeProvider = new MockTEEProvider() - const attestation = await teeProvider.attest(paymentAgent.did) - - // Scenario A: Good agent, normal payment - const goodTrust = createTrustContext({ - reputationScore: 0.95, - capabilityScore: 0.98, - attestation, - evidenceChain, - killSwitchEngaged: false, - recentIncidents: 0, - }) - - const goodDecision = await evaluateGuard({ - agentDid: paymentAgent.did, - capabilityId: 'payments.prepare', - policy: paymentPolicy, - context: { paymentAmount: 500, fraudScore: 0.05, transactionsPerMinute: 2 }, - trust: goodTrust, - }) - - console.log(` Scenario A: Good agent, $500 payment`) - console.log(` Decision: ${goodDecision.decision}`) - console.log(` Explanation: ${goodDecision.explanation}`) - console.log(` Factors: ${goodDecision.factors.length}`) - - // Scenario B: Kill switch engaged (fraud) - const killedTrust = createTrustContext({ - reputationScore: 0.95, - killSwitchEngaged: true, - recentIncidents: 0, - }) - - const killedDecision = await evaluateGuard({ - agentDid: paymentAgent.did, - capabilityId: 'payments.prepare', - policy: paymentPolicy, - context: { paymentAmount: 500 }, - trust: killedTrust, - }) - - console.log(` Scenario B: Kill switch engaged (fraud)`) - console.log(` Decision: ${killedDecision.decision}`) - console.log(` Explanation: ${killedDecision.explanation}`) - - // Scenario C: Low trust, many incidents - const badTrust = createTrustContext({ - reputationScore: 0.1, - killSwitchEngaged: false, - recentIncidents: 8, - }) - - const badDecision = await evaluateGuard({ - agentDid: paymentAgent.did, - capabilityId: 'payments.prepare', - policy: paymentPolicy, - context: { paymentAmount: 500 }, - trust: badTrust, - }) - - console.log(` Scenario C: Low trust (0.1), 8 incidents`) - console.log(` Decision: ${badDecision.decision}`) - console.log(` Explanation: ${badDecision.explanation}`) - console.log() - - // ─── Summary ───────────────────────────────────────────────── - console.log('═'.repeat(60)) - console.log(' Payment Agent Demo Complete') - console.log('═'.repeat(60)) - console.log() - console.log(' Demonstrated:') - console.log(' ✅ Identity creation (agent + merchant + customer)') - console.log(' ✅ AgentCard with payment capabilities') - console.log(' ✅ Critical risk classification (payments.execute remains Sardis-specific)') - console.log(' ✅ Delegation with spending constraints') - console.log(' ✅ Policy evaluation (allow / approve-required / deny / dry-run)') - console.log(' ✅ Fraud detection policies (fraud score, velocity)') - console.log(' ✅ Evidence chain for payment audit trail') - console.log(' ✅ Kill switch engagement and recovery') - console.log(' ✅ Guard decision engine (good / killed / bad trust)') - console.log() -} - -function localEvidenceSignature(event: unknown): string { - return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` -} - -main().catch(console.error) +import './payment-agent/index.js' diff --git a/examples/payment-agent/README.md b/examples/payment-agent/README.md new file mode 100644 index 0000000..462ede9 --- /dev/null +++ b/examples/payment-agent/README.md @@ -0,0 +1,19 @@ +# Payment Agent + +Runnable FIDES v2 example agent for `payments.prepare` and +`payments.execute`. + +Generic FIDES only models payment preparation and dry-run authority flows. +Payment execution remains Sardis-specific. This example demonstrates critical +risk classification, approval/attestation-aware policy, kill-switch behavior, +and evidence generation without turning discovery into permission. + +```bash +pnpm exec tsx examples/payment-agent/index.ts +``` + +The legacy wrapper remains available: + +```bash +pnpm exec tsx examples/payment-agent.ts +``` diff --git a/examples/payment-agent/index.ts b/examples/payment-agent/index.ts new file mode 100644 index 0000000..695c981 --- /dev/null +++ b/examples/payment-agent/index.ts @@ -0,0 +1,435 @@ +/** + * Payment Agent — Processes payments + * + * Demonstrates: + * - Identity creation and AgentCard publishing + * - Critical risk capability classification + * - Guard decision engine with trust context + * - Kill switch engagement for fraud detection + * - Evidence recording for payment audit trail + * + * Run: pnpm exec tsx examples/payment-agent/index.ts + */ + +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' +import type { AgentCard, CapabilityDescriptor } from '@fides/core' +import { classifyCapabilityRisk } from '@fides/core' +import { evaluatePolicy, type PolicyBundle } from '@fides/policy' +import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' +import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' +import { evaluateGuard, createTrustContext } from '@fides/guard' +import { LocalDiscoveryProvider } from '@fides/discovery' + +async function main() { + console.log('═'.repeat(60)) + console.log(' Payment Agent — FIDES Example') + console.log('═'.repeat(60)) + console.log() + + // ─── Step 1: Create Identities ─────────────────────────────── + console.log('📝 Step 1: Creating Identities') + console.log('─'.repeat(40)) + + const { identity: paymentAgent, privateKey: paymentAgentPrivateKey } = await createAgentIdentity() + paymentAgent.metadata = { name: 'Payment Processor', version: '1.0.0' } + const { identity: merchant, privateKey: merchantPrivateKey } = await createPrincipalIdentity({ + type: 'organization', + displayName: 'ACME Corp', + }) + const { identity: customer } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Bob Customer', + }) + + console.log(` Payment Agent: ${paymentAgent.did}`) + console.log(` Merchant: ${merchant.did}`) + console.log(` Customer: ${customer.did}`) + console.log() + + // ─── Step 2: Create AgentCard ──────────────────────────────── + console.log('🃏 Step 2: Creating AgentCard') + console.log('─'.repeat(40)) + + const capabilities: CapabilityDescriptor[] = [ + { + id: 'payments.prepare', + name: 'Prepare Payment', + description: 'Prepare a payment plan without executing funds movement', + inputSchema: { type: 'object', properties: { amount: { type: 'number' }, currency: { type: 'string' }, customerId: { type: 'string' } }, required: ['amount', 'currency', 'customerId'] }, + outputSchema: { type: 'object', properties: { preparationId: { type: 'string' }, status: { type: 'string' } } }, + riskLevel: 'high', + requiresApproval: true, + requiresRuntimeAttestation: true, + }, + { + id: 'payments.execute', + name: 'Execute Payment', + description: 'Sardis-specific payment execution capability, modeled but not executed by generic FIDES', + inputSchema: { type: 'object', properties: { preparationId: { type: 'string' }, amount: { type: 'number' } }, required: ['preparationId'] }, + outputSchema: { type: 'object', properties: { executionId: { type: 'string' }, status: { type: 'string' } } }, + riskLevel: 'critical', + requiresApproval: true, + requiresRuntimeAttestation: true, + }, + ] + + const agentCard: AgentCard = { + id: paymentAgent.did, + identity: paymentAgent, + capabilities, + endpoints: [ + { + url: 'https://payment-agent.example.com/fides', + protocol: 'https', + capabilities: ['payments.prepare', 'payments.execute'], + auth: 'signature', + }, + ], + policies: [ + { requiresRuntimeAttestation: true, requiresApproval: true, minTrustScore: 0.8 }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + const validation = validateAgentCard(agentCard) + console.log(` Name: ${agentCard.identity.metadata!.name}`) + console.log(` Capabilities: ${agentCard.capabilities.map(c => c.id).join(', ')}`) + console.log(` Valid: ${validation.valid}`) + if (!validation.valid) { + console.log(` Errors: ${validation.errors.join(', ')}`) + } + console.log() + + // ─── Step 3: Critical Risk Classification ──────────────────── + console.log('⚠️ Step 3: Critical Risk Classification') + console.log('─'.repeat(40)) + + for (const cap of agentCard.capabilities) { + const risk = classifyCapabilityRisk(cap.id) + const riskLabel = risk === 'critical' ? '🔴 CRITICAL' : risk === 'high' ? '🟠 HIGH' : risk === 'medium' ? '🟡 MEDIUM' : '🟢 LOW' + console.log(` ${cap.id}: ${risk} (declared: ${cap.riskLevel}) ${riskLabel}`) + } + console.log() + + // ─── Step 4: Delegation from Merchant ──────────────────────── + console.log('🔑 Step 4: Merchant Delegates Payment Access') + console.log('─'.repeat(40)) + + const merchantDelegation = await signDelegationToken(createDelegationToken({ + delegator: merchant.did, + delegatee: paymentAgent.did, + capabilities: ['payments.prepare'], + constraints: { + maxActions: 1000, + maxSpend: '100000.00', + allowedContexts: ['production'], + forbiddenContexts: ['test', 'staging'], + }, + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), // 30 days + }), merchantPrivateKey) + + const delegationValid = validateDelegationToken(merchantDelegation) + console.log(` Token ID: ${merchantDelegation.id}`) + console.log(` Merchant → Agent: ${merchantDelegation.delegator} → ${merchantDelegation.delegatee}`) + console.log(` Max spend: $${merchantDelegation.constraints.maxSpend}`) + console.log(` Valid: ${delegationValid.valid}`) + console.log() + + // ─── Step 5: Register with Local Discovery ─────────────────── + console.log('🔍 Step 5: Registering with Local Discovery') + console.log('─'.repeat(40)) + + const localDiscovery = new LocalDiscoveryProvider() + const signedCard = await signAgentCard(agentCard, paymentAgentPrivateKey, paymentAgent.did) + await localDiscovery.register(signedCard) + const resolved = await localDiscovery.resolve(paymentAgent.did) + const discovered = await localDiscovery.discover({ + schema_version: 'fides.discovery_query.v1', + id: 'payment-local-query', + capability: 'payments.prepare', + }) + console.log(` Registered: ${resolved ? 'yes' : 'no'}`) + console.log(` Verified candidate: ${discovered[0]?.verified ? 'yes' : 'no'}`) + console.log(` Resolved: ${resolved?.identity.metadata!.name}`) + console.log() + + // ─── Step 6: Policy Evaluation ─────────────────────────────── + console.log('📋 Step 6: Policy Evaluation for Payment Processing') + console.log('─'.repeat(40)) + + const paymentPolicy = { + id: 'payment-policy', + version: '1.0.0', + rules: [ + { + id: 'high-trust-payment', + condition: { operator: 'gte', field: 'reputationScore', value: 0.9 }, + action: 'allow' as const, + explanation: 'High trust agent approved for payment operations', + }, + { + id: 'large-payment-approval', + condition: { operator: 'gt', field: 'paymentAmount', value: 10000 }, + action: 'approve-required' as const, + explanation: 'Payments over $10,000 require manual approval', + }, + { + id: 'fraud-block', + condition: { operator: 'gt', field: 'fraudScore', value: 0.7 }, + action: 'deny' as const, + explanation: 'High fraud score — payment blocked', + }, + { + id: 'velocity-check', + condition: { operator: 'gt', field: 'transactionsPerMinute', value: 10 }, + action: 'deny' as const, + explanation: 'Transaction velocity exceeded', + }, + { + id: 'medium-trust-dry-run', + condition: { operator: 'gte', field: 'reputationScore', value: 0.6 }, + action: 'dry-run' as const, + explanation: 'Medium trust — payment processed in dry-run mode', + }, + ], + defaultAction: 'deny' as const, + } satisfies PolicyBundle + + // Scenario 1: High trust, normal payment + const normalResult = evaluatePolicy(paymentPolicy, { + reputationScore: 0.95, + paymentAmount: 500, + fraudScore: 0.05, + transactionsPerMinute: 2, + }) + console.log(` High trust, $500 payment: ${normalResult.decision}`) + console.log(` Matched: ${normalResult.matchedRules.join(', ')}`) + + // Scenario 2: Large payment requiring approval + const largeResult = evaluatePolicy(paymentPolicy, { + reputationScore: 0.95, + paymentAmount: 25000, + fraudScore: 0.05, + transactionsPerMinute: 1, + }) + console.log(` High trust, $25,000 payment: ${largeResult.decision}`) + console.log(` Explanation: ${largeResult.explanation.decision}`) + + // Scenario 3: Fraud detected + const fraudResult = evaluatePolicy(paymentPolicy, { + reputationScore: 0.95, + paymentAmount: 500, + fraudScore: 0.85, + transactionsPerMinute: 2, + }) + console.log(` High trust, fraud score 0.85: ${fraudResult.decision}`) + console.log(` Explanation: ${fraudResult.explanation.decision}`) + + // Scenario 4: Velocity exceeded + const velocityResult = evaluatePolicy(paymentPolicy, { + reputationScore: 0.95, + paymentAmount: 100, + fraudScore: 0.05, + transactionsPerMinute: 15, + }) + console.log(` High trust, 15 tx/min: ${velocityResult.decision}`) + console.log(` Explanation: ${velocityResult.explanation.decision}`) + console.log() + + // ─── Step 7: Evidence Ledger (Payment Audit Trail) ────────── + console.log('📜 Step 7: Evidence Ledger — Payment Audit Trail') + console.log('─'.repeat(40)) + + let evidenceChain = createEvidenceChain() + + const paymentEvents = [ + { + id: 'pay-001', + type: 'delegation_created', + timestamp: new Date().toISOString(), + actor: merchant.did, + action: 'payments.prepare', + target: paymentAgent.did, + payload: { maxSpend: '100000.00', maxActions: 1000 }, + privacy: { level: 'hash_only' as const }, + }, + { + id: 'pay-002', + type: 'capability_invoke', + timestamp: new Date().toISOString(), + actor: paymentAgent.did, + action: 'payments.prepare', + target: 'TXN-001', + payload: { amount: 500, currency: 'USD', customerId: customer.did }, + privacy: { level: 'redacted' as const }, + }, + { + id: 'pay-003', + type: 'policy_eval', + timestamp: new Date().toISOString(), + actor: paymentAgent.did, + action: 'evaluate', + payload: { policy: 'payment-policy', decision: normalResult.decision, fraudScore: 0.05 }, + privacy: { level: 'public' as const }, + }, + { + id: 'pay-004', + type: 'capability_invoke', + timestamp: new Date().toISOString(), + actor: paymentAgent.did, + action: 'payments.prepare', + target: 'TXN-002', + payload: { amount: 25000, currency: 'USD', customerId: customer.did }, + privacy: { level: 'redacted' as const }, + }, + { + id: 'pay-005', + type: 'approval_required', + timestamp: new Date().toISOString(), + actor: paymentAgent.did, + action: 'payments.prepare', + target: 'TXN-002', + payload: { reason: 'Amount exceeds $10,000 threshold', escalatedTo: merchant.did }, + privacy: { level: 'public' as const }, + }, + { + id: 'pay-006', + type: 'capability_invoke', + timestamp: new Date().toISOString(), + actor: paymentAgent.did, + action: 'payments.execute', + target: 'blocked-generic-fides', + payload: { reason: 'Payment execution remains Sardis-specific in generic FIDES examples' }, + privacy: { level: 'public' as const }, + }, + ] + + for (const evt of paymentEvents) { + evidenceChain = appendEvidenceEvent(evidenceChain, evt, localEvidenceSignature(evt)) + console.log(` Recorded: ${evt.type} — ${evt.action} → ${evt.target}`) + } + + const chainValid = verifyEvidenceChain(evidenceChain) + const merkleRoot = buildMerkleRoot(evidenceChain.events.map(e => e.hash)) + console.log(` Chain valid: ${chainValid}`) + console.log(` Events: ${evidenceChain.events.length}`) + console.log(` Merkle root: ${merkleRoot.slice(0, 16)}...`) + console.log() + + // ─── Step 8: Kill Switch — Fraud Detection ─────────────────── + console.log('🛑 Step 8: Kill Switch — Fraud Detection') + console.log('─'.repeat(40)) + + const killSwitch = new InMemoryKillSwitch() + + console.log(` Initial state: engaged=${killSwitch.isEngaged({ type: 'agent', did: paymentAgent.did })}`) + + // Simulate fraud detection triggering kill switch + console.log(` ⚡ Fraud detected — engaging kill switch...`) + killSwitch.engage({ type: 'agent', did: paymentAgent.did }) + console.log(` After engagement: engaged=${killSwitch.isEngaged({ type: 'agent', did: paymentAgent.did })}`) + + // Verify global kill also works + killSwitch.engage({ type: 'global' }) + console.log(` Global kill engaged: ${killSwitch.isEngaged({ type: 'global' })}`) + console.log(` Agent still killed: ${killSwitch.isEngaged({ type: 'agent', did: paymentAgent.did })}`) + + // Disengage and verify recovery + killSwitch.disengage({ type: 'global' }) + killSwitch.disengage({ type: 'agent', did: paymentAgent.did }) + console.log(` After disengage: engaged=${killSwitch.isEngaged({ type: 'agent', did: paymentAgent.did })}`) + console.log() + + // ─── Step 9: Guard Decision Engine ─────────────────────────── + console.log('🛡️ Step 9: Guard Decision Engine') + console.log('─'.repeat(40)) + + const teeProvider = new MockTEEProvider() + const attestation = await teeProvider.attest(paymentAgent.did) + + // Scenario A: Good agent, normal payment + const goodTrust = createTrustContext({ + reputationScore: 0.95, + capabilityScore: 0.98, + attestation, + evidenceChain, + killSwitchEngaged: false, + recentIncidents: 0, + }) + + const goodDecision = await evaluateGuard({ + agentDid: paymentAgent.did, + capabilityId: 'payments.prepare', + policy: paymentPolicy, + context: { paymentAmount: 500, fraudScore: 0.05, transactionsPerMinute: 2 }, + trust: goodTrust, + }) + + console.log(` Scenario A: Good agent, $500 payment`) + console.log(` Decision: ${goodDecision.decision}`) + console.log(` Explanation: ${goodDecision.explanation}`) + console.log(` Factors: ${goodDecision.factors.length}`) + + // Scenario B: Kill switch engaged (fraud) + const killedTrust = createTrustContext({ + reputationScore: 0.95, + killSwitchEngaged: true, + recentIncidents: 0, + }) + + const killedDecision = await evaluateGuard({ + agentDid: paymentAgent.did, + capabilityId: 'payments.prepare', + policy: paymentPolicy, + context: { paymentAmount: 500 }, + trust: killedTrust, + }) + + console.log(` Scenario B: Kill switch engaged (fraud)`) + console.log(` Decision: ${killedDecision.decision}`) + console.log(` Explanation: ${killedDecision.explanation}`) + + // Scenario C: Low trust, many incidents + const badTrust = createTrustContext({ + reputationScore: 0.1, + killSwitchEngaged: false, + recentIncidents: 8, + }) + + const badDecision = await evaluateGuard({ + agentDid: paymentAgent.did, + capabilityId: 'payments.prepare', + policy: paymentPolicy, + context: { paymentAmount: 500 }, + trust: badTrust, + }) + + console.log(` Scenario C: Low trust (0.1), 8 incidents`) + console.log(` Decision: ${badDecision.decision}`) + console.log(` Explanation: ${badDecision.explanation}`) + console.log() + + // ─── Summary ───────────────────────────────────────────────── + console.log('═'.repeat(60)) + console.log(' Payment Agent Demo Complete') + console.log('═'.repeat(60)) + console.log() + console.log(' Demonstrated:') + console.log(' ✅ Identity creation (agent + merchant + customer)') + console.log(' ✅ AgentCard with payment capabilities') + console.log(' ✅ Critical risk classification (payments.execute remains Sardis-specific)') + console.log(' ✅ Delegation with spending constraints') + console.log(' ✅ Policy evaluation (allow / approve-required / deny / dry-run)') + console.log(' ✅ Fraud detection policies (fraud score, velocity)') + console.log(' ✅ Evidence chain for payment audit trail') + console.log(' ✅ Kill switch engagement and recovery') + console.log(' ✅ Guard decision engine (good / killed / bad trust)') + console.log() +} + +function localEvidenceSignature(event: unknown): string { + return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +} + +main().catch(console.error) diff --git a/examples/requester-agent.ts b/examples/requester-agent.ts index 5638ced..01d6e9b 100644 --- a/examples/requester-agent.ts +++ b/examples/requester-agent.ts @@ -1,499 +1 @@ -/** - * Requester Agent — Discovers and invokes other agents - * - * Demonstrates: - * - Identity creation for a requester agent - * - Discovery using LocalDiscoveryProvider - * - Trust score checking before invoking capabilities - * - Full flow: discover → check trust → evaluate guard → invoke - * - Evidence recording for the complete interaction - * - * Run: npx tsx examples/requester-agent.ts - */ - -import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' -import type { AgentCard, CapabilityDescriptor } from '@fides/core' -import { classifyCapabilityRisk } from '@fides/core' -import { evaluatePolicy, type PolicyBundle } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' -import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' -import { evaluateGuard, createTrustContext } from '@fides/guard' -import { LocalDiscoveryProvider } from '@fides/discovery' - -async function main() { - console.log('═'.repeat(60)) - console.log(' Requester Agent — FIDES Example') - console.log('═'.repeat(60)) - console.log() - - // ─── Step 1: Create Identities ─────────────────────────────── - console.log('📝 Step 1: Creating Identities') - console.log('─'.repeat(40)) - - const { identity: requesterAgent } = await createAgentIdentity() - requesterAgent.metadata = { name: 'Task Orchestrator', version: '1.0.0' } - const { identity: user, privateKey: userPrivateKey } = await createPrincipalIdentity({ - type: 'individual', - displayName: 'Alice', - }) - - console.log(` Requester: ${requesterAgent.did}`) - console.log(` User: ${user.did}`) - console.log() - - // ─── Step 2: Create Service Provider AgentCards ────────────── - console.log('🃏 Step 2: Creating Service Provider AgentCards') - console.log('─'.repeat(40)) - - // Calendar service provider - const { identity: calendarAgent, privateKey: calendarAgentPrivateKey } = await createAgentIdentity() - calendarAgent.metadata = { name: 'Calendar Service' } - const calendarCapabilities: CapabilityDescriptor[] = [ - { - id: 'calendar.schedule', - name: 'Schedule Event', - description: 'Schedule a calendar event', - inputSchema: { type: 'object', properties: { title: { type: 'string' }, date: { type: 'string' } }, required: ['title', 'date'] }, - outputSchema: { type: 'object', properties: { eventId: { type: 'string' } } }, - riskLevel: 'low', - requiresApproval: false, - requiresRuntimeAttestation: false, - }, - { - id: 'calendar.read', - name: 'List Events', - description: 'List calendar events', - inputSchema: { type: 'object', properties: { start: { type: 'string' }, end: { type: 'string' } } }, - outputSchema: { type: 'array', items: { type: 'object' } }, - riskLevel: 'low', - requiresApproval: false, - requiresRuntimeAttestation: false, - }, - ] - const calendarCard: AgentCard = { - id: calendarAgent.did, - identity: calendarAgent, - capabilities: calendarCapabilities, - endpoints: [ - { url: 'https://calendar.example.com/fides', protocol: 'https', capabilities: ['calendar.schedule', 'calendar.read'], auth: 'signature' }, - ], - policies: [{ requiresRuntimeAttestation: false, requiresApproval: false, minTrustScore: 0.5 }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - - // Payment service provider - const { identity: paymentAgent, privateKey: paymentAgentPrivateKey } = await createAgentIdentity() - paymentAgent.metadata = { name: 'Payment Service' } - const paymentCapabilities: CapabilityDescriptor[] = [ - { - id: 'payments.prepare', - name: 'Prepare Payment', - description: 'Prepare a payment plan without executing funds movement', - inputSchema: { type: 'object', properties: { amount: { type: 'number' }, currency: { type: 'string' } }, required: ['amount', 'currency'] }, - outputSchema: { type: 'object', properties: { preparationId: { type: 'string' } } }, - riskLevel: 'high', - requiresApproval: true, - requiresRuntimeAttestation: true, - }, - ] - const paymentCard: AgentCard = { - id: paymentAgent.did, - identity: paymentAgent, - capabilities: paymentCapabilities, - endpoints: [ - { url: 'https://payment.example.com/fides', protocol: 'https', capabilities: ['payments.prepare'], auth: 'signature' }, - ], - policies: [{ requiresRuntimeAttestation: true, requiresApproval: true, minTrustScore: 0.8 }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - - // Invoice service provider - const { identity: invoiceAgent, privateKey: invoiceAgentPrivateKey } = await createAgentIdentity() - invoiceAgent.metadata = { name: 'Invoice Service' } - const invoiceCapabilities: CapabilityDescriptor[] = [ - { - id: 'invoice.reconcile', - name: 'Reconcile Invoice', - description: 'Reconcile an invoice', - inputSchema: { type: 'object', properties: { orderId: { type: 'string' }, amount: { type: 'number' } }, required: ['orderId', 'amount'] }, - outputSchema: { type: 'object', properties: { invoiceId: { type: 'string' } } }, - riskLevel: 'medium', - requiresApproval: false, - requiresRuntimeAttestation: true, - }, - { - id: 'invoice.read', - name: 'List Invoices', - description: 'List invoices', - inputSchema: { type: 'object', properties: { status: { type: 'string' } } }, - outputSchema: { type: 'array', items: { type: 'object' } }, - riskLevel: 'low', - requiresApproval: false, - requiresRuntimeAttestation: false, - }, - ] - const invoiceCard: AgentCard = { - id: invoiceAgent.did, - identity: invoiceAgent, - capabilities: invoiceCapabilities, - endpoints: [ - { url: 'https://invoice.example.com/fides', protocol: 'https', capabilities: ['invoice.reconcile', 'invoice.read'], auth: 'signature' }, - ], - policies: [{ requiresRuntimeAttestation: true, requiresApproval: false, minTrustScore: 0.7 }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - - console.log(` Calendar: ${calendarCard.identity.metadata!.name} (${calendarCard.capabilities.length} caps)`) - console.log(` Payment: ${paymentCard.identity.metadata!.name} (${paymentCard.capabilities.length} caps)`) - console.log(` Invoice: ${invoiceCard.identity.metadata!.name} (${invoiceCard.capabilities.length} caps)`) - console.log() - - // ─── Step 3: Register All Providers with Local Discovery ───── - console.log('🔍 Step 3: Registering with Local Discovery') - console.log('─'.repeat(40)) - - const localDiscovery = new LocalDiscoveryProvider() - await localDiscovery.register(await signAgentCard(calendarCard, calendarAgentPrivateKey, calendarAgent.did)) - await localDiscovery.register(await signAgentCard(paymentCard, paymentAgentPrivateKey, paymentAgent.did)) - await localDiscovery.register(await signAgentCard(invoiceCard, invoiceAgentPrivateKey, invoiceAgent.did)) - - console.log(` Registered 3 service providers`) - - // Discover each provider - const discoveredCalendar = await localDiscovery.resolve(calendarAgent.did) - const discoveredPayment = await localDiscovery.resolve(paymentAgent.did) - const discoveredInvoice = await localDiscovery.resolve(invoiceAgent.did) - - console.log(` Discovered calendar: ${discoveredCalendar ? discoveredCalendar.identity.metadata!.name : 'NOT FOUND'}`) - console.log(` Discovered payment: ${discoveredPayment ? discoveredPayment.identity.metadata!.name : 'NOT FOUND'}`) - console.log(` Discovered invoice: ${discoveredInvoice ? discoveredInvoice.identity.metadata!.name : 'NOT FOUND'}`) - - // List all available agents - const allAgents = localDiscovery.list() - const verifiedCalendarCandidates = await localDiscovery.discover({ - schema_version: 'fides.discovery_query.v1', - id: 'requester-calendar-query', - capability: 'calendar.schedule', - }) - console.log(` Total agents in discovery: ${allAgents.length}`) - console.log(` Verified calendar candidates: ${verifiedCalendarCandidates.filter(candidate => candidate.verified).length}`) - console.log() - - // ─── Step 4: User Delegates to Requester ───────────────────── - console.log('🔑 Step 4: User Delegates to Requester Agent') - console.log('─'.repeat(40)) - - const userDelegation = await signDelegationToken(createDelegationToken({ - delegator: user.did, - delegatee: requesterAgent.did, - capabilities: ['calendar.schedule', 'payments.prepare', 'invoice.reconcile'], - constraints: { - maxActions: 20, - maxSpend: '5000.00', - allowedContexts: ['work'], - }, - expiresAt: new Date(Date.now() + 86400000).toISOString(), - }), userPrivateKey) - - const delegationValid = validateDelegationToken(userDelegation) - console.log(` Token ID: ${userDelegation.id}`) - console.log(` User → Requester: ${userDelegation.delegator} → ${userDelegation.delegatee}`) - console.log(` Capabilities: ${userDelegation.capabilities.join(', ')}`) - console.log(` Max spend: $${userDelegation.constraints.maxSpend}`) - console.log(` Valid: ${delegationValid.valid}`) - console.log() - - // ─── Step 5: Trust Score Checking ──────────────────────────── - console.log('📊 Step 5: Trust Score Checking Before Invocation') - console.log('─'.repeat(40)) - - // Simulate trust scores for each provider - const trustScores: Record = { - [calendarAgent.did]: 0.85, - [paymentAgent.did]: 0.95, - [invoiceAgent.did]: 0.70, - } - - for (const [did, score] of Object.entries(trustScores)) { - const card = await localDiscovery.resolve(did) - const minRequired = card?.policies[0]?.minTrustScore ?? 0 - const meetsThreshold = score >= minRequired - const status = meetsThreshold ? '✅ PASS' : '❌ FAIL' - console.log(` ${card?.identity.metadata!.name}: score=${score.toFixed(2)}, required=${minRequired.toFixed(2)} ${status}`) - } - console.log() - - // ─── Step 6: Full Flow — Discover → Trust → Guard → Invoke ── - console.log('🔄 Step 6: Full Flow — Discover → Trust → Guard → Invoke') - console.log('─'.repeat(40)) - - // Shared policy for all invocations - const requesterPolicy = { - id: 'requester-policy', - version: '1.0.0', - rules: [ - { - id: 'high-trust', - condition: { operator: 'gte', field: 'reputationScore', value: 0.8 }, - action: 'allow' as const, - explanation: 'High trust service provider', - }, - { - id: 'medium-trust', - condition: { operator: 'gte', field: 'reputationScore', value: 0.5 }, - action: 'dry-run' as const, - explanation: 'Medium trust — dry-run mode', - }, - { - id: 'rate-limit', - condition: { operator: 'gt', field: 'requestCount', value: 50 }, - action: 'deny' as const, - explanation: 'Rate limit exceeded', - }, - ], - defaultAction: 'deny' as const, - } satisfies PolicyBundle - - const evidenceChain = createEvidenceChain() - const teeProvider = new MockTEEProvider() - const requesterAttestation = await teeProvider.attest(requesterAgent.did) - - // Flow 1: Invoke calendar service (medium risk, high trust) - console.log(` ┌─ Flow 1: Create Calendar Event`) - console.log(` │`) - - // 1a. Discover - const calendarProvider = await localDiscovery.resolve(calendarAgent.did) - console.log(` │ 1a. Discovered: ${calendarProvider?.identity.metadata!.name}`) - - // 1b. Check trust - const calendarTrustScore = trustScores[calendarAgent.did] ?? 0 - const calendarMeetsTrust = calendarTrustScore >= (calendarProvider?.policies[0]?.minTrustScore ?? 0) - console.log(` │ 1b. Trust score: ${calendarTrustScore.toFixed(2)} (meets threshold: ${calendarMeetsTrust})`) - - // 1c. Evaluate guard - const calendarTrust = createTrustContext({ - reputationScore: calendarTrustScore, - capabilityScore: 0.9, - attestation: requesterAttestation, - evidenceChain, - killSwitchEngaged: false, - recentIncidents: 0, - }) - - const calendarGuard = await evaluateGuard({ - agentDid: calendarAgent.did, - capabilityId: 'calendar.schedule', - policy: requesterPolicy, - context: { requestCount: 5, reputationScore: calendarTrustScore }, - trust: calendarTrust, - }) - console.log(` │ 1c. Guard decision: ${calendarGuard.decision}`) - - if (calendarGuard.decision === 'allow') { - console.log(` │ 1d. ✅ Invoked calendar.schedule successfully`) - - // Record evidence - const calendarEvent = { - id: 'req-001', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: requesterAgent.did, - action: 'calendar.schedule', - target: calendarAgent.did, - payload: { title: 'Team Meeting', date: '2026-05-06T10:00:00Z' }, - privacy: { level: 'redacted' as const }, - } - appendIntoEvidenceChain(evidenceChain, calendarEvent) - } else { - console.log(` │ 1d. ❌ Invocation blocked: ${calendarGuard.explanation}`) - } - console.log(` │`) - - // Flow 2: Invoke payment service (high risk, high trust) - console.log(` ┌─ Flow 2: Prepare Payment`) - console.log(` │`) - - const paymentProvider = await localDiscovery.resolve(paymentAgent.did) - console.log(` │ 2a. Discovered: ${paymentProvider?.identity.metadata!.name}`) - - const paymentTrustScore = trustScores[paymentAgent.did] ?? 0 - const paymentMeetsTrust = paymentTrustScore >= (paymentProvider?.policies[0]?.minTrustScore ?? 0) - console.log(` │ 2b. Trust score: ${paymentTrustScore.toFixed(2)} (meets threshold: ${paymentMeetsTrust})`) - - const paymentTrust = createTrustContext({ - reputationScore: paymentTrustScore, - capabilityScore: 0.98, - attestation: requesterAttestation, - evidenceChain, - killSwitchEngaged: false, - recentIncidents: 0, - }) - - const paymentGuard = await evaluateGuard({ - agentDid: paymentAgent.did, - capabilityId: 'payments.prepare', - policy: requesterPolicy, - context: { requestCount: 5, reputationScore: paymentTrustScore }, - trust: paymentTrust, - }) - console.log(` │ 2c. Guard decision: ${paymentGuard.decision}`) - - if (paymentGuard.decision === 'allow') { - console.log(` │ 2d. ✅ Invoked payments.prepare successfully`) - const paymentEvent = { - id: 'req-002', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: requesterAgent.did, - action: 'payments.prepare', - target: paymentAgent.did, - payload: { amount: 150, currency: 'USD' }, - privacy: { level: 'redacted' as const }, - } - appendIntoEvidenceChain(evidenceChain, paymentEvent) - } else { - console.log(` │ 2d. ❌ Invocation blocked: ${paymentGuard.explanation}`) - } - console.log(` │`) - - // Flow 3: Invoke invoice service (medium risk, medium trust) - console.log(` ┌─ Flow 3: Reconcile Invoice`) - console.log(` │`) - - const invoiceProvider = await localDiscovery.resolve(invoiceAgent.did) - console.log(` │ 3a. Discovered: ${invoiceProvider?.identity.metadata!.name}`) - - const invoiceTrustScore = trustScores[invoiceAgent.did] ?? 0 - const invoiceMeetsTrust = invoiceTrustScore >= (invoiceProvider?.policies[0]?.minTrustScore ?? 0) - console.log(` │ 3b. Trust score: ${invoiceTrustScore.toFixed(2)} (meets threshold: ${invoiceMeetsTrust})`) - - const invoiceTrust = createTrustContext({ - reputationScore: invoiceTrustScore, - capabilityScore: 0.75, - attestation: requesterAttestation, - evidenceChain, - killSwitchEngaged: false, - recentIncidents: 0, - }) - - const invoiceGuard = await evaluateGuard({ - agentDid: invoiceAgent.did, - capabilityId: 'invoice.reconcile', - policy: requesterPolicy, - context: { requestCount: 5, reputationScore: invoiceTrustScore }, - trust: invoiceTrust, - }) - console.log(` │ 3c. Guard decision: ${invoiceGuard.decision}`) - - if (invoiceGuard.decision === 'allow') { - console.log(` │ 3d. ✅ Invoked invoice.reconcile successfully`) - } else if (invoiceGuard.decision === 'dry-run') { - console.log(` │ 3d. ⚠️ Dry-run mode (trust score below optimal)`) - } else { - console.log(` │ 3d. ❌ Invocation blocked: ${invoiceGuard.explanation}`) - } - console.log(` │`) - - // ─── Step 7: Evidence Chain ────────────────────────────────── - console.log('📜 Step 7: Evidence Chain for Requester Flow') - console.log('─'.repeat(40)) - - // Add policy evaluation events - const policyEvents = [ - { - id: 'req-policy-001', - type: 'policy_eval', - timestamp: new Date().toISOString(), - actor: requesterAgent.did, - action: 'evaluate', - target: calendarAgent.did, - payload: { capability: 'calendar.schedule', decision: calendarGuard.decision }, - privacy: { level: 'public' as const }, - }, - { - id: 'req-policy-002', - type: 'policy_eval', - timestamp: new Date().toISOString(), - actor: requesterAgent.did, - action: 'evaluate', - target: paymentAgent.did, - payload: { capability: 'payments.prepare', decision: paymentGuard.decision }, - privacy: { level: 'public' as const }, - }, - { - id: 'req-policy-003', - type: 'policy_eval', - timestamp: new Date().toISOString(), - actor: requesterAgent.did, - action: 'evaluate', - target: invoiceAgent.did, - payload: { capability: 'invoice.reconcile', decision: invoiceGuard.decision }, - privacy: { level: 'public' as const }, - }, - ] - - let fullChain = createEvidenceChain() - for (const evt of [...evidenceChain.events, ...policyEvents]) { - fullChain = appendEvidenceEvent(fullChain, evt, localEvidenceSignature(evt)) - console.log(` Recorded: ${evt.type} — ${evt.action} → ${evt.target}`) - } - - const chainValid = verifyEvidenceChain(fullChain) - const merkleRoot = buildMerkleRoot(fullChain.events.map(e => e.hash)) - console.log(` Chain valid: ${chainValid}`) - console.log(` Events: ${fullChain.events.length}`) - console.log(` Merkle root: ${merkleRoot.slice(0, 16)}...`) - console.log() - - // ─── Step 8: Capability Risk Summary ───────────────────────── - console.log('⚠️ Step 8: Capability Risk Summary') - console.log('─'.repeat(40)) - - const allCapabilities = [ - ...calendarCapabilities, - ...paymentCapabilities, - ...invoiceCapabilities, - ] - - for (const cap of allCapabilities) { - const risk = classifyCapabilityRisk(cap.id) - const riskLabel = risk === 'critical' ? '🔴' : risk === 'high' ? '🟠' : risk === 'medium' ? '🟡' : '🟢' - console.log(` ${riskLabel} ${cap.id}: ${risk} (declared: ${cap.riskLevel})`) - } - console.log() - - // ─── Summary ───────────────────────────────────────────────── - console.log('═'.repeat(60)) - console.log(' Requester Agent Demo Complete') - console.log('═'.repeat(60)) - console.log() - console.log(' Demonstrated:') - console.log(' ✅ Identity creation for requester agent') - console.log(' ✅ Multiple service provider AgentCards') - console.log(' ✅ Local discovery registration and resolution') - console.log(' ✅ User delegation to requester') - console.log(' ✅ Trust score checking against provider thresholds') - console.log(' ✅ Full flow: discover → trust → guard → invoke') - console.log(' ✅ Evidence chain for multi-agent interaction') - console.log(' ✅ Capability risk classification across providers') - console.log() -} - -function appendIntoEvidenceChain( - chain: ReturnType, - event: Parameters[1] -): void { - const next = appendEvidenceEvent(chain, event, localEvidenceSignature(event)) - chain.events = next.events - chain.merkleRoot = next.merkleRoot -} - -function localEvidenceSignature(event: unknown): string { - return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` -} - -main().catch(console.error) +import './requester-agent/index.js' diff --git a/examples/requester-agent/README.md b/examples/requester-agent/README.md new file mode 100644 index 0000000..a818c36 --- /dev/null +++ b/examples/requester-agent/README.md @@ -0,0 +1,18 @@ +# Requester Agent + +Runnable FIDES v2 requester example. + +This example discovers candidate agents, checks trust and policy, requests +scoped authority, and records evidence for multi-agent interaction. It is the +ARP-like resolution step upgraded with identity, trust, policy, and evidence, +not a naive directory lookup. + +```bash +pnpm exec tsx examples/requester-agent/index.ts +``` + +The legacy wrapper remains available: + +```bash +pnpm exec tsx examples/requester-agent.ts +``` diff --git a/examples/requester-agent/index.ts b/examples/requester-agent/index.ts new file mode 100644 index 0000000..e90354f --- /dev/null +++ b/examples/requester-agent/index.ts @@ -0,0 +1,499 @@ +/** + * Requester Agent — Discovers and invokes other agents + * + * Demonstrates: + * - Identity creation for a requester agent + * - Discovery using LocalDiscoveryProvider + * - Trust score checking before invoking capabilities + * - Full flow: discover → check trust → evaluate guard → invoke + * - Evidence recording for the complete interaction + * + * Run: pnpm exec tsx examples/requester-agent/index.ts + */ + +import { createAgentIdentity, createPrincipalIdentity, validateAgentCard, createDelegationToken, validateDelegationToken, signAgentCard, signDelegationToken } from '@fides/core' +import type { AgentCard, CapabilityDescriptor } from '@fides/core' +import { classifyCapabilityRisk } from '@fides/core' +import { evaluatePolicy, type PolicyBundle } from '@fides/policy' +import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot, hashEvidenceValue } from '@fides/evidence' +import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' +import { evaluateGuard, createTrustContext } from '@fides/guard' +import { LocalDiscoveryProvider } from '@fides/discovery' + +async function main() { + console.log('═'.repeat(60)) + console.log(' Requester Agent — FIDES Example') + console.log('═'.repeat(60)) + console.log() + + // ─── Step 1: Create Identities ─────────────────────────────── + console.log('📝 Step 1: Creating Identities') + console.log('─'.repeat(40)) + + const { identity: requesterAgent } = await createAgentIdentity() + requesterAgent.metadata = { name: 'Task Orchestrator', version: '1.0.0' } + const { identity: user, privateKey: userPrivateKey } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Alice', + }) + + console.log(` Requester: ${requesterAgent.did}`) + console.log(` User: ${user.did}`) + console.log() + + // ─── Step 2: Create Service Provider AgentCards ────────────── + console.log('🃏 Step 2: Creating Service Provider AgentCards') + console.log('─'.repeat(40)) + + // Calendar service provider + const { identity: calendarAgent, privateKey: calendarAgentPrivateKey } = await createAgentIdentity() + calendarAgent.metadata = { name: 'Calendar Service' } + const calendarCapabilities: CapabilityDescriptor[] = [ + { + id: 'calendar.schedule', + name: 'Schedule Event', + description: 'Schedule a calendar event', + inputSchema: { type: 'object', properties: { title: { type: 'string' }, date: { type: 'string' } }, required: ['title', 'date'] }, + outputSchema: { type: 'object', properties: { eventId: { type: 'string' } } }, + riskLevel: 'low', + requiresApproval: false, + requiresRuntimeAttestation: false, + }, + { + id: 'calendar.read', + name: 'List Events', + description: 'List calendar events', + inputSchema: { type: 'object', properties: { start: { type: 'string' }, end: { type: 'string' } } }, + outputSchema: { type: 'array', items: { type: 'object' } }, + riskLevel: 'low', + requiresApproval: false, + requiresRuntimeAttestation: false, + }, + ] + const calendarCard: AgentCard = { + id: calendarAgent.did, + identity: calendarAgent, + capabilities: calendarCapabilities, + endpoints: [ + { url: 'https://calendar.example.com/fides', protocol: 'https', capabilities: ['calendar.schedule', 'calendar.read'], auth: 'signature' }, + ], + policies: [{ requiresRuntimeAttestation: false, requiresApproval: false, minTrustScore: 0.5 }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + // Payment service provider + const { identity: paymentAgent, privateKey: paymentAgentPrivateKey } = await createAgentIdentity() + paymentAgent.metadata = { name: 'Payment Service' } + const paymentCapabilities: CapabilityDescriptor[] = [ + { + id: 'payments.prepare', + name: 'Prepare Payment', + description: 'Prepare a payment plan without executing funds movement', + inputSchema: { type: 'object', properties: { amount: { type: 'number' }, currency: { type: 'string' } }, required: ['amount', 'currency'] }, + outputSchema: { type: 'object', properties: { preparationId: { type: 'string' } } }, + riskLevel: 'high', + requiresApproval: true, + requiresRuntimeAttestation: true, + }, + ] + const paymentCard: AgentCard = { + id: paymentAgent.did, + identity: paymentAgent, + capabilities: paymentCapabilities, + endpoints: [ + { url: 'https://payment.example.com/fides', protocol: 'https', capabilities: ['payments.prepare'], auth: 'signature' }, + ], + policies: [{ requiresRuntimeAttestation: true, requiresApproval: true, minTrustScore: 0.8 }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + // Invoice service provider + const { identity: invoiceAgent, privateKey: invoiceAgentPrivateKey } = await createAgentIdentity() + invoiceAgent.metadata = { name: 'Invoice Service' } + const invoiceCapabilities: CapabilityDescriptor[] = [ + { + id: 'invoice.reconcile', + name: 'Reconcile Invoice', + description: 'Reconcile an invoice', + inputSchema: { type: 'object', properties: { orderId: { type: 'string' }, amount: { type: 'number' } }, required: ['orderId', 'amount'] }, + outputSchema: { type: 'object', properties: { invoiceId: { type: 'string' } } }, + riskLevel: 'medium', + requiresApproval: false, + requiresRuntimeAttestation: true, + }, + { + id: 'invoice.read', + name: 'List Invoices', + description: 'List invoices', + inputSchema: { type: 'object', properties: { status: { type: 'string' } } }, + outputSchema: { type: 'array', items: { type: 'object' } }, + riskLevel: 'low', + requiresApproval: false, + requiresRuntimeAttestation: false, + }, + ] + const invoiceCard: AgentCard = { + id: invoiceAgent.did, + identity: invoiceAgent, + capabilities: invoiceCapabilities, + endpoints: [ + { url: 'https://invoice.example.com/fides', protocol: 'https', capabilities: ['invoice.reconcile', 'invoice.read'], auth: 'signature' }, + ], + policies: [{ requiresRuntimeAttestation: true, requiresApproval: false, minTrustScore: 0.7 }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + console.log(` Calendar: ${calendarCard.identity.metadata!.name} (${calendarCard.capabilities.length} caps)`) + console.log(` Payment: ${paymentCard.identity.metadata!.name} (${paymentCard.capabilities.length} caps)`) + console.log(` Invoice: ${invoiceCard.identity.metadata!.name} (${invoiceCard.capabilities.length} caps)`) + console.log() + + // ─── Step 3: Register All Providers with Local Discovery ───── + console.log('🔍 Step 3: Registering with Local Discovery') + console.log('─'.repeat(40)) + + const localDiscovery = new LocalDiscoveryProvider() + await localDiscovery.register(await signAgentCard(calendarCard, calendarAgentPrivateKey, calendarAgent.did)) + await localDiscovery.register(await signAgentCard(paymentCard, paymentAgentPrivateKey, paymentAgent.did)) + await localDiscovery.register(await signAgentCard(invoiceCard, invoiceAgentPrivateKey, invoiceAgent.did)) + + console.log(` Registered 3 service providers`) + + // Discover each provider + const discoveredCalendar = await localDiscovery.resolve(calendarAgent.did) + const discoveredPayment = await localDiscovery.resolve(paymentAgent.did) + const discoveredInvoice = await localDiscovery.resolve(invoiceAgent.did) + + console.log(` Discovered calendar: ${discoveredCalendar ? discoveredCalendar.identity.metadata!.name : 'NOT FOUND'}`) + console.log(` Discovered payment: ${discoveredPayment ? discoveredPayment.identity.metadata!.name : 'NOT FOUND'}`) + console.log(` Discovered invoice: ${discoveredInvoice ? discoveredInvoice.identity.metadata!.name : 'NOT FOUND'}`) + + // List all available agents + const allAgents = localDiscovery.list() + const verifiedCalendarCandidates = await localDiscovery.discover({ + schema_version: 'fides.discovery_query.v1', + id: 'requester-calendar-query', + capability: 'calendar.schedule', + }) + console.log(` Total agents in discovery: ${allAgents.length}`) + console.log(` Verified calendar candidates: ${verifiedCalendarCandidates.filter(candidate => candidate.verified).length}`) + console.log() + + // ─── Step 4: User Delegates to Requester ───────────────────── + console.log('🔑 Step 4: User Delegates to Requester Agent') + console.log('─'.repeat(40)) + + const userDelegation = await signDelegationToken(createDelegationToken({ + delegator: user.did, + delegatee: requesterAgent.did, + capabilities: ['calendar.schedule', 'payments.prepare', 'invoice.reconcile'], + constraints: { + maxActions: 20, + maxSpend: '5000.00', + allowedContexts: ['work'], + }, + expiresAt: new Date(Date.now() + 86400000).toISOString(), + }), userPrivateKey) + + const delegationValid = validateDelegationToken(userDelegation) + console.log(` Token ID: ${userDelegation.id}`) + console.log(` User → Requester: ${userDelegation.delegator} → ${userDelegation.delegatee}`) + console.log(` Capabilities: ${userDelegation.capabilities.join(', ')}`) + console.log(` Max spend: $${userDelegation.constraints.maxSpend}`) + console.log(` Valid: ${delegationValid.valid}`) + console.log() + + // ─── Step 5: Trust Score Checking ──────────────────────────── + console.log('📊 Step 5: Trust Score Checking Before Invocation') + console.log('─'.repeat(40)) + + // Simulate trust scores for each provider + const trustScores: Record = { + [calendarAgent.did]: 0.85, + [paymentAgent.did]: 0.95, + [invoiceAgent.did]: 0.70, + } + + for (const [did, score] of Object.entries(trustScores)) { + const card = await localDiscovery.resolve(did) + const minRequired = card?.policies[0]?.minTrustScore ?? 0 + const meetsThreshold = score >= minRequired + const status = meetsThreshold ? '✅ PASS' : '❌ FAIL' + console.log(` ${card?.identity.metadata!.name}: score=${score.toFixed(2)}, required=${minRequired.toFixed(2)} ${status}`) + } + console.log() + + // ─── Step 6: Full Flow — Discover → Trust → Guard → Invoke ── + console.log('🔄 Step 6: Full Flow — Discover → Trust → Guard → Invoke') + console.log('─'.repeat(40)) + + // Shared policy for all invocations + const requesterPolicy = { + id: 'requester-policy', + version: '1.0.0', + rules: [ + { + id: 'high-trust', + condition: { operator: 'gte', field: 'reputationScore', value: 0.8 }, + action: 'allow' as const, + explanation: 'High trust service provider', + }, + { + id: 'medium-trust', + condition: { operator: 'gte', field: 'reputationScore', value: 0.5 }, + action: 'dry-run' as const, + explanation: 'Medium trust — dry-run mode', + }, + { + id: 'rate-limit', + condition: { operator: 'gt', field: 'requestCount', value: 50 }, + action: 'deny' as const, + explanation: 'Rate limit exceeded', + }, + ], + defaultAction: 'deny' as const, + } satisfies PolicyBundle + + const evidenceChain = createEvidenceChain() + const teeProvider = new MockTEEProvider() + const requesterAttestation = await teeProvider.attest(requesterAgent.did) + + // Flow 1: Invoke calendar service (medium risk, high trust) + console.log(` ┌─ Flow 1: Create Calendar Event`) + console.log(` │`) + + // 1a. Discover + const calendarProvider = await localDiscovery.resolve(calendarAgent.did) + console.log(` │ 1a. Discovered: ${calendarProvider?.identity.metadata!.name}`) + + // 1b. Check trust + const calendarTrustScore = trustScores[calendarAgent.did] ?? 0 + const calendarMeetsTrust = calendarTrustScore >= (calendarProvider?.policies[0]?.minTrustScore ?? 0) + console.log(` │ 1b. Trust score: ${calendarTrustScore.toFixed(2)} (meets threshold: ${calendarMeetsTrust})`) + + // 1c. Evaluate guard + const calendarTrust = createTrustContext({ + reputationScore: calendarTrustScore, + capabilityScore: 0.9, + attestation: requesterAttestation, + evidenceChain, + killSwitchEngaged: false, + recentIncidents: 0, + }) + + const calendarGuard = await evaluateGuard({ + agentDid: calendarAgent.did, + capabilityId: 'calendar.schedule', + policy: requesterPolicy, + context: { requestCount: 5, reputationScore: calendarTrustScore }, + trust: calendarTrust, + }) + console.log(` │ 1c. Guard decision: ${calendarGuard.decision}`) + + if (calendarGuard.decision === 'allow') { + console.log(` │ 1d. ✅ Invoked calendar.schedule successfully`) + + // Record evidence + const calendarEvent = { + id: 'req-001', + type: 'capability_invoke', + timestamp: new Date().toISOString(), + actor: requesterAgent.did, + action: 'calendar.schedule', + target: calendarAgent.did, + payload: { title: 'Team Meeting', date: '2026-05-06T10:00:00Z' }, + privacy: { level: 'redacted' as const }, + } + appendIntoEvidenceChain(evidenceChain, calendarEvent) + } else { + console.log(` │ 1d. ❌ Invocation blocked: ${calendarGuard.explanation}`) + } + console.log(` │`) + + // Flow 2: Invoke payment service (high risk, high trust) + console.log(` ┌─ Flow 2: Prepare Payment`) + console.log(` │`) + + const paymentProvider = await localDiscovery.resolve(paymentAgent.did) + console.log(` │ 2a. Discovered: ${paymentProvider?.identity.metadata!.name}`) + + const paymentTrustScore = trustScores[paymentAgent.did] ?? 0 + const paymentMeetsTrust = paymentTrustScore >= (paymentProvider?.policies[0]?.minTrustScore ?? 0) + console.log(` │ 2b. Trust score: ${paymentTrustScore.toFixed(2)} (meets threshold: ${paymentMeetsTrust})`) + + const paymentTrust = createTrustContext({ + reputationScore: paymentTrustScore, + capabilityScore: 0.98, + attestation: requesterAttestation, + evidenceChain, + killSwitchEngaged: false, + recentIncidents: 0, + }) + + const paymentGuard = await evaluateGuard({ + agentDid: paymentAgent.did, + capabilityId: 'payments.prepare', + policy: requesterPolicy, + context: { requestCount: 5, reputationScore: paymentTrustScore }, + trust: paymentTrust, + }) + console.log(` │ 2c. Guard decision: ${paymentGuard.decision}`) + + if (paymentGuard.decision === 'allow') { + console.log(` │ 2d. ✅ Invoked payments.prepare successfully`) + const paymentEvent = { + id: 'req-002', + type: 'capability_invoke', + timestamp: new Date().toISOString(), + actor: requesterAgent.did, + action: 'payments.prepare', + target: paymentAgent.did, + payload: { amount: 150, currency: 'USD' }, + privacy: { level: 'redacted' as const }, + } + appendIntoEvidenceChain(evidenceChain, paymentEvent) + } else { + console.log(` │ 2d. ❌ Invocation blocked: ${paymentGuard.explanation}`) + } + console.log(` │`) + + // Flow 3: Invoke invoice service (medium risk, medium trust) + console.log(` ┌─ Flow 3: Reconcile Invoice`) + console.log(` │`) + + const invoiceProvider = await localDiscovery.resolve(invoiceAgent.did) + console.log(` │ 3a. Discovered: ${invoiceProvider?.identity.metadata!.name}`) + + const invoiceTrustScore = trustScores[invoiceAgent.did] ?? 0 + const invoiceMeetsTrust = invoiceTrustScore >= (invoiceProvider?.policies[0]?.minTrustScore ?? 0) + console.log(` │ 3b. Trust score: ${invoiceTrustScore.toFixed(2)} (meets threshold: ${invoiceMeetsTrust})`) + + const invoiceTrust = createTrustContext({ + reputationScore: invoiceTrustScore, + capabilityScore: 0.75, + attestation: requesterAttestation, + evidenceChain, + killSwitchEngaged: false, + recentIncidents: 0, + }) + + const invoiceGuard = await evaluateGuard({ + agentDid: invoiceAgent.did, + capabilityId: 'invoice.reconcile', + policy: requesterPolicy, + context: { requestCount: 5, reputationScore: invoiceTrustScore }, + trust: invoiceTrust, + }) + console.log(` │ 3c. Guard decision: ${invoiceGuard.decision}`) + + if (invoiceGuard.decision === 'allow') { + console.log(` │ 3d. ✅ Invoked invoice.reconcile successfully`) + } else if (invoiceGuard.decision === 'dry-run') { + console.log(` │ 3d. ⚠️ Dry-run mode (trust score below optimal)`) + } else { + console.log(` │ 3d. ❌ Invocation blocked: ${invoiceGuard.explanation}`) + } + console.log(` │`) + + // ─── Step 7: Evidence Chain ────────────────────────────────── + console.log('📜 Step 7: Evidence Chain for Requester Flow') + console.log('─'.repeat(40)) + + // Add policy evaluation events + const policyEvents = [ + { + id: 'req-policy-001', + type: 'policy_eval', + timestamp: new Date().toISOString(), + actor: requesterAgent.did, + action: 'evaluate', + target: calendarAgent.did, + payload: { capability: 'calendar.schedule', decision: calendarGuard.decision }, + privacy: { level: 'public' as const }, + }, + { + id: 'req-policy-002', + type: 'policy_eval', + timestamp: new Date().toISOString(), + actor: requesterAgent.did, + action: 'evaluate', + target: paymentAgent.did, + payload: { capability: 'payments.prepare', decision: paymentGuard.decision }, + privacy: { level: 'public' as const }, + }, + { + id: 'req-policy-003', + type: 'policy_eval', + timestamp: new Date().toISOString(), + actor: requesterAgent.did, + action: 'evaluate', + target: invoiceAgent.did, + payload: { capability: 'invoice.reconcile', decision: invoiceGuard.decision }, + privacy: { level: 'public' as const }, + }, + ] + + let fullChain = createEvidenceChain() + for (const evt of [...evidenceChain.events, ...policyEvents]) { + fullChain = appendEvidenceEvent(fullChain, evt, localEvidenceSignature(evt)) + console.log(` Recorded: ${evt.type} — ${evt.action} → ${evt.target}`) + } + + const chainValid = verifyEvidenceChain(fullChain) + const merkleRoot = buildMerkleRoot(fullChain.events.map(e => e.hash)) + console.log(` Chain valid: ${chainValid}`) + console.log(` Events: ${fullChain.events.length}`) + console.log(` Merkle root: ${merkleRoot.slice(0, 16)}...`) + console.log() + + // ─── Step 8: Capability Risk Summary ───────────────────────── + console.log('⚠️ Step 8: Capability Risk Summary') + console.log('─'.repeat(40)) + + const allCapabilities = [ + ...calendarCapabilities, + ...paymentCapabilities, + ...invoiceCapabilities, + ] + + for (const cap of allCapabilities) { + const risk = classifyCapabilityRisk(cap.id) + const riskLabel = risk === 'critical' ? '🔴' : risk === 'high' ? '🟠' : risk === 'medium' ? '🟡' : '🟢' + console.log(` ${riskLabel} ${cap.id}: ${risk} (declared: ${cap.riskLevel})`) + } + console.log() + + // ─── Summary ───────────────────────────────────────────────── + console.log('═'.repeat(60)) + console.log(' Requester Agent Demo Complete') + console.log('═'.repeat(60)) + console.log() + console.log(' Demonstrated:') + console.log(' ✅ Identity creation for requester agent') + console.log(' ✅ Multiple service provider AgentCards') + console.log(' ✅ Local discovery registration and resolution') + console.log(' ✅ User delegation to requester') + console.log(' ✅ Trust score checking against provider thresholds') + console.log(' ✅ Full flow: discover → trust → guard → invoke') + console.log(' ✅ Evidence chain for multi-agent interaction') + console.log(' ✅ Capability risk classification across providers') + console.log() +} + +function appendIntoEvidenceChain( + chain: ReturnType, + event: Parameters[1] +): void { + const next = appendEvidenceEvent(chain, event, localEvidenceSignature(event)) + chain.events = next.events + chain.merkleRoot = next.merkleRoot +} + +function localEvidenceSignature(event: unknown): string { + return `local-evidence:${hashEvidenceValue(event).slice('sha256:'.length)}` +} + +main().catch(console.error) diff --git a/scripts/audit-examples.mjs b/scripts/audit-examples.mjs index 0f21720..b514c59 100644 --- a/scripts/audit-examples.mjs +++ b/scripts/audit-examples.mjs @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process' -import { readdirSync, readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -33,6 +33,14 @@ const required = { } for (const [agentId, capabilities] of Object.entries(required)) { + const agentDir = resolve(root, 'examples', agentId) + if (!existsSync(join(agentDir, 'index.ts'))) { + errors.push(`${agentId} is missing examples/${agentId}/index.ts`) + } + if (!existsSync(join(agentDir, 'README.md'))) { + errors.push(`${agentId} is missing examples/${agentId}/README.md`) + } + const agent = agents.find(entry => entry.id === agentId) if (!agent) { errors.push(`example catalog is missing ${agentId}`) @@ -88,15 +96,14 @@ const forbiddenLegacyCapabilities = [ 'payment:status', 'email:send', ] -const exampleSourceFiles = readdirSync(resolve(root, 'examples')) - .filter(file => file.endsWith('.ts')) - .filter(file => file !== 'agent-catalog.ts') +const exampleSourceFiles = findExampleSourceFiles(resolve(root, 'examples')) + .filter(file => !file.endsWith('agent-catalog.ts')) for (const file of exampleSourceFiles) { - const source = readFileSync(resolve(root, 'examples', file), 'utf8') + const source = readFileSync(file, 'utf8') for (const capability of forbiddenLegacyCapabilities) { if (source.includes(capability)) { - errors.push(`${file} still references legacy capability ${capability}`) + errors.push(`${relativeToExamples(file)} still references legacy capability ${capability}`) } } } @@ -107,3 +114,18 @@ if (errors.length > 0) { } console.log(`Example catalog audit passed for ${agents.length} agents.`) + +function findExampleSourceFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const absolutePath = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'node_modules') return [] + return findExampleSourceFiles(absolutePath) + } + return entry.isFile() && entry.name.endsWith('.ts') ? [absolutePath] : [] + }) +} + +function relativeToExamples(file) { + return file.slice(resolve(root, 'examples').length + 1) +} From 34971a6edec726f4b8e27e9d82a50eef4b5d42eb Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:26:53 +0300 Subject: [PATCH 262/282] docs: record target example layout gate --- docs/status/fides-v2-implementation-status.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index a0e08bf..3900526 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -388,6 +388,7 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `cb14b16 test(examples): enforce target agent layout` - `86c719e feat(packages): publish guard and adapters surfaces` - `33a6de8 feat(packages): add daemon and runtime effect boundaries` - `99d40b2 test(examples): enforce v2 capability names` From 061d14e0e422a68d22a3406fec05be3e697bcb50 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:29:36 +0300 Subject: [PATCH 263/282] test(adapters): audit rust adapter readiness --- docs/status/fides-v2-implementation-status.md | 5 + package.json | 3 +- packages/rust-sdk/README.md | 1 + scripts/audit-rust-adapter-readiness.mjs | 101 ++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 scripts/audit-rust-adapter-readiness.mjs diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 3900526..d7340fc 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -54,6 +54,9 @@ Last verified locally: 2026-05-30. - Publishable package hygiene and dry-run pack checks now cover all 25 non-private packages under `packages/*`, and fail if a publishable package is omitted from the public package gate. +- Rust remains adapter-ready rather than required: `pnpm rust-adapter:audit` + verifies `packages/rust-sdk` stays documentation-only, has no Rust/package + runtime manifest, and matches the `@fides/adapters` Rust primitive surfaces. - Canonical v2 example agent catalog for calendar, invoice, payment, requester, and malicious agents, with audited capability IDs, risk classes, required scopes, and authority notes. @@ -323,6 +326,7 @@ pnpm --filter @fides/cli build pnpm package:hygiene pnpm api:audit pnpm cli:audit +pnpm rust-adapter:audit pnpm smoke:agentd ``` @@ -388,6 +392,7 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `TBD test(adapters): audit rust adapter readiness` - `cb14b16 test(examples): enforce target agent layout` - `86c719e feat(packages): publish guard and adapters surfaces` - `33a6de8 feat(packages): add daemon and runtime effect boundaries` diff --git a/package.json b/package.json index a739202..0da2c21 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "cli:audit": "node scripts/audit-cli-surface.mjs", "package:hygiene": "node scripts/check-package-hygiene.mjs", "package:packcheck": "node scripts/check-public-package-packs.mjs", - "verify": "pnpm package:hygiene && pnpm api:audit && pnpm cli:audit && pnpm examples:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", + "rust-adapter:audit": "node scripts/audit-rust-adapter-readiness.mjs", + "verify": "pnpm package:hygiene && pnpm api:audit && pnpm cli:audit && pnpm examples:audit && pnpm rust-adapter:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", "verify:quick": "pnpm lint && pnpm test", "ci:local": "pnpm verify", "dev": "turbo run dev --parallel", diff --git a/packages/rust-sdk/README.md b/packages/rust-sdk/README.md index ff19060..2210fd2 100644 --- a/packages/rust-sdk/README.md +++ b/packages/rust-sdk/README.md @@ -33,6 +33,7 @@ Future Rust adapters may implement: - Rust adapters must not introduce a separate wire format. - Public SDK APIs remain Promise-based TypeScript APIs. - Effect, if used internally, must not leak into Rust adapter protocol objects. +- No Rust crate is required or published yet. ## Current Status diff --git a/scripts/audit-rust-adapter-readiness.mjs b/scripts/audit-rust-adapter-readiness.mjs new file mode 100644 index 0000000..b90ddba --- /dev/null +++ b/scripts/audit-rust-adapter-readiness.mjs @@ -0,0 +1,101 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const rustAdapterDir = resolve(root, 'packages/rust-sdk') +const rustReadmePath = resolve(rustAdapterDir, 'README.md') +const adapterSourcePath = resolve(root, 'packages/adapters/src/index.ts') +const errors = [] + +const forbiddenRuntimeManifests = [ + 'Cargo.toml', + 'Cargo.lock', + 'package.json', +] + +for (const manifest of forbiddenRuntimeManifests) { + if (existsSync(resolve(rustAdapterDir, manifest))) { + errors.push(`packages/rust-sdk/${manifest} must not exist until Rust becomes an explicit optional adapter package`) + } +} + +if (!existsSync(rustReadmePath)) { + errors.push('packages/rust-sdk/README.md is required to document the Rust adapter boundary') +} + +const readme = existsSync(rustReadmePath) ? readFileSync(rustReadmePath, 'utf8') : '' +const adapterSource = readFileSync(adapterSourcePath, 'utf8') +const requiredReadmePhrases = [ + 'FIDES v2 is TS-first', + 'Rust is adapter-ready, not required', + 'No Rust crate is required or published yet', + 'Rust must not become a runtime dependency for the TypeScript SDK, CLI, daemon,', + '@fides/adapters', + 'RustPrimitiveAdapter', +] + +for (const phrase of requiredReadmePhrases) { + if (!readme.includes(phrase)) { + errors.push(`packages/rust-sdk/README.md must include "${phrase}"`) + } +} + +const adapterSurfaces = extractStringArray(adapterSource, 'RUST_PRIMITIVE_SURFACES') +const readmeSurfaces = [ + 'canonical JSON serialization', + 'hashing', + 'canonical object signing', + 'canonical object signature verification', + 'evidence hash-chain append and verification helpers', + 'Merkle proof creation and verification', + 'DAG primitives for evidence lineage', +] + +for (const surface of [ + 'canonical_json', + 'hashing', + 'object_signing', + 'signature_verification', + 'evidence_hash_chain', + 'merkle_proofs', + 'dag_primitives', +]) { + if (!adapterSurfaces.includes(surface)) { + errors.push(`RUST_PRIMITIVE_SURFACES is missing ${surface}`) + } +} + +for (const readmeSurface of readmeSurfaces) { + if (!readme.includes(readmeSurface)) { + errors.push(`packages/rust-sdk/README.md is missing documented surface "${readmeSurface}"`) + } +} + +if (!adapterSource.includes('runtime_dependency_required: false')) { + errors.push('RustPrimitiveAdapterManifest must require runtime_dependency_required: false') +} + +if (!adapterSource.includes("schema_version: 'fides.rust_primitive_adapter.manifest.v1'")) { + errors.push('RustPrimitiveAdapterManifest must declare the stable fides.rust_primitive_adapter.manifest.v1 schema') +} + +if (errors.length > 0) { + console.error('Rust adapter readiness audit failed:') + for (const error of errors) { + console.error(`- ${error}`) + } + process.exit(1) +} + +console.log(`Rust adapter readiness audit passed for ${adapterSurfaces.length} primitive surfaces.`) + +function extractStringArray(source, exportName) { + const match = source.match(new RegExp(String.raw`export const ${exportName} = \[([\s\S]*?)\] as const`)) + if (!match) { + errors.push(`Could not find ${exportName} in packages/adapters/src/index.ts`) + return [] + } + + return [...match[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]) +} From d2d9e4860236f7586782e8cc412d24eec21baa49 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:29:41 +0300 Subject: [PATCH 264/282] docs: record rust adapter readiness gate --- docs/status/fides-v2-implementation-status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index d7340fc..4942c72 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -392,7 +392,7 @@ Observed manual smoke results: Recent v2 status/DX commits: -- `TBD test(adapters): audit rust adapter readiness` +- `061d14e test(adapters): audit rust adapter readiness` - `cb14b16 test(examples): enforce target agent layout` - `86c719e feat(packages): publish guard and adapters surfaces` - `33a6de8 feat(packages): add daemon and runtime effect boundaries` From ad606ecc07b261f6177c8d923877b00c2979ba92 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:34:03 +0300 Subject: [PATCH 265/282] feat(delegation): add canonical delegation tokens --- docs/protocol/delegation-and-sessions.md | 32 ++++++++ packages/core/src/delegation.ts | 94 ++++++++++++++++++++++++ packages/core/test/delegation.test.ts | 73 ++++++++++++++++++ 3 files changed, 199 insertions(+) diff --git a/docs/protocol/delegation-and-sessions.md b/docs/protocol/delegation-and-sessions.md index d19c7b0..2aa3496 100644 --- a/docs/protocol/delegation-and-sessions.md +++ b/docs/protocol/delegation-and-sessions.md @@ -8,6 +8,38 @@ Current implementation anchors: - `packages/core/src/session-store.ts` - `packages/core/src/invocation.ts` +## DelegationToken Fields + +`DelegationTokenV2` is the canonical FIDES v2 delegation payload. Legacy +camelCase `DelegationToken` helpers remain for compatibility, but new authority +paths should use `DelegationTokenV2` and `SignedDelegationTokenV2`. + +- `schema_version` +- `id` +- `issuer` +- `subject` +- `delegator` +- `delegatee` +- `capabilities` +- `constraints` +- `issued_at` +- `expires_at` +- `nonce` +- `audience` +- `payload_hash` +- canonical signature + +`issuer` and `delegator` are the same DID. `subject` and `delegatee` are the +same DID. `payload_hash` is computed with the shared canonical JSON digest over +the unsigned delegation payload. `signDelegationTokenV2` signs with the shared +canonical object signing model and proof purpose `delegation`. + +Signed delegation verification has two levels. `verifySignedDelegationTokenV2` +checks the canonical Ed25519 proof. `verifySignedDelegationTokenV2Issuer` also +requires `proof.verificationMethod` to equal the delegation `issuer`. Authority +paths should use the issuer-bound verifier so a valid signature from a different +DID cannot grant delegation authority. + ## SessionGrant Fields - `id` diff --git a/packages/core/src/delegation.ts b/packages/core/src/delegation.ts index 84da642..60225ce 100644 --- a/packages/core/src/delegation.ts +++ b/packages/core/src/delegation.ts @@ -32,6 +32,24 @@ export interface DelegationToken { export type SignedDelegationToken = SignedObject +export interface DelegationTokenV2 { + schema_version: 'fides.delegation_token.v1' + id: string + issuer: string + subject: string + delegator: string + delegatee: string + capabilities: string[] + constraints: Record + issued_at: string + expires_at: string + nonce: string + audience: string[] + payload_hash: string +} + +export type SignedDelegationTokenV2 = SignedObject + export interface SessionGrant { id: string token: DelegationToken @@ -75,6 +93,17 @@ export interface DelegationInput { audience?: string[] } +export interface DelegationTokenV2Input { + delegator: string + delegatee: string + capabilities: string[] + constraints?: Record + expiresAt: string + audience?: string[] + issuedAt?: string + nonce?: string +} + export interface SessionGrantV2Input { requesterAgentId: string targetAgentId: string @@ -109,6 +138,28 @@ export function createDelegationToken(input: DelegationInput): DelegationToken { } } +export function createDelegationTokenV2(input: DelegationTokenV2Input): DelegationTokenV2 { + const payload = { + schema_version: 'fides.delegation_token.v1' as const, + id: crypto.randomUUID(), + issuer: input.delegator, + subject: input.delegatee, + delegator: input.delegator, + delegatee: input.delegatee, + capabilities: input.capabilities, + constraints: input.constraints ?? {}, + issued_at: input.issuedAt ?? new Date().toISOString(), + expires_at: input.expiresAt, + nonce: input.nonce ?? crypto.randomUUID(), + audience: input.audience ?? [input.delegatee], + } + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + export function createSessionGrantV2(input: SessionGrantV2Input): SessionGrantV2 { const sessionId = crypto.randomUUID() const supportedVersions = input.supportedVersions?.length @@ -198,6 +249,33 @@ export function validateDelegationToken(token: DelegationToken): { valid: boolea return { valid: errors.length === 0, errors } } +export function validateDelegationTokenV2(token: DelegationTokenV2, now: Date = new Date()): { valid: boolean; errors: string[] } { + const errors: string[] = [] + const { payload_hash: _, ...payload } = token + + if (token.schema_version !== 'fides.delegation_token.v1') errors.push('DelegationToken.schema_version is invalid') + if (!token.id) errors.push('DelegationToken.id is required') + if (!token.issuer) errors.push('DelegationToken.issuer is required') + if (!token.subject) errors.push('DelegationToken.subject is required') + if (!token.delegator) errors.push('DelegationToken.delegator is required') + if (!token.delegatee) errors.push('DelegationToken.delegatee is required') + if (token.issuer && token.delegator && token.issuer !== token.delegator) { + errors.push('DelegationToken.issuer must match DelegationToken.delegator') + } + if (token.subject && token.delegatee && token.subject !== token.delegatee) { + errors.push('DelegationToken.subject must match DelegationToken.delegatee') + } + if (!token.capabilities || token.capabilities.length === 0) errors.push('DelegationToken.capabilities must not be empty') + if (!token.issued_at) errors.push('DelegationToken.issued_at is required') + if (!token.expires_at) errors.push('DelegationToken.expires_at is required') + if (token.expires_at && new Date(token.expires_at) <= now) errors.push('DelegationToken is expired') + if (!token.nonce) errors.push('DelegationToken.nonce is required') + if (!token.audience || token.audience.length === 0) errors.push('DelegationToken.audience must not be empty') + if (token.payload_hash !== hashProtocolPayload(payload)) errors.push('DelegationToken.payload_hash mismatch') + + return { valid: errors.length === 0, errors } +} + export function validateSessionGrantV2(session: SessionGrantV2): { valid: boolean; errors: string[] } { const errors: string[] = [] if (session.schema_version !== 'fides.session_grant.v1') errors.push('SessionGrant.schema_version is invalid') @@ -242,6 +320,22 @@ export function validateSessionGrantV2(session: SessionGrantV2): { valid: boolea return { valid: errors.length === 0, errors } } +export function signDelegationTokenV2( + token: DelegationTokenV2, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(token, privateKey, { verificationMethod, proofPurpose: 'delegation' }) +} + +export function verifySignedDelegationTokenV2(signed: SignedDelegationTokenV2): Promise { + return verifyObject(signed) +} + +export async function verifySignedDelegationTokenV2Issuer(signed: SignedDelegationTokenV2): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedDelegationTokenV2(signed) +} + export function signSessionGrantV2( session: SessionGrantV2, privateKey: Uint8Array, diff --git a/packages/core/test/delegation.test.ts b/packages/core/test/delegation.test.ts index 8b3101c..eeb526b 100644 --- a/packages/core/test/delegation.test.ts +++ b/packages/core/test/delegation.test.ts @@ -6,9 +6,15 @@ import { deriveSessionPublicKey, revokeSession, createDelegationToken, + createDelegationTokenV2, signDelegationToken, + signDelegationTokenV2, + validateDelegationTokenV2, + verifySignedDelegationTokenV2, + verifySignedDelegationTokenV2Issuer, } from '../src/delegation.js' import * as ed from '@noble/ed25519' +import bs58 from 'bs58' function makeValidToken(overrides: Record = {}) { const token = createDelegationToken({ @@ -23,6 +29,73 @@ function makeValidToken(overrides: Record = {}) { } describe('SessionGrant', () => { + describe('DelegationTokenV2', () => { + it('creates canonical delegation token payloads with issuer-bound signatures', async () => { + const privateKey = ed.utils.randomPrivateKey() + const publicKey = await ed.getPublicKeyAsync(privateKey) + const delegator = `did:fides:${bs58.encode(publicKey)}` + + const token = createDelegationTokenV2({ + delegator, + delegatee: 'did:fides:delegatee', + capabilities: ['invoice.reconcile'], + constraints: { maxActions: 3 }, + audience: ['did:fides:delegatee'], + issuedAt: '2026-05-30T00:00:00.000Z', + expiresAt: '2026-05-31T00:00:00.000Z', + nonce: 'nonce_123', + }) + + expect(token).toMatchObject({ + schema_version: 'fides.delegation_token.v1', + issuer: delegator, + subject: 'did:fides:delegatee', + delegator, + delegatee: 'did:fides:delegatee', + capabilities: ['invoice.reconcile'], + audience: ['did:fides:delegatee'], + nonce: 'nonce_123', + }) + expect(token.payload_hash).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(validateDelegationTokenV2(token, new Date('2026-05-30T00:00:01.000Z'))).toEqual({ + valid: true, + errors: [], + }) + + const signed = await signDelegationTokenV2(token, privateKey, delegator) + expect(signed.proof.proofPurpose).toBe('delegation') + await expect(verifySignedDelegationTokenV2(signed)).resolves.toBe(true) + await expect(verifySignedDelegationTokenV2Issuer(signed)).resolves.toBe(true) + }) + + it('rejects mutated delegation token v2 hashes and issuer mismatches', async () => { + const privateKey = ed.utils.randomPrivateKey() + const publicKey = await ed.getPublicKeyAsync(privateKey) + const delegator = `did:fides:${bs58.encode(publicKey)}` + const token = createDelegationTokenV2({ + delegator, + delegatee: 'did:fides:delegatee', + capabilities: ['calendar.schedule'], + expiresAt: '2026-05-31T00:00:00.000Z', + }) + + const mutated = { + ...token, + capabilities: ['payments.execute'], + } + expect(validateDelegationTokenV2(mutated, new Date('2026-05-30T00:00:01.000Z'))).toMatchObject({ + valid: false, + errors: ['DelegationToken.payload_hash mismatch'], + }) + + const otherPrivateKey = ed.utils.randomPrivateKey() + const otherPublicKey = await ed.getPublicKeyAsync(otherPrivateKey) + const signedByWrongIssuer = await signDelegationTokenV2(token, otherPrivateKey, `did:fides:${bs58.encode(otherPublicKey)}`) + await expect(verifySignedDelegationTokenV2(signedByWrongIssuer)).resolves.toBe(true) + await expect(verifySignedDelegationTokenV2Issuer(signedByWrongIssuer)).resolves.toBe(false) + }) + }) + describe('createSessionGrant', () => { it('creates a valid session with default TTL (1 hour)', () => { const token = makeValidToken() From c6a7c1e617a35fcd061a0ba16c96d72bace4b50a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:34:09 +0300 Subject: [PATCH 266/282] docs: record canonical delegation tokens --- docs/status/fides-v2-implementation-status.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 4942c72..1690d95 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -36,6 +36,8 @@ Last verified locally: 2026-05-30. - Capability-specific trust and reputation scoring with explainability. - Policy-before-execution with approval, dry-run, revocation, incident, runtime attestation, and kill switch inputs. +- Canonically signed `DelegationTokenV2` records with issuer-bound verification, + scoped capabilities, audience restriction, expiry, nonce, and payload hash. - Scoped SessionGrants and invocation preflight. - SessionGrants now carry supported protocol versions, optional required versions, and the negotiated protocol version used for authority. @@ -91,6 +93,8 @@ Last verified locally: 2026-05-30. - Publishable package gate for all non-private package manifests, including README/LICENSE/package metadata and dry-run package contents. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. +- Core tests for issuer-bound `DelegationTokenV2` canonical signatures and + payload-hash tamper detection. - Canonical example catalog audit for the requested demo agents and capability/risk contracts. - Example audit coverage rejects legacy standalone example capability names so @@ -392,6 +396,7 @@ Observed manual smoke results: Recent v2 status/DX commits: +- `ad606ec feat(delegation): add canonical delegation tokens` - `061d14e test(adapters): audit rust adapter readiness` - `cb14b16 test(examples): enforce target agent layout` - `86c719e feat(packages): publish guard and adapters surfaces` From 7299649b17a139cf1314982514c37a87759045a5 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:53:17 +0300 Subject: [PATCH 267/282] feat(agentd): accept canonical delegation tokens --- docs/api-reference.md | 5 + docs/api/agentd.yaml | 103 +++++++++++++++++- docs/sdk-reference.md | 5 + docs/status/fides-v2-implementation-status.md | 5 + packages/core/src/session-store.ts | 73 +++++++++++++ packages/core/test/session-store.test.ts | 64 +++++++++++ packages/sdk/README.md | 4 +- packages/sdk/src/agentd/client.ts | 30 +++-- packages/sdk/test/agentd.test.ts | 39 ++++++- services/agentd/src/index.ts | 26 ++++- services/agentd/test/routes.test.ts | 49 +++++++++ 11 files changed, 380 insertions(+), 23 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 786587b..a5637e4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -79,6 +79,11 @@ Current implementation anchors: - `POST /v1/killswitch/disengage` - `POST /v1/attest` +`POST /v1/sessions` accepts the legacy `token` plus `delegatorPublicKey` path +and the canonical `signedToken` path. New callers should send +`SignedDelegationTokenV2`; agentd verifies the issuer-bound canonical proof and +does not require a separate public key. + ## v2 Alias Endpoints - `POST /dht/start` diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index e8545e7..307c927 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -1170,10 +1170,12 @@ paths: security: - ApiKeyAuth: [] description: | - Creates a local SessionGrant from a DelegationToken. In production, - or whenever `AGENTD_REQUIRE_AUTHORITY_SIGNATURE_VERIFICATION=true`, - the request must include `delegatorPublicKey` so agentd can verify - the DelegationToken before storing the session. + Creates a local SessionGrant from either a legacy DelegationToken or a + canonical SignedDelegationTokenV2. New callers should send + `signedToken`; agentd verifies its issuer-bound canonical proof without + a separate `delegatorPublicKey`. Legacy `token` requests still require + `delegatorPublicKey` in production or whenever + `AGENTD_REQUIRE_AUTHORITY_SIGNATURE_VERIFICATION=true`. requestBody: required: true content: @@ -3863,6 +3865,87 @@ components: type: string description: Hex-encoded Ed25519 signature over the canonical token body + DelegationTokenV2: + type: object + required: + - schema_version + - id + - issuer + - subject + - delegator + - delegatee + - capabilities + - constraints + - issued_at + - expires_at + - nonce + - payload_hash + properties: + schema_version: + type: string + example: fides.delegation_token.v1 + id: + type: string + issuer: + type: string + description: Issuer DID. Must match the proof verification method. + subject: + type: string + delegator: + type: string + delegatee: + type: string + capabilities: + type: array + items: + type: string + constraints: + type: object + additionalProperties: true + issued_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + nonce: + type: string + audience: + type: array + items: + type: string + payload_hash: + type: string + example: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + + SignedDelegationTokenV2: + type: object + required: [payload, proof] + properties: + payload: + $ref: "#/components/schemas/DelegationTokenV2" + proof: + type: object + required: [type, created, verificationMethod, proofPurpose, payloadHash, proofValue] + properties: + type: + type: string + example: Ed25519Signature2020 + created: + type: string + format: date-time + verificationMethod: + type: string + description: did:fides DID containing the signing Ed25519 public key. + proofPurpose: + type: string + example: delegation + payloadHash: + type: string + proofValue: + type: string + description: Base58 Ed25519 canonical object signature + SessionGrant: type: object properties: @@ -3891,10 +3974,12 @@ components: SessionCreateRequest: type: object - required: [token, capabilityId] + required: [capabilityId] properties: token: $ref: "#/components/schemas/DelegationToken" + signedToken: + $ref: "#/components/schemas/SignedDelegationTokenV2" capabilityId: type: string audience: @@ -3907,7 +3992,10 @@ components: delegatorPublicKey: type: string pattern: "^[a-fA-F0-9]{64}$" - description: Delegator Ed25519 public key. Required when `AGENTD_REQUIRE_AUTHORITY_SIGNATURE_VERIFICATION=true`. + description: Delegator Ed25519 public key for legacy `token` requests. Not required for canonical `signedToken` requests. + oneOf: + - required: [token] + - required: [signedToken] SessionCreateResponse: type: object @@ -3916,6 +4004,9 @@ components: type: boolean session: $ref: "#/components/schemas/SessionGrant" + signedDelegationVerified: + type: boolean + description: True when the session was created from a canonical SignedDelegationTokenV2. SessionLookupResponse: type: object diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index cf653ca..51040bf 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -283,6 +283,11 @@ from daemon-side verification. Advanced authority flows can use `AgentdClient`. `AgentdClient.health()` reads `GET /health` and returns typed authority-store and local-state-store status, including the SQLite snapshot path when the daemon exposes it. +`AgentdClient.createSignedSession()` now creates a canonical +`SignedDelegationTokenV2` and sends it as `signedToken` to `/v1/sessions`. +The daemon verifies the issuer-bound proof directly, so no external +`delegatorPublicKey` is required for this path. The delegator DID must be a +`did:fides:` identity that matches the supplied private key. ## AGIT / Rust Primitive Bridge diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 1690d95..4789756 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -38,6 +38,9 @@ Last verified locally: 2026-05-30. attestation, and kill switch inputs. - Canonically signed `DelegationTokenV2` records with issuer-bound verification, scoped capabilities, audience restriction, expiry, nonce, and payload hash. +- `agentd` `/v1/sessions` accepts canonical `SignedDelegationTokenV2` payloads + and verifies the issuer-bound proof without requiring an external + `delegatorPublicKey`; legacy token sessions remain supported. - Scoped SessionGrants and invocation preflight. - SessionGrants now carry supported protocol versions, optional required versions, and the negotiated protocol version used for authority. @@ -95,6 +98,8 @@ Last verified locally: 2026-05-30. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. - Core tests for issuer-bound `DelegationTokenV2` canonical signatures and payload-hash tamper detection. +- Core and agentd route tests cover canonical delegation-session creation, + issuer mismatch rejection, and nonce replay protection. - Canonical example catalog audit for the requested demo agents and capability/risk contracts. - Example audit coverage rejects legacy standalone example capability names so diff --git a/packages/core/src/session-store.ts b/packages/core/src/session-store.ts index 4f7a75c..584b419 100644 --- a/packages/core/src/session-store.ts +++ b/packages/core/src/session-store.ts @@ -5,8 +5,11 @@ import { createSessionGrant, isSessionExpired, type DelegationToken, + type SignedDelegationTokenV2, type SessionGrant, validateDelegationToken, + validateDelegationTokenV2, + verifySignedDelegationTokenV2Issuer, } from './delegation.js' export interface NonceUseRecord { @@ -42,6 +45,15 @@ export interface DelegationAuthorizationInput { ttlMs?: number } +export interface DelegationAuthorizationV2Input { + signedToken: SignedDelegationTokenV2 + store: SessionStore + capabilityId?: string + audience?: string + boundTo?: string + ttlMs?: number +} + export interface SessionInvocationInput { sessionId: string store: SessionStore @@ -215,6 +227,51 @@ export async function authorizeDelegation(input: DelegationAuthorizationInput): return { ok: true, session, errors: [] } } +export async function authorizeDelegationV2(input: DelegationAuthorizationV2Input): Promise { + const proofValid = await verifySignedDelegationTokenV2Issuer(input.signedToken) + const validation = validateDelegationTokenV2(input.signedToken.payload) + const errors = [...validation.errors] + + if (!proofValid) { + errors.push('DelegationToken canonical signature verification failed') + } + + const token = toLegacyDelegationToken(input.signedToken) + + if (input.capabilityId && !token.capabilities.includes(input.capabilityId)) { + errors.push(`DelegationToken does not grant capability ${input.capabilityId}`) + } + + if (input.audience && token.audience?.length && !token.audience.includes(input.audience)) { + errors.push(`DelegationToken audience does not include ${input.audience}`) + } + + if (await input.store.hasNonce(token.nonce)) { + errors.push('DelegationToken nonce has already been used') + } + + if (errors.length > 0) { + return { ok: false, errors } + } + + const session = toStoredSession( + createSessionGrant({ + token, + boundTo: input.boundTo, + ttlMs: input.ttlMs, + }) + ) + await input.store.markNonceUsed({ + nonce: token.nonce, + tokenId: token.id, + delegator: token.delegator, + delegatee: token.delegatee, + usedAt: new Date().toISOString(), + }) + await input.store.createSession(session) + return { ok: true, session, errors: [] } +} + export async function authorizeSessionInvocation(input: SessionInvocationInput): Promise { const session = await input.store.getSession(input.sessionId) if (!session) { @@ -246,6 +303,22 @@ export function toStoredSession(session: SessionGrant): StoredSession { } } +function toLegacyDelegationToken(signedToken: SignedDelegationTokenV2): DelegationToken { + const token = signedToken.payload + return { + id: token.id, + delegator: token.delegator, + delegatee: token.delegatee, + capabilities: token.capabilities, + constraints: token.constraints, + issuedAt: token.issued_at, + expiresAt: token.expires_at, + nonce: token.nonce, + audience: token.audience, + signature: signedToken.proof.proofValue, + } +} + function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error } diff --git a/packages/core/test/session-store.test.ts b/packages/core/test/session-store.test.ts index 6d3a088..ba800bf 100644 --- a/packages/core/test/session-store.test.ts +++ b/packages/core/test/session-store.test.ts @@ -4,10 +4,14 @@ import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' import { authorizeDelegation, + authorizeDelegationV2, authorizeSessionInvocation, createDelegationToken, + createDelegationTokenV2, + createIdentityKeyPair, FileSessionStore, InMemorySessionStore, + signDelegationTokenV2, } from '../src/index.js' const tempDirs: string[] = [] @@ -66,6 +70,66 @@ describe('SessionStore authorization', () => { expect(replay.errors).toContain('DelegationToken nonce has already been used') }) + it('creates sessions from issuer-bound canonical delegation tokens', async () => { + const store = new InMemorySessionStore() + const { privateKey, did: delegator } = await createIdentityKeyPair() + const token = createDelegationTokenV2({ + delegator, + delegatee: 'did:fides:delegatee', + capabilities: ['payments.execute'], + constraints: { maxActions: 1 }, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + audience: ['agentd'], + nonce: 'nonce_v2_authorized', + }) + const signedToken = await signDelegationTokenV2(token, privateKey, delegator) + + const result = await authorizeDelegationV2({ + signedToken, + store, + capabilityId: 'payments.execute', + audience: 'agentd', + }) + + expect(result.ok).toBe(true) + expect(result.session?.token).toMatchObject({ + id: token.id, + delegator, + delegatee: 'did:fides:delegatee', + capabilities: ['payments.execute'], + issuedAt: token.issued_at, + expiresAt: token.expires_at, + nonce: 'nonce_v2_authorized', + signature: signedToken.proof.proofValue, + }) + expect(await store.hasNonce(token.nonce)).toBe(true) + }) + + it('rejects canonical delegation tokens signed by a different issuer', async () => { + const store = new InMemorySessionStore() + const { did: delegator } = await createIdentityKeyPair() + const wrongIssuer = await createIdentityKeyPair() + const token = createDelegationTokenV2({ + delegator, + delegatee: 'did:fides:delegatee', + capabilities: ['payments.execute'], + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + audience: ['agentd'], + }) + const signedToken = await signDelegationTokenV2(token, wrongIssuer.privateKey, wrongIssuer.did) + + const result = await authorizeDelegationV2({ + signedToken, + store, + capabilityId: 'payments.execute', + audience: 'agentd', + }) + + expect(result.ok).toBe(false) + expect(result.errors).toContain('DelegationToken canonical signature verification failed') + expect(await store.listSessions()).toHaveLength(0) + }) + it('rejects missing capabilities before creating a session', async () => { const store = new InMemorySessionStore() diff --git a/packages/sdk/README.md b/packages/sdk/README.md index e402bad..a5eb5c6 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -290,7 +290,7 @@ const card = await agentd.getCard('did:fides:agent') const domain = await agentd.verifyDomain('example.com', 'did:fides:agent') const session = await agentd.createSignedSession({ - delegator: 'did:fides:principal', + delegator: 'did:fides:', delegatee: 'did:fides:agent', capabilities: ['payments.execute'], capabilityId: 'payments.execute', @@ -459,7 +459,7 @@ const distribution = await platform.trustAnchorDistribution({ | `DiscoveryClient.verifyDomain(did, domain?)` | Verify and persist a registered identity domain in discovery | | `DiscoveryClient.verifyOrganizationDomain(did, domain?)` | Verify and persist a registered organization domain in discovery | | `AgentdClient.createSession(request)` | Create delegated agentd sessions | -| `AgentdClient.createSignedSession(options)` | Create and sign a delegation token before opening a session | +| `AgentdClient.createSignedSession(options)` | Create a canonical `SignedDelegationTokenV2` and open a session without an external public-key field | | `AgentdClient.recordRevocation(request)` | Submit signed authority revocations | | `AgentdClient.recordSignedRevocation(options)` | Create and sign an authority revocation before submission | | `AgentdClient.recordIncident(request)` | Submit signed authority incidents | diff --git a/packages/sdk/src/agentd/client.ts b/packages/sdk/src/agentd/client.ts index b8cd6e0..ecd2b39 100644 --- a/packages/sdk/src/agentd/client.ts +++ b/packages/sdk/src/agentd/client.ts @@ -1,13 +1,15 @@ import { - createDelegationToken, + createDelegationTokenV2, createIncidentRecord, createRevocationRecord, deriveEd25519PublicKeyHex, + didFromPublicKey, isErrorEnvelope, - signDelegationToken, + signDelegationTokenV2, signIncidentRecord, signRevocationRecord, type DelegationToken as CoreDelegationToken, + type SignedDelegationTokenV2 as CoreSignedDelegationTokenV2, type ErrorEnvelope, type IncidentRecord as CoreIncidentRecord, type RevocationRecord as CoreRevocationRecord, @@ -53,6 +55,7 @@ export interface AuthorizationRequest { } export type DelegationToken = CoreDelegationToken +export type SignedDelegationTokenV2 = CoreSignedDelegationTokenV2 export interface SessionGrant { id: string @@ -67,7 +70,8 @@ export interface SessionGrant { } export interface SessionCreateRequest { - token: DelegationToken + token?: DelegationToken + signedToken?: SignedDelegationTokenV2 capabilityId?: string audience?: string boundTo?: string @@ -79,6 +83,7 @@ export interface SessionCreateResponse { authorized: boolean session?: SessionGrant errors?: string[] + signedDelegationVerified?: boolean } export interface SessionLookupResponse { @@ -130,7 +135,7 @@ export interface CreateSignedSessionOptions { delegatee: string capabilities: string[] privateKey: Uint8Array | string - constraints?: DelegationToken['constraints'] + constraints?: Record audience?: string[] capabilityId?: string boundTo?: string @@ -262,8 +267,13 @@ export class AgentdClient { async createSignedSession(options: CreateSignedSessionOptions): Promise { const privateKey = this.privateKeyBytes(options.privateKey) + const expectedDelegator = await this.didForPrivateKey(privateKey) + if (options.delegator !== expectedDelegator) { + throw new AgentdError('Delegator DID must match the supplied Ed25519 private key') + } + const audience = options.audience ?? ['agentd'] - const token = createDelegationToken({ + const token = createDelegationTokenV2({ delegator: options.delegator, delegatee: options.delegatee, capabilities: options.capabilities, @@ -271,15 +281,14 @@ export class AgentdClient { expiresAt: options.tokenExpiresAt ?? this.expiresAt(options.tokenTtlMs ?? 3600_000), audience, }) - const signedToken = await signDelegationToken(token, privateKey) + const signedToken = await signDelegationTokenV2(token, privateKey, options.delegator) return this.createSession({ - token: signedToken, + signedToken, capabilityId: options.capabilityId, audience: options.sessionAudience ?? audience[0] ?? 'agentd', boundTo: options.boundTo, ttlMs: options.sessionTtlMs, - delegatorPublicKey: await this.publicKeyHex(options.privateKey), }) } @@ -393,6 +402,11 @@ export class AgentdClient { return deriveEd25519PublicKeyHex(hex) } + private async didForPrivateKey(key: Uint8Array): Promise { + const publicKeyHex = await this.publicKeyHex(key) + return didFromPublicKey(Uint8Array.from(Buffer.from(publicKeyHex, 'hex'))) + } + private expiresAt(ttlMs: number): string { return new Date(Date.now() + ttlMs).toISOString() } diff --git a/packages/sdk/test/agentd.test.ts b/packages/sdk/test/agentd.test.ts index 5be544e..f7d933c 100644 --- a/packages/sdk/test/agentd.test.ts +++ b/packages/sdk/test/agentd.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { deriveEd25519PublicKeyHex, didFromPublicKey } from '@fides/core' import { AgentdClient, AgentdError } from '../src/agentd/client.js' describe('AgentdClient', () => { @@ -268,13 +269,15 @@ describe('AgentdClient', () => { }) it('creates signed sessions from authority inputs', async () => { + const publicKeyHex = await deriveEd25519PublicKeyHex(privateKeyHex) + const delegator = didFromPublicKey(Uint8Array.from(Buffer.from(publicKeyHex, 'hex'))) mockFetch.mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({ authorized: true, session: { id: 'sess-1', token, sessionKey: 'redacted', expiresAt: token.expiresAt } }), }) await expect(client.createSignedSession({ - delegator: 'did:fides:principal', + delegator, delegatee: 'did:fides:agent', capabilities: ['payments.execute'], privateKey: privateKeyHex, @@ -286,15 +289,39 @@ describe('AgentdClient', () => { const body = JSON.parse(init.body as string) expect(body.capabilityId).toBe('payments.execute') expect(body.audience).toBe('agentd') - expect(body.delegatorPublicKey).toMatch(/^[0-9a-f]{64}$/) - expect(body.token).toMatchObject({ + expect(body.delegatorPublicKey).toBeUndefined() + expect(body.token).toBeUndefined() + expect(body.signedToken).toMatchObject({ + payload: { + schema_version: 'fides.delegation_token.v1', + issuer: delegator, + subject: 'did:fides:agent', + delegator, + delegatee: 'did:fides:agent', + capabilities: ['payments.execute'], + expires_at: '2026-01-01T01:00:00.000Z', + audience: ['agentd'], + }, + proof: { + proofPurpose: 'delegation', + verificationMethod: delegator, + }, + }) + expect(body.signedToken.payload.payload_hash).toMatch(/^sha256:[0-9a-f]{64}$/) + }) + + it('rejects signed sessions when the delegator DID does not match the private key', async () => { + await expect(client.createSignedSession({ delegator: 'did:fides:principal', delegatee: 'did:fides:agent', capabilities: ['payments.execute'], - expiresAt: '2026-01-01T01:00:00.000Z', - audience: ['agentd'], + privateKey: privateKeyHex, + capabilityId: 'payments.execute', + })).rejects.toMatchObject({ + name: 'AgentdError', + message: 'Delegator DID must match the supplied Ed25519 private key', }) - expect(body.token.signature).toMatch(/^[0-9a-f]{128}$/) + expect(mockFetch).not.toHaveBeenCalled() }) it('records and reads authority revocations', async () => { diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index cc2d425..c88b748 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -29,6 +29,7 @@ import { createTrustContext, evaluateGuard } from '@fides/guard' import { aggregateIncidentImpact, authorizeDelegation, + authorizeDelegationV2, authorizeSessionInvocation, createAgentIdentity, computeCapabilityReputation, @@ -91,6 +92,7 @@ import { type IdentityTrustAnchor, type KillSwitchRule, type DelegationToken, + type SignedDelegationTokenV2, type PrincipalIdentity, type PublisherIdentity, type ReputationRecord, @@ -4155,8 +4157,30 @@ app.post('/v1/policy/evaluate', async (c) => { // ─── Delegation Sessions (local) ───────────────────────────────── app.post('/v1/sessions', async (c) => { const body = await c.req.json() + const signedToken = body.signedToken ?? (body.token?.payload && body.token?.proof ? body.token : undefined) + if (signedToken) { + const result = await authorizeDelegationV2({ + signedToken: signedToken as SignedDelegationTokenV2, + store: authorityStore, + capabilityId: body.capabilityId, + audience: body.audience, + boundTo: body.boundTo, + ttlMs: body.ttlMs, + }) + + if (!result.ok) { + return c.json({ authorized: false, errors: result.errors }, 409) + } + + return c.json({ + authorized: true, + session: redactSessionKey(result.session!), + signedDelegationVerified: true, + }, 201) + } + if (!body.token) { - return c.json({ error: 'token is required' }, 400) + return c.json({ error: 'token or signedToken is required' }, 400) } const signatureErrors = await verifyOptionalSignature( diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 272dacc..15652c1 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -45,12 +45,14 @@ vi.mock('node:dns/promises', () => ({ import { app } from '../src/index.js' import { createDelegationToken, + createDelegationTokenV2, createIdentityKeyPair, createIncidentRecord, createInvocationRequest, createRevocationRecord, hashProtocolPayload, signDelegationToken, + signDelegationTokenV2, signIncidentRecord, signInvocationRequest, signRevocationRecord, @@ -115,6 +117,22 @@ describe('Agentd Service Routes', () => { } } + async function signedDelegationTokenV2(delegatee: string) { + const { privateKey, did: delegator } = await createIdentityKeyPair() + const token = createDelegationTokenV2({ + delegator, + delegatee, + capabilities: ['payments.execute'], + constraints: {}, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + audience: ['agentd'], + }) + return { + token, + signedToken: await signDelegationTokenV2(token, privateKey, delegator), + } + } + async function signedIncidentRecord(actor: string) { const privateKey = Buffer.from('01'.repeat(32), 'hex') return signIncidentRecord(createIncidentRecord({ @@ -3081,6 +3099,37 @@ describe('Agentd Service Routes', () => { expect(data.errors).toContain('DelegationToken nonce has already been used') }) + it('creates sessions from canonical signed DelegationTokenV2 without external public keys', async () => { + const { signedToken } = await signedDelegationTokenV2(`${TEST_DID}:session-v2`) + + const res = await app.request('/v1/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + signedToken, + capabilityId: 'payments.execute', + audience: 'agentd', + }), + }) + + expect(res.status).toBe(201) + const data = await res.json() + expect(data).toMatchObject({ + authorized: true, + signedDelegationVerified: true, + session: { + token: { + id: signedToken.payload.id, + delegator: signedToken.payload.delegator, + delegatee: signedToken.payload.delegatee, + capabilities: ['payments.execute'], + signature: signedToken.proof.proofValue, + }, + sessionKey: 'redacted', + }, + }) + }) + it('rejects tampered delegation tokens when a delegator public key is supplied', async () => { const { token, publicKey } = await signedDelegationToken(`${TEST_DID}:tampered-session`) const tampered = { ...token, capabilities: ['payments.refund'] } From e7263e717b90b71ce4818e84d006517186b88da3 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:53:24 +0300 Subject: [PATCH 268/282] test(cli): stabilize entrypoint failure formatting test --- packages/cli/test/entrypoint.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/entrypoint.test.ts b/packages/cli/test/entrypoint.test.ts index 021ef6b..cc9a237 100644 --- a/packages/cli/test/entrypoint.test.ts +++ b/packages/cli/test/entrypoint.test.ts @@ -20,7 +20,7 @@ describe('CLI entrypoint', () => { cwd: cliRoot, encoding: 'utf8', stdio: 'pipe', - timeout: 5000, + timeout: 15000, }) expect(result.status).toBe(1) From c066f90968e4060934865f3752625b92fe8393e0 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 22:59:43 +0300 Subject: [PATCH 269/282] feat(cli): sign invocation requests --- docs/cli-reference.md | 8 +- docs/status/fides-v2-implementation-status.md | 8 +- packages/cli/src/commands/invoke.ts | 117 +++++++++++++++++- packages/cli/test/commands.test.ts | 107 +++++++++++++++- scripts/audit-cli-surface.mjs | 2 +- 5 files changed, 231 insertions(+), 11 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c0c04cb..47a7885 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -68,6 +68,7 @@ agentd dht publish --capability invoice.reconcile --agent-id did:fides:... agentd dht find --capability invoice.reconcile agentd invoke did:fides:... --capability invoice.reconcile --input invoice.json --requested-scopes invoice:read agentd invoke --session-id sess_... --input invoice.json +agentd invoke --session-id sess_... --input invoice.json --sign --requester-private-key-file requester.key agentd invoke --dry-run did:fides:... --capability payments.prepare --input payment.json agentd trust did:fides:... --capability invoice.reconcile agentd reputation update --agent did:fides:... --capability invoice.reconcile --successful-invocations 5 @@ -146,8 +147,11 @@ local pointer without a URL by passing `--agent-id` or `--agent-card-id` with `POST /invoke` directly. With ` --capability`, it first requests a policy-checked `SessionGrant` from `POST /sessions`, then invokes that session. Input defaults to `{}` and can be supplied with `--input` or `--input-json`. -Use `--dry-run` to request dry-run execution; discovery is never treated as -authority by this command. +Use `--dry-run` to request dry-run execution. Use `--sign` with +`--requester-private-key-file` or `--requester-private-key` to fetch the +SessionGrant, create a canonical signed `InvocationRequest`, and submit it as +`signedRequest`; the private key must resolve to the grant's +`requester_agent_id`. Discovery is never treated as authority by this command. `trust --capability` evaluates root v2 capability-specific trust through local agentd, defaulting to `http://localhost:7345` or diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 4789756..f9cc4f5 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -42,6 +42,9 @@ Last verified locally: 2026-05-30. and verifies the issuer-bound proof without requiring an external `delegatorPublicKey`; legacy token sessions remain supported. - Scoped SessionGrants and invocation preflight. +- `agentd invoke --sign` fetches or creates a SessionGrant, verifies the + requester private key resolves to `requester_agent_id`, signs a canonical + `InvocationRequest`, and submits it as `signedRequest`. - SessionGrants now carry supported protocol versions, optional required versions, and the negotiated protocol version used for authority. - Hash-chained EvidenceEvents with verification and export. @@ -100,6 +103,8 @@ Last verified locally: 2026-05-30. payload-hash tamper detection. - Core and agentd route tests cover canonical delegation-session creation, issuer mismatch rejection, and nonce replay protection. +- CLI tests cover canonical signed invocation request submission and issuer + proof verification. - Canonical example catalog audit for the requested demo agents and capability/risk contracts. - Example audit coverage rejects legacy standalone example capability names so @@ -114,7 +119,8 @@ Last verified locally: 2026-05-30. - AgentCard create/sign/verify/register/discover. - Capability-specific trust and reputation. - Policy evaluation and session request. -- Invocation with dry-run and denial modes. +- Invocation with dry-run, denial modes, and optional canonical signed + invocation requests through SDK and CLI. - Evidence append, inspect, verify, and export. - Full demo scenario. - Adversarial simulation harness. diff --git a/packages/cli/src/commands/invoke.ts b/packages/cli/src/commands/invoke.ts index d411be4..1d585cc 100644 --- a/packages/cli/src/commands/invoke.ts +++ b/packages/cli/src/commands/invoke.ts @@ -1,6 +1,14 @@ import { readFileSync } from 'node:fs' +import { + createInvocationRequest, + deriveEd25519PublicKeyHex, + didFromPublicKey, + signInvocationRequest, + type SessionGrantV2, + type SignedInvocationRequest, +} from '@fides/core' import { Command } from 'commander' -import { parseList, parseJsonObject, postJson, printResult } from './authority-utils.js' +import { getJson, parseList, parseJsonObject, postJson, printResult } from './authority-utils.js' export function createInvokeCommand(): Command { return new Command('invoke') @@ -12,6 +20,9 @@ export function createInvokeCommand(): Command { .option('--input ', 'Invocation input JSON file') .option('--input-json ', 'Invocation input JSON object') .option('--dry-run', 'Request dry-run execution') + .option('--sign', 'Sign the invocation request with the requester agent key') + .option('--requester-private-key ', 'Requester Ed25519 private key as 32-byte hex') + .option('--requester-private-key-file ', 'File containing requester Ed25519 private key hex') .option('--principal-id ', 'Principal DID for session creation') .option('--requester-agent-id ', 'Requester agent DID for session creation') .option('--requested-scopes ', 'Comma-separated requested scopes for session creation') @@ -23,11 +34,23 @@ export function createInvokeCommand(): Command { try { const input = readInput(options) const baseUrl = options.agentdUrl.replace(/\/$/, '') - const sessionId = options.sessionId ?? await createSession(baseUrl, agentId, options) + const wantsSignedRequest = shouldSignInvocation(options) + const session = options.sessionId + ? wantsSignedRequest ? await getSession(baseUrl, options.sessionId) : undefined + : await createSession(baseUrl, agentId, options) + const sessionId = session?.session_id ?? options.sessionId + if (!sessionId) { + throw new Error('Either --session-id or with --capability is required') + } + + const signedRequest = session + ? await maybeSignInvocationRequest(session, input, options) + : undefined const result = await postJson(`${baseUrl}/invoke`, { sessionId, input, ...(options.dryRun && { dryRun: true }), + ...(signedRequest && { signedRequest }), }) printResult('Invocation result:', result, options) } catch (error) { @@ -43,6 +66,14 @@ function readInput(options: { input?: string; inputJson?: string }): unknown { return {} } +function shouldSignInvocation(options: { + sign?: boolean + requesterPrivateKey?: string + requesterPrivateKeyFile?: string +}): boolean { + return Boolean(options.sign || options.requesterPrivateKey || options.requesterPrivateKeyFile) +} + async function createSession(baseUrl: string, agentId: string | undefined, options: { capability?: string principalId?: string @@ -51,7 +82,7 @@ async function createSession(baseUrl: string, agentId: string | undefined, optio constraintsJson?: string attestationId?: string approvalGranted?: boolean -}): Promise { +}): Promise { if (!agentId || !options.capability) { throw new Error('Either --session-id or with --capability is required') } @@ -70,9 +101,83 @@ async function createSession(baseUrl: string, agentId: string | undefined, optio if (!response || typeof response !== 'object') { throw new Error('Session response was not an object') } - const session = (response as { session?: { session_id?: unknown } }).session - if (typeof session?.session_id !== 'string') { + return parseSessionGrant((response as { session?: unknown }).session, 'Session response did not include a valid session') +} + +async function getSession(baseUrl: string, sessionId: string): Promise { + const response = await getJson(`${baseUrl}/sessions/${encodeURIComponent(sessionId)}`) + if (!response || typeof response !== 'object') { + throw new Error('Session lookup response was not an object') + } + return parseSessionGrant((response as { session?: unknown }).session, 'Session lookup response did not include a valid session') +} + +async function maybeSignInvocationRequest( + session: SessionGrantV2, + input: unknown, + options: { + sign?: boolean + requesterPrivateKey?: string + requesterPrivateKeyFile?: string + dryRun?: boolean + } +): Promise { + if (!options.sign && !options.requesterPrivateKey && !options.requesterPrivateKeyFile) { + return undefined + } + + const privateKeyHex = readPrivateKeyHex(options) + const publicKeyHex = await deriveEd25519PublicKeyHex(privateKeyHex) + const signerDid = didFromPublicKey(Uint8Array.from(Buffer.from(publicKeyHex, 'hex'))) + if (signerDid !== session.requester_agent_id) { + throw new Error(`Requester private key resolves to ${signerDid}, expected ${session.requester_agent_id}`) + } + + const request = createInvocationRequest({ + issuer: session.requester_agent_id, + sessionGrant: session, + input, + dryRun: options.dryRun, + }) + + return signInvocationRequest( + request, + Uint8Array.from(Buffer.from(privateKeyHex, 'hex')), + session.requester_agent_id + ) +} + +function readPrivateKeyHex(options: { requesterPrivateKey?: string; requesterPrivateKeyFile?: string }): string { + const value = options.requesterPrivateKeyFile + ? readFileSync(options.requesterPrivateKeyFile, 'utf-8') + : options.requesterPrivateKey + if (!value) { + throw new Error('--requester-private-key or --requester-private-key-file is required when signing') + } + + const hex = value.trim() + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error('Requester private key must be a 32-byte hex string') + } + return hex.toLowerCase() +} + +function parseSessionGrant(value: unknown, message: string): SessionGrantV2 { + if (!value || typeof value !== 'object') { + throw new Error(message) + } + + const session = value as Partial + if ( + typeof session.session_id !== 'string' || + typeof session.requester_agent_id !== 'string' || + typeof session.target_agent_id !== 'string' || + typeof session.principal_id !== 'string' || + typeof session.capability !== 'string' || + !Array.isArray(session.scopes) + ) { throw new Error('Session response did not include session.session_id') } - return session.session_id + + return session as SessionGrantV2 } diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index a50fa0c..3683f30 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -231,7 +231,14 @@ describe('CLI Commands', () => { if (String(url).endsWith('/sessions')) { return new Response(JSON.stringify({ authorityGranted: true, - session: { session_id: 'sess_cli' }, + session: { + session_id: 'sess_cli', + requester_agent_id: 'did:fides:requester', + target_agent_id: 'did:fides:agent', + principal_id: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['read:invoices', 'write:evidence'], + }, }), { status: 201, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ @@ -274,6 +281,104 @@ describe('CLI Commands', () => { input: { invoiceId: 'inv_123' }, }); }); + + it('invokes an existing session directly when signing is not requested', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ + authorityGranted: true, + result: { status: 'completed' }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + })); + + const { createInvokeCommand } = await import('../src/commands/invoke.js'); + const cmd = createInvokeCommand(); + + await cmd.parseAsync([ + '--session-id', + 'sess_direct', + '--input-json', + '{"invoiceId":"inv_123"}', + '--json', + ], { from: 'user' }); + + expect(calls.map(call => call.url)).toEqual([ + 'http://localhost:7345/invoke', + ]); + expect(JSON.parse(calls[0].init?.body as string)).toEqual({ + sessionId: 'sess_direct', + input: { invoiceId: 'inv_123' }, + }); + }); + + it('signs invocation requests with the requester key', async () => { + const { + deriveEd25519PublicKeyHex, + didFromPublicKey, + verifySignedInvocationRequestIssuer, + } = await import('@fides/core'); + const privateKeyHex = '1'.repeat(64); + const publicKeyHex = await deriveEd25519PublicKeyHex(privateKeyHex); + const requesterDid = didFromPublicKey(Uint8Array.from(Buffer.from(publicKeyHex, 'hex'))); + const calls: Array<{ url: string; init?: RequestInit }> = []; + + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }); + if (String(url).endsWith('/sessions/sess_signed')) { + return new Response(JSON.stringify({ + session: { + schema_version: 'fides.session_grant.v2', + session_id: 'sess_signed', + requester_agent_id: requesterDid, + target_agent_id: 'did:fides:target', + principal_id: 'did:fides:principal', + capability: 'invoice.reconcile', + scopes: ['read:invoices'], + constraints: {}, + audience: ['did:fides:target'], + policy_hash: 'sha256:policy', + trust_result_hash: 'sha256:trust', + issued_at: '2026-05-30T00:00:00.000Z', + expires_at: '2026-05-30T01:00:00.000Z', + nonce: 'nonce_cli', + payload_hash: 'sha256:session', + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Response(JSON.stringify({ + authorityGranted: true, + signedRequestVerified: true, + result: { status: 'completed' }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + })); + + const { createInvokeCommand } = await import('../src/commands/invoke.js'); + const cmd = createInvokeCommand(); + + await cmd.parseAsync([ + '--session-id', + 'sess_signed', + '--input-json', + '{"invoiceId":"inv_123"}', + '--sign', + '--requester-private-key', + privateKeyHex, + '--json', + ], { from: 'user' }); + + expect(calls.map(call => call.url)).toEqual([ + 'http://localhost:7345/sessions/sess_signed', + 'http://localhost:7345/invoke', + ]); + const invokeBody = JSON.parse(calls[1].init?.body as string); + expect(invokeBody.sessionId).toBe('sess_signed'); + expect(invokeBody.input).toEqual({ invoiceId: 'inv_123' }); + expect(invokeBody.signedRequest.payload.issuer).toBe(requesterDid); + expect(invokeBody.signedRequest.payload.session_id).toBe('sess_signed'); + expect(invokeBody.signedRequest.proof.verificationMethod).toBe(requesterDid); + await expect(verifySignedInvocationRequestIssuer(invokeBody.signedRequest)).resolves.toBe(true); + }); }); describe('card registry commands', () => { diff --git a/scripts/audit-cli-surface.mjs b/scripts/audit-cli-surface.mjs index cf50e98..51fb3f7 100644 --- a/scripts/audit-cli-surface.mjs +++ b/scripts/audit-cli-surface.mjs @@ -48,7 +48,7 @@ const checks = [ { args: ['graph', '--help'], contains: ['inspect'] }, { args: ['policy', '--help'], contains: ['evaluate'] }, { args: ['session', '--help'], contains: ['request', 'show', 'verify'] }, - { args: ['invoke', '--help'], contains: ['--capability ', '--input ', '--dry-run'] }, + { args: ['invoke', '--help'], contains: ['--capability ', '--input ', '--dry-run', '--sign'] }, { args: ['approval', '--help'], contains: ['request', 'list', 'approve', 'deny'] }, { args: ['evidence', '--help'], contains: ['list', 'inspect', 'verify', 'export'] }, { args: ['revoke', '--help'], contains: ['agent', 'key', 'identity', 'card', 'capability', 'session', 'attestation', 'publisher'] }, From 11b4cad4df9df5b4f68847b2f126defc9258d17e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:03:13 +0300 Subject: [PATCH 270/282] feat(cli): accept signed delegation tokens --- docs/cli-reference.md | 6 +- docs/status/fides-v2-implementation-status.md | 4 ++ packages/cli/src/commands/session.ts | 30 +++++++++- packages/cli/test/commands.test.ts | 58 +++++++++++++++++++ 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 47a7885..93a8b91 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -184,8 +184,10 @@ invocation authority until policy produces a scoped `SessionGrant`. `session request`, `session show`, and `session verify` use the root v2 local agentd session endpoints. The older `session create` and `session revoke` -commands remain available for the legacy signed `DelegationToken` `/v1` -authority path. +commands remain available for the `/v1` authority path. `session create` +auto-detects canonical signed delegation-token input shaped as +`{ payload, proof }` and sends it as `signedToken`; legacy `DelegationToken` +input is still sent as `token` and may include `--delegator-public-key`. `attest github/email/domain/package/wallet` add local mock identity trust anchors to an existing identity and emit evidence. `attest runtime/show/verify` diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index f9cc4f5..6a87532 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -41,6 +41,9 @@ Last verified locally: 2026-05-30. - `agentd` `/v1/sessions` accepts canonical `SignedDelegationTokenV2` payloads and verifies the issuer-bound proof without requiring an external `delegatorPublicKey`; legacy token sessions remain supported. +- `agentd session create` auto-detects canonical signed delegation-token input + and sends it as `signedToken`; legacy token input remains available with + optional `--delegator-public-key`. - Scoped SessionGrants and invocation preflight. - `agentd invoke --sign` fetches or creates a SessionGrant, verifies the requester private key resolves to `requester_agent_id`, signs a canonical @@ -103,6 +106,7 @@ Last verified locally: 2026-05-30. payload-hash tamper detection. - Core and agentd route tests cover canonical delegation-session creation, issuer mismatch rejection, and nonce replay protection. +- CLI tests cover canonical signed delegation-token submission to `/v1/sessions`. - CLI tests cover canonical signed invocation request submission and issuer proof verification. - Canonical example catalog audit for the requested demo agents and diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index 18b2083..dc3dafa 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -79,11 +79,11 @@ export function createSessionCommand(): Command { .action(async (options) => { try { const token = parseTokenInput(options) + const tokenBody = createSessionTokenBody(token, options) const result = await postJson(`${options.agentdUrl.replace(/\/$/, '')}/v1/sessions`, { - token, + ...tokenBody, capabilityId: options.capability, audience: options.audience, - ...(options.delegatorPublicKey && { delegatorPublicKey: options.delegatorPublicKey }), ...(options.ttlMs && { ttlMs: Number(options.ttlMs) }), }) printResult('Session created:', result, options) @@ -117,3 +117,29 @@ export function createSessionCommand(): Command { function baseUrl(url: string): string { return url.replace(/\/+$/, '') } + +function createSessionTokenBody( + token: unknown, + options: { delegatorPublicKey?: string } +): { token: unknown; delegatorPublicKey?: string } | { signedToken: unknown } { + if (isCanonicalSignedObject(token)) { + if (options.delegatorPublicKey) { + throw new Error('--delegator-public-key is only valid for legacy DelegationToken input') + } + return { signedToken: token } + } + + return { + token, + ...(options.delegatorPublicKey && { delegatorPublicKey: options.delegatorPublicKey }), + } +} + +function isCanonicalSignedObject(value: unknown): boolean { + if (!value || typeof value !== 'object') return false + const candidate = value as { payload?: unknown; proof?: unknown } + if (!candidate.payload || typeof candidate.payload !== 'object') return false + if (!candidate.proof || typeof candidate.proof !== 'object') return false + const proof = candidate.proof as { verificationMethod?: unknown; proofValue?: unknown } + return typeof proof.verificationMethod === 'string' && typeof proof.proofValue === 'string' +} diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 3683f30..f0468b7 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -1268,6 +1268,64 @@ describe('CLI Commands', () => { }), }) ); + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(init.body as string)).toMatchObject({ + token, + capabilityId: 'payments.execute', + audience: 'agentd', + }); + }); + + it('session create should send canonical signed delegation tokens as signedToken', async () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + signedDelegationVerified: true, + session: { id: 'sess-1', sessionKey: 'redacted' }, + }), { status: 201, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const signedToken = { + payload: { + schema_version: 'fides.delegation_token.v2', + id: 'dtok_1', + issuer: 'did:fides:principal', + subject: 'did:fides:agent', + capabilities: ['payments.execute'], + audience: ['agentd'], + issued_at: '2026-05-30T00:00:00.000Z', + expires_at: '2026-05-30T01:00:00.000Z', + nonce: 'nonce-1', + payload_hash: 'sha256:token', + }, + proof: { + type: 'Ed25519Signature2024', + created: '2026-05-30T00:00:00.000Z', + verificationMethod: 'did:fides:principal', + proofPurpose: 'delegation', + canonicalizationAlgorithm: 'https://fides.dev/canonical-json/v1', + proofValue: 'proof', + }, + }; + + const { createSessionCommand } = await import('../src/commands/session.js'); + const cmd = createSessionCommand(); + + await cmd.parseAsync([ + 'create', + '--agentd-url', + 'http://agentd.test', + '--capability', + 'payments.execute', + '--token-json', + JSON.stringify(signedToken), + '--json', + ], { from: 'user' }); + + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(init.body as string)).toEqual({ + signedToken, + capabilityId: 'payments.execute', + audience: 'agentd', + }); }); it('session create should send a delegator public key when provided', async () => { From 3ab5ec25889a663f59649739697bf5ef16c6bddb Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:04:21 +0300 Subject: [PATCH 271/282] docs: document signed authority cli flows --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 69f792f..4ac6632 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,14 @@ agentd graph inspect did:fides:invoice-agent --agentd-url http://localhost:7345 agentd policy evaluate --agent did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 agentd session request did:fides:invoice-agent --capability invoice.reconcile --requested-scopes invoice:read --agentd-url http://localhost:7345 agentd invoke --session-id sess_... --input invoice.json --agentd-url http://localhost:7345 +agentd invoke --session-id sess_... --input invoice.json --sign --requester-private-key-file requester.key --agentd-url http://localhost:7345 agentd evidence verify --agentd-url http://localhost:7345 ``` Discovery returns candidates only. Policy and scoped SessionGrants are the -authority path. +authority path. `invoke --sign` fetches the SessionGrant, creates a canonical +`InvocationRequest`, verifies that the requester private key resolves to the +grant's `requester_agent_id`, and submits the request as `signedRequest`. ### TypeScript SDK @@ -259,6 +262,8 @@ For production agentd mutations through the CLI, export the same API key used by ```bash export FIDES_API_KEY="$SERVICE_API_KEY" pnpm --filter @fides/cli fides session create --agentd-url https://agentd.example.com --capability payments.execute --token-file token.json --delegator-public-key "$DELEGATOR_PUBLIC_KEY_HEX" +pnpm --filter @fides/cli fides session create --agentd-url https://agentd.example.com --capability payments.execute --token-file signed-delegation-token-v2.json +pnpm --filter @fides/cli fides invoke --agentd-url https://agentd.example.com --session-id "$SESSION_ID" --input payment-dry-run.json --sign --requester-private-key-file requester.key pnpm --filter @fides/cli fides revoke agent did:fides:agent --agentd-url https://agentd.example.com --revoked-by did:fides:principal --reason "disabled" --private-key-hex "$REVOCATION_PRIVATE_KEY_HEX" pnpm --filter @fides/cli fides incident report --agentd-url https://agentd.example.com --actor did:fides:agent --type policy_violation --severity high --description "merchant policy bypass" --reporter did:fides:principal --private-key-hex "$REPORTER_PRIVATE_KEY_HEX" pnpm --filter @fides/cli fides propagation pending --agentd-url https://agentd.example.com --limit 25 @@ -267,7 +272,10 @@ pnpm --filter @fides/cli fides authorize check --agentd-url https://agentd.examp pnpm --filter @fides/cli fides card proxy did:fides:agent --agentd-url https://agentd.example.com ``` -When `--delegator-public-key` is provided, `agentd` verifies the DelegationToken signature before creating the session. +When `--delegator-public-key` is provided, `agentd` verifies the legacy +DelegationToken signature before creating the session. Canonical +`SignedDelegationTokenV2` input is detected from `{ payload, proof }` JSON and +sent as `signedToken`, so the daemon verifies the issuer-bound proof directly. For revocation and incident writes, the CLI derives the signer public key from `--private-key-hex` and sends it as `revokerPublicKey` or `reporterPublicKey`. Use `fides propagation pending` and `fides propagation retry` to inspect and replay failed authority propagation outbox records. Use `fides authorize check` to smoke-test the same local guard decision path used before agent execution. From ee009f3790a498c8018e37db0a999da4a7f6be63 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:10:02 +0300 Subject: [PATCH 272/282] test(cli): smoke signed authority flows --- docs/status/fides-v2-implementation-status.md | 12 ++- scripts/agentd-dx-smoke.ts | 95 ++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 6a87532..9f3fa9d 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -109,6 +109,10 @@ Last verified locally: 2026-05-30. - CLI tests cover canonical signed delegation-token submission to `/v1/sessions`. - CLI tests cover canonical signed invocation request submission and issuer proof verification. +- `pnpm smoke:agentd` starts an isolated local daemon and exercises root + `pnpm agentd` CLI flows for demo, signed invocation, canonical signed + delegation-token session creation, all-provider discovery, and adversarial + simulation. - Canonical example catalog audit for the requested demo agents and capability/risk contracts. - Example audit coverage rejects legacy standalone example capability names so @@ -357,6 +361,9 @@ pnpm smoke:agentd # Equivalent manual flow: AGENTD_LOCAL_STATE=memory AGENTD_PORT=7486 pnpm agentd:dev pnpm --silent agentd demo run --agentd-url http://localhost:7486 --json +pnpm --silent agentd session request --capability invoice.reconcile --requested-scopes invoice:read --principal-id --requester-agent-id --agentd-url http://localhost:7486 --json +pnpm --silent agentd invoke --session-id --input-json '{"invoiceId":"inv_smoke_signed"}' --sign --requester-private-key-file requester.key --agentd-url http://localhost:7486 --json +pnpm --silent agentd session create --capability invoice.reconcile --token-file signed-delegation-token-v2.json --agentd-url http://localhost:7486 --json pnpm --silent agentd discover --capability invoice.reconcile --all-providers --agentd-url http://localhost:7486 --json pnpm --silent agentd simulate adversarial --agentd-url http://localhost:7486 --json ``` @@ -368,6 +375,10 @@ Observed manual smoke results: - demo returned `evidenceHashChainValid: true`. - demo returned `discoveryGrantsAuthority: false`. - demo returned `payments: "dry_run_only"`. +- signed invocation returned `signedRequestVerified: true`. +- signed invocation returned `signedResultVerified: true`. +- canonical signed delegation-token session creation returned + `signedDelegationVerified: true`. - all-provider discovery queried local, well-known, registry, relay, DHT, and federation providers. - all-provider discovery returned `authorityGranted: false`. - registry publish, relay register, and DHT publish responses return top-level @@ -404,7 +415,6 @@ Observed manual smoke results: - Add real DHT, relay, registry, and federation adapters. - Add production TEE/build/container attestation providers. - Harden local key storage beyond prototype snapshot material. -- Expand CLI end-to-end tests around root `pnpm agentd` scripts. - Add full release notes and contribution guidance for external OSS users. ## Commit History Summary diff --git a/scripts/agentd-dx-smoke.ts b/scripts/agentd-dx-smoke.ts index eaa723c..5c9c5a7 100644 --- a/scripts/agentd-dx-smoke.ts +++ b/scripts/agentd-dx-smoke.ts @@ -1,8 +1,14 @@ import { spawn, execFile } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' +import { + createAgentIdentity, + createDelegationTokenV2, + createPrincipalIdentity, + signDelegationTokenV2, +} from '@fides/core' const execFileAsync = promisify(execFile) @@ -39,6 +45,88 @@ async function main() { assert(demo.verification?.evidenceHashChainValid === true, 'demo evidence hash chain must verify') console.log('ok agentd demo run') + const requester = await createAgentIdentity() + const requesterPrivateKeyHex = Buffer.from(requester.privateKey).toString('hex') + const requesterKeyPath = join(workdir, 'requester.key') + await writeFile(requesterKeyPath, requesterPrivateKeyHex, { mode: 0o600 }) + + const invoiceSession = await runAgentdJson([ + 'session', + 'request', + String(demo.identities?.invoice), + '--capability', + 'invoice.reconcile', + '--requested-scopes', + 'invoice:read', + '--principal-id', + String(demo.identities?.principal), + '--requester-agent-id', + requester.identity.did, + '--agentd-url', + baseUrl, + '--json', + ]) + const invoiceSessionId = assertString(invoiceSession.session?.session_id, 'signed invoke smoke session id') + assert(invoiceSession.authorityGranted === true, 'signed invoke smoke session must grant execution authority') + + const signedInvocation = await runAgentdJson([ + 'invoke', + '--session-id', + invoiceSessionId, + '--input-json', + '{"invoiceId":"inv_smoke_signed"}', + '--sign', + '--requester-private-key-file', + requesterKeyPath, + '--agentd-url', + baseUrl, + '--json', + ]) + assert(signedInvocation.signedRequestVerified === true, 'signed invocation request must verify') + assert(signedInvocation.signedResultVerified === true, 'signed invocation result must verify') + assert(signedInvocation.authorityGranted === true, 'signed invocation must preserve execution authority') + assert(signedInvocation.result?.status === 'completed', 'signed invocation must complete') + assert( + signedInvocation.signedRequest?.payload?.issuer === requester.identity.did, + 'signed invocation issuer must be the requester DID', + ) + console.log('ok signed invocation CLI authority path') + + const delegator = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Smoke Delegator', + verificationMethod: 'self_signed', + verified: false, + }) + const signedDelegationToken = await signDelegationTokenV2( + createDelegationTokenV2({ + delegator: delegator.identity.did, + delegatee: String(demo.identities?.invoice), + capabilities: ['invoice.reconcile'], + audience: ['agentd'], + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + delegator.privateKey, + delegator.identity.did, + ) + const signedTokenPath = join(workdir, 'signed-delegation-token-v2.json') + await writeFile(signedTokenPath, JSON.stringify(signedDelegationToken, null, 2)) + const delegatedSession = await runAgentdJson([ + 'session', + 'create', + '--capability', + 'invoice.reconcile', + '--token-file', + signedTokenPath, + '--agentd-url', + baseUrl, + '--json', + ]) + assert(delegatedSession.authorized === true, 'canonical signed delegation token must authorize a session') + assert(delegatedSession.signedDelegationVerified === true, 'canonical signed delegation token must verify') + assert(delegatedSession.session?.id, 'canonical signed delegation session must include an id') + console.log('ok signed delegation token CLI authority path') + const discovery = await runAgentdJson(['discover', '--capability', 'invoice.reconcile', '--all-providers', '--json']) assert(discovery.authorityGranted === false, 'all-provider discovery must not grant authority') assertNoAuthorityGrantedTrue(discovery, 'all-provider discovery response') @@ -129,6 +217,11 @@ function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message) } +function assertString(value: unknown, label: string): string { + assert(typeof value === 'string' && value.length > 0, `${label} must be a non-empty string`) + return value +} + function assertNoAuthorityGrantedTrue(value: unknown, label: string): void { if (!value || typeof value !== 'object') return if (Array.isArray(value)) { From 9c89cfc071b9b5c4ae0b7ad5b2950e9eae315d56 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:11:50 +0300 Subject: [PATCH 273/282] docs: add contribution guide --- CONTRIBUTING.md | 108 ++++++++++++++++++ README.md | 20 +--- docs/status/fides-v2-implementation-status.md | 5 +- 3 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5f9dc57 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,108 @@ +# Contributing to FIDES + +FIDES is a TS-first Agent Trust Fabric. Contributions should preserve the +protocol invariants that make discovery, trust, authority, policy, and evidence +separate layers. + +## Before You Start + +Use Node.js 22+ and pnpm 10. + +```bash +pnpm install +pnpm verify +``` + +For local daemon and CLI smoke testing: + +```bash +pnpm smoke:agentd +``` + +## Architecture Rules + +- Discovery must never grant authority. Discovery results are candidates only. +- Identity must never imply trust. A valid DID can still be low trust. +- Trust scores must never imply permission. Policy decisions issue authority. +- Policy must run before execution, signing, or external side effects. +- Evidence must be privacy-aware. Prefer hash-only or redacted inputs/outputs. +- Signed protocol objects must use the shared canonical signing model. +- Public protocol objects and SDK APIs must stay framework-agnostic and + Promise-based. +- FIDES is TS-first and Rust adapter-ready. Do not make Rust required for the + first working path. +- OAPS concepts should be ported into FIDES-owned runtime types rather than + added as a runtime dependency. +- Sardis-specific payment execution belongs in Sardis. FIDES owns generic + authority, trust, policy, delegation, and evidence primitives. + +## Code Changes + +Keep changes scoped and atomic. Prefer the existing package boundaries and +helpers before introducing new abstractions. + +Use focused tests for the changed package, then run the relevant repository +gate: + +```bash +pnpm --filter @fides/core test +pnpm --filter @fides/cli test +pnpm api:audit +pnpm cli:audit +pnpm examples:audit +pnpm package:hygiene +pnpm package:packcheck +pnpm verify +``` + +For public package changes, keep package metadata, README, LICENSE, exports, +and dry-run package contents aligned with `scripts/public-packages.mjs`. + +## Security-Sensitive Work + +Treat identity, signatures, delegation, sessions, policy, evidence, revocation, +incidents, kill switches, API keys, and storage as security-sensitive. + +Look for: + +- signature or issuer-binding bypasses +- replay and nonce mistakes +- policy-after-execution ordering bugs +- authority granted by discovery, registry, relay, or DHT paths +- raw sensitive input/output leaking into evidence +- private key, token, or API key exposure +- revocation, incident, or kill switch bypasses + +Do not log secrets or private keys. Use stable typed `ErrorEnvelope` responses +for public API/SDK/CLI failure surfaces. + +## Documentation + +Update docs when behavior changes. Prefer concrete flows and file paths over +abstract claims. + +Common docs to update: + +- `README.md` +- `docs/status/fides-v2-implementation-status.md` +- `docs/api-reference.md` +- `docs/cli-reference.md` +- `docs/sdk-reference.md` +- `docs/protocol/*` +- `docs/api/agentd.yaml` + +If an implementation is local mock, adapter-ready, or spec-complete rather than +production-like, say so explicitly. + +## Pull Requests + +Before opening a PR: + +1. Rebase or merge current `main`. +2. Run `pnpm verify`. +3. Run `pnpm smoke:agentd` for CLI/API/daemon changes. +4. Include what changed, what was verified, and any known limitations. +5. Keep commits atomic and descriptive. + +Do not claim production readiness for local mock registry, relay, DHT, +federation, or TEE surfaces. diff --git a/README.md b/README.md index 4ac6632..51a5c8d 100644 --- a/README.md +++ b/README.md @@ -402,22 +402,10 @@ FIDES v2 implements a complete trust fabric with: ## Contributing -We welcome contributions! Here's how to get started: - -1. **Fork the repository** -2. **Create a feature branch** — `git checkout -b feature/amazing-feature` -3. **Make your changes** — Follow TypeScript best practices -4. **Add tests** — Ensure `pnpm test` passes -5. **Commit changes** — `git commit -m 'Add amazing feature'` -6. **Push to branch** — `git push origin feature/amazing-feature` -7. **Open a Pull Request** - -**Guidelines:** -- Write clear commit messages -- Add tests for new features -- Update documentation as needed -- Follow existing code style -- Ensure CI passes +See [CONTRIBUTING.md](CONTRIBUTING.md) for the FIDES v2 contribution workflow, +architecture invariants, security review checklist, and verification gates. +At minimum, keep `pnpm verify` green and run `pnpm smoke:agentd` for CLI, +API, daemon, session, invocation, discovery, demo, or simulation changes. --- diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 9f3fa9d..a5a05ae 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -101,6 +101,9 @@ Last verified locally: 2026-05-30. - OpenAPI contract coverage for evidence-producing discovery responses. - Publishable package gate for all non-private package manifests, including README/LICENSE/package metadata and dry-run package contents. +- Root `CONTRIBUTING.md` documents protocol invariants, security-sensitive + review areas, docs expectations, and local verification gates for external + contributors. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. - Core tests for issuer-bound `DelegationTokenV2` canonical signatures and payload-hash tamper detection. @@ -415,7 +418,7 @@ Observed manual smoke results: - Add real DHT, relay, registry, and federation adapters. - Add production TEE/build/container attestation providers. - Harden local key storage beyond prototype snapshot material. -- Add full release notes and contribution guidance for external OSS users. +- Add full release notes for external OSS users. ## Commit History Summary From 6e7e4afed6e7a2a979aa582196e01544cd8d764a Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:13:42 +0300 Subject: [PATCH 274/282] docs: add release notes snapshot --- README.md | 4 + RELEASE_NOTES.md | 134 ++++++++++++++++++ docs/status/fides-v2-implementation-status.md | 4 +- 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 RELEASE_NOTES.md diff --git a/README.md b/README.md index 51a5c8d..6bac383 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,10 @@ architecture invariants, security review checklist, and verification gates. At minimum, keep `pnpm verify` green and run `pnpm smoke:agentd` for CLI, API, daemon, session, invocation, discovery, demo, or simulation changes. +See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the current v2 release snapshot, +verified gates, local mock surfaces, adapter-ready surfaces, and release +checklist. + --- ## License diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..88052b5 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,134 @@ +# FIDES v2 Release Notes + +This document summarizes the current FIDES v2 Agent Trust Fabric state for +local evaluation and OSS review. It is not a production-readiness claim for the +entire protocol roadmap. + +## Current Snapshot + +FIDES v2 is a TS-first, Rust-adapter-ready trust fabric for autonomous agent +systems. The current implementation provides local-first identity, signed +AgentCards, candidate-only discovery, capability-specific trust and reputation, +policy-before-execution, delegation, scoped sessions, signed invocation, +runtime attestation primitives, evidence, revocation, incidents, kill switches, +CLI, local daemon HTTP API, SDK, examples, demo, and adversarial simulation. + +The implementation keeps the core protocol constraints explicit: + +- discovery does not grant authority +- identity does not equal trust +- trust score does not equal permission +- policy is evaluated before execution +- signed protocol objects use one canonical signing model +- evidence defaults toward privacy-aware hash-only or redacted records +- Rust is optional and adapter-ready rather than required + +## Production-Like Surfaces + +- Canonical JSON signing and Ed25519 verification primitives. +- Typed `ErrorEnvelope` vocabulary on root v2 API/SDK/CLI failure paths. +- Policy-before-execution hooks for revocation, incidents, runtime attestation, + approval, kill switch, dry-run, and scope limits. +- Issuer-bound canonical `DelegationTokenV2` signatures and payload-hash + validation. +- Scoped `SessionGrantV2` records with protocol version negotiation. +- Canonical signed `InvocationRequest` and signed invocation result support. +- Hash-chained evidence verification and export. +- Publishable package hygiene and dry-run pack gates for public packages. +- OpenAPI and CLI surface audits. + +## Working Prototype Surfaces + +- Local `agentd` root v2 API and local state store. +- Promise-based `FidesClient` SDK. +- `agentd` CLI for identity, AgentCards, discovery, trust, reputation, policy, + sessions, invocation, evidence, revocation, incidents, kill switch, registry, + relay, DHT, demo, and adversarial simulation. +- Full local demo and adversarial simulation endpoints. +- Example agents for calendar, invoice, payment dry-run, requester, and + malicious simulation flows. + +## Local Mock Surfaces + +- Registry discovery. +- Relay presence and rendezvous hints. +- DHT pointer records. +- Federation discovery. +- MockTEE runtime attestation provider. +- Generic payment flow is dry-run only; payment execution remains Sardis-owned. + +## Adapter-Ready Surfaces + +- AGIT/Rust primitive bridge for hashing, canonicalization, hash chain, Merkle, + and DAG-style evidence primitives. +- libp2p/Kademlia DHT provider boundary. +- Relay transport boundary. +- Real TEE/build/container attestation provider boundaries. +- MCP, A2A, OAPS, OSP, AP2, x402, and Sardis interop adapter contracts. +- Federation peering and propagation contracts. + +## Recently Verified + +Local verification has passed with: + +```bash +pnpm verify +pnpm smoke:agentd +pnpm package:hygiene +pnpm package:packcheck +pnpm api:audit +pnpm cli:audit +pnpm examples:audit +pnpm rust-adapter:audit +``` + +`pnpm smoke:agentd` starts an isolated local daemon and exercises root +`pnpm agentd` CLI flows for demo, signed invocation, canonical signed +delegation-token session creation, all-provider discovery, and adversarial +simulation. + +## Known Limitations + +- The full FIDES v2 pivot is not complete. +- Several target package boundaries are facades over `packages/core` rather + than independent implementations. +- Registry, relay, DHT, and federation are local mock/simulator surfaces, not + production networks. +- Real TEE providers are adapter-ready but not implemented. +- Local key material is prototype-grade and should be hardened before + production use. +- Remote CI has not been checked in the current local session unless a PR or CI + run explicitly says otherwise. +- The branch may need to be pushed before external review. + +## Release Checklist + +Before cutting an external release: + +1. Run `pnpm verify`. +2. Run `pnpm smoke:agentd`. +3. Confirm `.github/workflows/ci.yml` passes remotely. +4. Confirm `scripts/print-public-package-dirs.mjs` matches intended packages. +5. Review `docs/status/fides-v2-implementation-status.md`. +6. Update this file with the final tag, date, commit range, and known limits. +7. Do not describe local mock or adapter-ready surfaces as production-ready. + +## Suggested Tag Notes Template + +```markdown +## vX.Y.Z - YYYY-MM-DD + +### Added +- ... + +### Changed +- ... + +### Verification +- `pnpm verify` +- `pnpm smoke:agentd` +- Remote CI: + +### Known Limits +- ... +``` diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index a5a05ae..a024df4 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -104,6 +104,9 @@ Last verified locally: 2026-05-30. - Root `CONTRIBUTING.md` documents protocol invariants, security-sensitive review areas, docs expectations, and local verification gates for external contributors. +- Root `RELEASE_NOTES.md` records the current v2 snapshot, production-like + surfaces, working prototypes, local mocks, adapter-ready surfaces, verified + gates, known limits, and release checklist. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. - Core tests for issuer-bound `DelegationTokenV2` canonical signatures and payload-hash tamper detection. @@ -418,7 +421,6 @@ Observed manual smoke results: - Add real DHT, relay, registry, and federation adapters. - Add production TEE/build/container attestation providers. - Harden local key storage beyond prototype snapshot material. -- Add full release notes for external OSS users. ## Commit History Summary From 88196d4266d4ca8db1415c0cb6271c0667b8b7cd Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:15:47 +0300 Subject: [PATCH 275/282] ci: run agentd dx smoke --- .github/workflows/ci.yml | 5 +++++ RELEASE_NOTES.md | 6 +++++- docs/status/fides-v2-implementation-status.md | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0675f54..48ab6ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,11 @@ jobs: DATABASE_URL: postgresql://fides:fides@localhost:5432/fides AGENTD_POSTGRES_TEST_REQUIRED: 'true' + - name: Agentd CLI smoke + run: pnpm smoke:agentd + env: + AGENTD_DX_SMOKE_PORT: '4819' + docker-build: runs-on: ubuntu-latest strategy: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 88052b5..bcea0a7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -87,6 +87,9 @@ pnpm rust-adapter:audit delegation-token session creation, all-provider discovery, and adversarial simulation. +The CI workflow runs both `pnpm verify` and `pnpm smoke:agentd`, so the same +local gates are enforced on pull requests before the Docker smoke job runs. + ## Known Limitations - The full FIDES v2 pivot is not complete. @@ -98,7 +101,8 @@ simulation. - Local key material is prototype-grade and should be hardened before production use. - Remote CI has not been checked in the current local session unless a PR or CI - run explicitly says otherwise. + run explicitly says otherwise; the workflow is configured to run the full + verify gate and the agentd DX smoke. - The branch may need to be pushed before external review. ## Release Checklist diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index a024df4..384cc8a 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -125,6 +125,8 @@ Last verified locally: 2026-05-30. docs, demos, and scripts stay aligned with the v2 ontology. - CI and npm publish workflows use the same full `pnpm verify` gate used locally. +- CI also runs `pnpm smoke:agentd`, so signed CLI authority flows, local daemon + DX, all-provider discovery, demo, and adversarial simulation are PR-gated. ## Working Prototype From 101bf68e17885398391bb9182634fd6a815a1edc Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:18:59 +0300 Subject: [PATCH 276/282] docs: improve public package readmes --- RELEASE_NOTES.md | 2 ++ docs/status/fides-v2-implementation-status.md | 3 ++ packages/attestations/README.md | 25 +++++++++++++++- packages/cards/README.md | 26 ++++++++++++++++- packages/crypto/README.md | 23 ++++++++++++++- packages/daemon/README.md | 25 ++++++++++++++-- packages/delegation/README.md | 25 +++++++++++++++- packages/dht/README.md | 25 ++++++++++++++-- packages/identity/README.md | 29 ++++++++++++++++--- packages/incidents/README.md | 26 ++++++++++++++++- packages/invocation/README.md | 25 +++++++++++++++- packages/registry/README.md | 26 ++++++++++++++++- packages/relay/README.md | 15 ++++++++++ packages/reputation/README.md | 19 +++++++++++- packages/revocation/README.md | 25 +++++++++++++++- packages/runtime-effect/README.md | 15 ++++++++++ packages/shared/README.md | 29 +++++++++++++++++-- packages/trust/README.md | 28 ++++++++++++++++-- scripts/check-package-hygiene.mjs | 9 ++++++ 19 files changed, 378 insertions(+), 22 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bcea0a7..c4b0068 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -89,6 +89,8 @@ simulation. The CI workflow runs both `pnpm verify` and `pnpm smoke:agentd`, so the same local gates are enforced on pull requests before the Docker smoke job runs. +`pnpm package:hygiene` also enforces non-placeholder README coverage for every +publishable package boundary. ## Known Limitations diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 384cc8a..7851134 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -107,6 +107,9 @@ Last verified locally: 2026-05-30. - Root `RELEASE_NOTES.md` records the current v2 snapshot, production-like surfaces, working prototypes, local mocks, adapter-ready surfaces, verified gates, known limits, and release checklist. +- Public package README quality is now enforced by `pnpm package:hygiene` so + publishable facade packages include installation, usage, status, and boundary + guidance instead of placeholder package pages. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. - Core tests for issuer-bound `DelegationTokenV2` canonical signatures and payload-hash tamper detection. diff --git a/packages/attestations/README.md b/packages/attestations/README.md index 2510efe..e8b87bb 100644 --- a/packages/attestations/README.md +++ b/packages/attestations/README.md @@ -2,10 +2,33 @@ Runtime attestation and TEE-ready provider entrypoints for FIDES v2. +Runtime attestations bind an agent to code, runtime, policy, and enclave or +build measurements. FIDES uses them as trust and policy inputs, especially for +high-risk capabilities, while keeping protocol objects framework-agnostic. + +## Installation + +```bash +npm install @fides/attestations +``` + +## Usage + ```typescript -import { MockTEEProvider, verifyRuntimeAttestation } from '@fides/attestations' +import { + MockTEEProvider, + NullAttestationProvider, + createRuntimeAttestation, + verifyRuntimeAttestation, +} from '@fides/attestations' ``` +## Status + +`MockTEEProvider` and `NullAttestationProvider` are local development surfaces. +AWS Nitro, SGX, SEV, container image, and reproducible build providers are +adapter-ready interfaces, not production integrations in this package snapshot. + ## License MIT diff --git a/packages/cards/README.md b/packages/cards/README.md index ebd355d..babf66a 100644 --- a/packages/cards/README.md +++ b/packages/cards/README.md @@ -3,10 +3,34 @@ AgentCard, capability descriptor, capability ontology, and risk taxonomy entrypoints for FIDES v2. +AgentCards describe who an agent is, who published it, which capabilities it +claims, which transports it supports, what policy requirements apply, and which +protocol versions are compatible. A signed AgentCard is a candidate record, not +an authorization grant. + +## Installation + +```bash +npm install @fides/cards +``` + +## Usage + ```typescript -import { signAgentCard, verifySignedAgentCard } from '@fides/cards' +import { + classifyCapabilityRisk, + createCapabilityDescriptor, + signAgentCard, + verifySignedAgentCardIdentity, +} from '@fides/cards' ``` +## Status + +`@fides/cards` is a framework-agnostic facade over the v2 AgentCard and +capability ontology primitives. It preserves the invariant that discovery and +metadata verification never equal authority. + ## License MIT diff --git a/packages/crypto/README.md b/packages/crypto/README.md index 2fe9aaf..4cd075e 100644 --- a/packages/crypto/README.md +++ b/packages/crypto/README.md @@ -6,10 +6,31 @@ This package is a domain facade over the TS-first core implementation. It keeps crypto-related imports stable while Rust adapters for canonicalization, hashing, Merkle, DAG, or performance-critical primitives remain optional. +All signed protocol objects should use the same canonical object signing model. +Higher level packages should not invent object-specific signature formats. + +## Installation + +```bash +npm install @fides/crypto +``` + +## Usage + ```typescript -import { canonicalDigest, signObject } from '@fides/crypto' +import { + canonicalDigest, + canonicalJson, + signObject, + verifyObject, +} from '@fides/crypto' ``` +## Status + +`@fides/crypto` is TS-first and Rust adapter-ready. Future AGIT/Rust adapters +can provide faster primitives without changing public protocol objects. + ## License MIT diff --git a/packages/daemon/README.md b/packages/daemon/README.md index ec55c55..206d5d4 100644 --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -4,13 +4,32 @@ Local daemon configuration and client boundary for FIDES v2. The production daemon implementation currently lives in `services/agentd`. This package provides the publishable package boundary for tools and adapters -that need stable daemon defaults, local config paths, and a Promise-based client -factory. +that need stable daemon defaults, local config paths, well-known endpoint +helpers, and a Promise-based client factory. + +## Installation + +```bash +npm install @fides/daemon +``` + +## Usage ```typescript -import { createDaemonClient, defaultDaemonConfig } from '@fides/daemon' +import { + DEFAULT_DAEMON_PORT, + createDaemonClient, + defaultDaemonConfig, + wellKnownDaemonEndpoints, +} from '@fides/daemon' ``` +## Status + +`@fides/daemon` is a lightweight boundary package. It does not embed storage, +migrations, or server routes; those remain in the `agentd` service and +`@fides/sdk`. + ## License MIT diff --git a/packages/delegation/README.md b/packages/delegation/README.md index 2900549..400605c 100644 --- a/packages/delegation/README.md +++ b/packages/delegation/README.md @@ -2,10 +2,33 @@ DelegationToken and SessionGrant entrypoints for scoped FIDES v2 authority. +Delegation tokens model authority transfer. Session grants are the short-lived, +audience-bound authorization artifacts used before capability invocation. Both +are signed with the shared canonical object signing model. + +## Installation + +```bash +npm install @fides/delegation +``` + +## Usage + ```typescript -import { createDelegationToken, createSessionGrantV2 } from '@fides/delegation' +import { + createDelegationToken, + createSessionGrantV2, + signSessionGrantV2, + verifySignedSessionGrantV2Issuer, +} from '@fides/delegation' ``` +## Status + +This package exposes scoped authority primitives only. Discovery results, trust +scores, and policy decisions do not grant authority until a valid delegation or +session object is issued and verified. + ## License MIT diff --git a/packages/dht/README.md b/packages/dht/README.md index 01e3825..bade4fa 100644 --- a/packages/dht/README.md +++ b/packages/dht/README.md @@ -2,12 +2,33 @@ DHT pointer record entrypoints for FIDES v2 discovery. -DHT records are pointers only. They are not trust or authority sources. +DHT records are signed pointers only. They are never trust sources and never +grant authority. + +## Installation + +```bash +npm install @fides/dht +``` + +## Usage ```typescript -import { createDHTPointerRecord, verifyDHTPointerRecord } from '@fides/dht' +import { + createDHTPointerRecord, + hashAgentCard, + hashCapability, + signDHTPointerRecord, + verifyDHTPointerRecord, +} from '@fides/dht' ``` +## Status + +`@fides/dht` contains framework-agnostic pointer helpers. The current local DHT +behavior is simulator-grade; libp2p/Kademlia or other production DHT networks +should plug in through adapters and still follow the same verification flow. + ## License MIT diff --git a/packages/identity/README.md b/packages/identity/README.md index 240485c..86f14d1 100644 --- a/packages/identity/README.md +++ b/packages/identity/README.md @@ -3,14 +3,35 @@ Identity entrypoints for FIDES v2 agents, publishers, principals, and trust anchors. -```typescript -import { createAgentIdentity, createPublisherIdentity } from '@fides/identity' -``` - Domains are optional. Domain verification is one trust anchor among GitHub, email, package registry, wallet, passkey, organization invitation, runtime attestation, build attestation, peer attestation, and evidence history. +## Installation + +```bash +npm install @fides/identity +``` + +## Usage + +```typescript +import { + createAgentIdentity, + createPrincipalIdentity, + createPublisherIdentity, + createTrustAnchorDistribution, + isValidFidesDid, +} from '@fides/identity' +``` + +## Status + +`@fides/identity` exposes domainless, hosted, domain-verified, and +organization-oriented identity primitives. A valid cryptographic identity is +not a trust grant; trust anchors, evidence, reputation, runtime attestation, +revocation, and policy still need to be evaluated. + ## License MIT diff --git a/packages/incidents/README.md b/packages/incidents/README.md index 1dd62ab..89e8a42 100644 --- a/packages/incidents/README.md +++ b/packages/incidents/README.md @@ -3,10 +3,34 @@ Incident record entrypoints for FIDES v2 reporting, resolution, and trust impact. +Incident records capture reported failures, abuse, policy violations, and +runtime safety issues. They are evidence-linked inputs for trust, reputation, +and policy decisions. + +## Installation + +```bash +npm install @fides/incidents +``` + +## Usage + ```typescript -import { createIncidentRecordV2, aggregateIncidentImpact } from '@fides/incidents' +import { + aggregateIncidentImpact, + createIncidentRecordV2, + resolveIncidentRecordV2, + signIncidentRecordV2, + verifySignedIncidentRecordV2Issuer, +} from '@fides/incidents' ``` +## Status + +This package provides signed incident protocol objects and resolution helpers. +It does not operate a federation or moderation network by itself; propagation +and registry integration remain daemon or adapter responsibilities. + ## License MIT diff --git a/packages/invocation/README.md b/packages/invocation/README.md index 1f6645a..e6f163e 100644 --- a/packages/invocation/README.md +++ b/packages/invocation/README.md @@ -2,10 +2,33 @@ Capability invocation entrypoints for FIDES v2 policy-before-execution flows. +Invocation is the post-discovery authority path. Callers should verify the +session grant, requester signature, schema compatibility, revocation state, kill +switch state, and policy decision before executing capability logic. + +## Installation + +```bash +npm install @fides/invocation +``` + +## Usage + ```typescript -import { createInvocationRequest, verifySignedInvocationResult } from '@fides/invocation' +import { + createInvocationRequest, + evaluateInvocationPreflight, + signInvocationRequest, + verifySignedInvocationRequestIssuer, +} from '@fides/invocation' ``` +## Status + +The package exposes protocol and validation helpers. It does not run untrusted +agent code or provide a sandbox; execution hosts must supply their own runtime +isolation and use FIDES decisions as authority inputs. + ## License MIT diff --git a/packages/registry/README.md b/packages/registry/README.md index 83d9b43..beb34a8 100644 --- a/packages/registry/README.md +++ b/packages/registry/README.md @@ -2,10 +2,34 @@ Registry and federation record entrypoints for FIDES v2. +Registries publish signed index records and peering records. They help agents +find candidates, but they are not an authority source: consumers still verify +AgentCards, evaluate trust, check revocations and incidents, then run policy +before any session grant or invocation. + +## Installation + +```bash +npm install @fides/registry +``` + +## Usage + ```typescript -import { createRegistryIndexRecord, createRegistryPeerRecord } from '@fides/registry' +import { + createRegistryIndexRecord, + createRegistryPeerRecord, + signRegistryIndexRecord, + verifySignedRegistryIndexRecord, +} from '@fides/registry' ``` +## Status + +The current package exposes framework-agnostic protocol record helpers. Hosted, +public, private, and federated registry network implementations are still local +mock or adapter-ready surfaces in this v2 snapshot. + ## License MIT diff --git a/packages/relay/README.md b/packages/relay/README.md index b430f3a..c6b56c8 100644 --- a/packages/relay/README.md +++ b/packages/relay/README.md @@ -5,10 +5,25 @@ Relay discovery entrypoints for NAT-hidden FIDES v2 agents. Relay discovery supplies presence, rendezvous, endpoint hints, and signed AgentCard references. It does not decide trust or grant authority. +## Installation + +```bash +npm install @fides/relay +``` + +## Usage + ```typescript import { RelayDiscoveryProvider } from '@fides/relay' ``` +## Status + +The exported provider is a local discovery facade over `@fides/discovery`. +Production relay servers should treat relay data as candidate metadata only: +consumers must still verify AgentCards, evaluate trust and policy, and issue a +scoped session grant before invocation. + ## License MIT diff --git a/packages/reputation/README.md b/packages/reputation/README.md index a1544bd..eeb835b 100644 --- a/packages/reputation/README.md +++ b/packages/reputation/README.md @@ -5,10 +5,27 @@ Capability-specific reputation entrypoints for FIDES v2. Reputation is scoped to capability and context. There is no global popularity score. +## Installation + +```bash +npm install @fides/reputation +``` + +## Usage + ```typescript -import { computeCapabilityReputation } from '@fides/reputation' +import { + computeCapabilityReputation, + createReputationRecord, +} from '@fides/reputation' ``` +## Status + +`@fides/reputation` exposes typed scoring helpers for capability-specific, +principal-aware, publisher-weighted signals. Reputation remains an input to +trust and policy; it does not grant permission to invoke an agent. + ## License MIT diff --git a/packages/revocation/README.md b/packages/revocation/README.md index 0f7d3aa..c61c4b5 100644 --- a/packages/revocation/README.md +++ b/packages/revocation/README.md @@ -3,10 +3,33 @@ Revocation record entrypoints for FIDES v2 identities, keys, AgentCards, capabilities, sessions, attestations, and publishers. +Revocation records disable compromised or withdrawn authority surfaces. +Revocation must be checked before trust, policy, session issuance, and +invocation. + +## Installation + +```bash +npm install @fides/revocation +``` + +## Usage + ```typescript -import { createRevocationRecordV2, verifySignedRevocationRecordV2 } from '@fides/revocation' +import { + createRevocationRecordV2, + isRevocationValid, + signRevocationRecordV2, + verifySignedRevocationRecordV2Issuer, +} from '@fides/revocation' ``` +## Status + +This package provides signed revocation primitives and validation helpers. +Network propagation, registry fan-out, and incident-driven automated response +belong in daemon, registry, or federation adapters. + ## License MIT diff --git a/packages/runtime-effect/README.md b/packages/runtime-effect/README.md index 76e3c65..100da11 100644 --- a/packages/runtime-effect/README.md +++ b/packages/runtime-effect/README.md @@ -7,10 +7,25 @@ workflow descriptors and Promise-based runners so Effect services, layers, and typed-error workflows can be added around the same framework-agnostic protocol objects. +## Installation + +```bash +npm install @fides/runtime-effect +``` + +## Usage + ```typescript import { createRuntimeWorkflow, runRuntimeWorkflow } from '@fides/runtime-effect' ``` +## Status + +The current package provides typed workflow boundaries and a Promise-based +runner interface. It does not make AgentCards, EvidenceEvents, SessionGrants, +or public schemas Effect-specific; optional Effect-native APIs can be layered +on later without changing protocol objects. + ## License MIT diff --git a/packages/shared/README.md b/packages/shared/README.md index a9b5342..83f7e06 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -1,8 +1,33 @@ # @fides/shared -Shared types, constants, and error classes for the FIDES trust protocol. +Shared protocol types, constants, security helpers, service-auth utilities, +metrics helpers, and stable error classes for the FIDES trust protocol. -Used internally by `@fides/sdk` and FIDES services. +This package is intentionally small. It contains cross-package infrastructure +that is not specific to canonical protocol objects, signing, discovery, +policy, or the daemon API. + +## Installation + +```bash +npm install @fides/shared +``` + +## Usage + +```typescript +import { + FIDES_PROTOCOL_VERSION, + FidesError, + createErrorEnvelope, +} from '@fides/shared' +``` + +## Status + +`@fides/shared` is a low-level support package for FIDES services and SDKs. New +protocol primitives should normally live in `@fides/core` or a focused facade +package instead of being added here. ## License diff --git a/packages/trust/README.md b/packages/trust/README.md index 5d9f214..3f240fa 100644 --- a/packages/trust/README.md +++ b/packages/trust/README.md @@ -2,12 +2,36 @@ Capability-specific trust scoring entrypoints for FIDES v2. -Trust is a signal. Policy is the authority. +Trust is a signal. Policy is the authority. This package computes explainable +trust results for a specific agent, capability, principal context, and runtime +state. Scores should be consumed by policy evaluators and user interfaces, not +treated as permission grants. + +## Installation + +```bash +npm install @fides/trust +``` + +## Usage ```typescript -import { computeTrustResult } from '@fides/trust' +import { computeTrustResult, trustBandForScore } from '@fides/trust' + +const trust = computeTrustResult({ + agentId: 'did:fides:agent', + capability: 'invoice.reconcile', +}) + +console.log(trust.band, trust.reasons) ``` +## Status + +`@fides/trust` is a typed facade over core trust primitives. The model is +capability-specific and explainable; production deployments should tune weights +and evidence sources for their risk domain. + ## License MIT diff --git a/scripts/check-package-hygiene.mjs b/scripts/check-package-hygiene.mjs index 6bf9a2e..eeda565 100644 --- a/scripts/check-package-hygiene.mjs +++ b/scripts/check-package-hygiene.mjs @@ -6,6 +6,7 @@ import { publicPackageDirs, publicPackageJsonPaths } from './public-packages.mjs const root = dirname(dirname(fileURLToPath(import.meta.url))) const requiredFileEntries = new Set(['README.md', 'LICENSE']) +const minimumReadmeBytes = 500 const errors = [] const configuredPublicPackageDirs = new Set(publicPackageDirs) const discoveredPublicPackageDirs = readdirSync(join(root, 'packages'), { withFileTypes: true }) @@ -60,6 +61,14 @@ for (const packagePath of publicPackageJsonPaths) { errors.push(`${label} references missing ${relative(root, join(packageDir, entry))}`) } } + + const readmePath = join(packageDir, 'README.md') + if (existsSync(readmePath)) { + const readme = readFileSync(readmePath, 'utf8') + if (Buffer.byteLength(readme, 'utf8') < minimumReadmeBytes) { + errors.push(`${label} README.md must be at least ${minimumReadmeBytes} bytes`) + } + } } if (errors.length > 0) { From 6aa837a784e60b943486e0f57af9c541e7926ed4 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:30:18 +0300 Subject: [PATCH 277/282] test(cli): audit phase 22 command matrix --- docs/status/fides-v2-implementation-status.md | 4 ++ scripts/audit-cli-surface.mjs | 49 ++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 7851134..52c22c9 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -118,6 +118,10 @@ Last verified locally: 2026-05-30. - CLI tests cover canonical signed delegation-token submission to `/v1/sessions`. - CLI tests cover canonical signed invocation request submission and issuer proof verification. +- `pnpm cli:audit` now checks 57 command/help surfaces across the Phase 22 CLI + matrix, including identity, attestations, cards, discovery providers, policy, + sessions, invocation, approvals, evidence, revocation, incidents, kill switch, + daemon, demo, and adversarial simulation commands. - `pnpm smoke:agentd` starts an isolated local daemon and exercises root `pnpm agentd` CLI flows for demo, signed invocation, canonical signed delegation-token session creation, all-provider discovery, and adversarial diff --git a/scripts/audit-cli-surface.mjs b/scripts/audit-cli-surface.mjs index 51fb3f7..6d62c39 100644 --- a/scripts/audit-cli-surface.mjs +++ b/scripts/audit-cli-surface.mjs @@ -36,26 +36,61 @@ const checks = [ ], }, { args: ['identity', '--help'], contains: ['create', 'list', 'show'] }, - { args: ['identity', 'create', '--help'], contains: ['--type ', '--agentd-url '] }, - { args: ['attest', '--help'], contains: ['github', 'email', 'domain', 'package', 'wallet', 'runtime'] }, + { args: ['identity', 'create', '--help'], contains: ['--type ', 'agent, publisher, or principal', '--agentd-url '] }, + { args: ['identity', 'list', '--help'], contains: ['--agentd-url '] }, + { args: ['identity', 'show', '--help'], contains: ['', '--agentd-url '] }, + { args: ['attest', '--help'], contains: ['github', 'email', 'domain', 'package', 'wallet', 'runtime', 'show', 'verify'] }, + { args: ['attest', 'github', '--help'], contains: ['--identity ', '--handle '] }, + { args: ['attest', 'email', '--help'], contains: ['--identity ', '--email '] }, + { args: ['attest', 'domain', '--help'], contains: ['--identity ', '--domain '] }, + { args: ['attest', 'package', '--help'], contains: ['--identity ', '--registry ', '--package '] }, + { args: ['attest', 'wallet', '--help'], contains: ['--identity ', '--address
'] }, + { args: ['attest', 'runtime', '--help'], contains: ['--agent ', '--code-hash '] }, { args: ['card', '--help'], contains: ['create', 'sign', 'verify', 'inspect'] }, + { args: ['card', 'create', '--help'], contains: ['--did ', '--name ', '--capabilities '] }, + { args: ['card', 'sign', '--help'], contains: ['', '--agentd-url '] }, + { args: ['card', 'verify', '--help'], contains: [''] }, + { args: ['card', 'inspect', '--help'], contains: ['', '--agentd-url '] }, { args: ['discover', '--help'], contains: ['--capability ', '--provider ', '--all-providers'] }, - { args: ['registry', '--help'], contains: ['start', 'publish', 'search'] }, - { args: ['relay', '--help'], contains: ['start', 'register', 'discover'] }, + { args: ['registry', '--help'], contains: ['start', 'publish', 'search', 'index'] }, + { args: ['registry', 'publish', '--help'], contains: ['', '--agentd-url '] }, + { args: ['registry', 'search', '--help'], contains: ['--capability '] }, + { args: ['relay', '--help'], contains: ['start', 'register', 'discover', 'send', 'poll', 'status', 'delete', 'stats'] }, + { args: ['relay', 'register', '--help'], contains: ['', '--agentd-url '] }, + { args: ['relay', 'discover', '--help'], contains: ['--capability '] }, { args: ['dht', '--help'], contains: ['start', 'publish', 'find'] }, + { args: ['dht', 'publish', '--help'], contains: ['[agent-card]', '--capability '] }, + { args: ['dht', 'find', '--help'], contains: ['--capability '] }, { args: ['trust', '--help'], contains: ['--capability '] }, { args: ['reputation', '--help'], contains: ['update', 'get', '--capability '] }, { args: ['graph', '--help'], contains: ['inspect'] }, { args: ['policy', '--help'], contains: ['evaluate'] }, - { args: ['session', '--help'], contains: ['request', 'show', 'verify'] }, - { args: ['invoke', '--help'], contains: ['--capability ', '--input ', '--dry-run', '--sign'] }, + { args: ['policy', 'evaluate', '--help'], contains: ['--agent ', '--capability ', '--requested-scopes '] }, + { args: ['session', '--help'], contains: ['request', 'show', 'verify', 'create', 'revoke'] }, + { args: ['session', 'request', '--help'], contains: ['', '--capability ', '--requested-scopes '] }, + { args: ['session', 'verify', '--help'], contains: ['', '--agentd-url '] }, + { args: ['session', 'create', '--help'], contains: ['--token-file ', '--token-json ', '--delegator-public-key '] }, + { args: ['invoke', '--help'], contains: ['--capability ', '--input ', '--dry-run', '--sign', '--requester-private-key-file '] }, { args: ['approval', '--help'], contains: ['request', 'list', 'approve', 'deny'] }, + { args: ['approval', 'approve', '--help'], contains: ['', '--approver '] }, + { args: ['approval', 'deny', '--help'], contains: ['', '--approver '] }, { args: ['evidence', '--help'], contains: ['list', 'inspect', 'verify', 'export'] }, - { args: ['revoke', '--help'], contains: ['agent', 'key', 'identity', 'card', 'capability', 'session', 'attestation', 'publisher'] }, + { args: ['evidence', 'inspect', '--help'], contains: ['', '--agentd-url '] }, + { args: ['evidence', 'export', '--help'], contains: ['--privacy-mode ', '--agentd-url '] }, + { args: ['revoke', '--help'], contains: ['agent', 'key', 'identity', 'card', 'capability', 'session', 'attestation', 'publisher', 'list', 'inspect'] }, + { args: ['revoke', 'agent', '--help'], contains: ['', '--reason '] }, + { args: ['revoke', 'session', '--help'], contains: ['', '--reason '] }, { args: ['incident', '--help'], contains: ['report', 'list', 'inspect', 'resolve'] }, + { args: ['incident', 'report', '--help'], contains: ['[agent-id]', '--severity ', '--category '] }, + { args: ['incident', 'resolve', '--help'], contains: ['', '--status '] }, { args: ['killswitch', '--help'], contains: ['enable', 'list', 'disable'] }, + { args: ['killswitch', 'enable', '--help'], contains: ['--agent ', '--capability '] }, + { args: ['killswitch', 'disable', '--help'], contains: [''] }, + { args: ['daemon', '--help'], contains: ['start', 'status', 'stop'] }, { args: ['demo', '--help'], contains: ['run'] }, + { args: ['demo', 'run', '--help'], contains: ['--agentd-url '] }, { args: ['simulate', '--help'], contains: ['adversarial'] }, + { args: ['simulate', 'adversarial', '--help'], contains: ['--agentd-url '] }, ] const errors = [] From 9233602dabcce52c106f2d155833b5924a30669f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:32:47 +0300 Subject: [PATCH 278/282] test(cli): audit delegation command surface --- docs/status/fides-v2-implementation-status.md | 4 ++-- scripts/audit-cli-surface.mjs | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 52c22c9..efd7c7a 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -118,10 +118,10 @@ Last verified locally: 2026-05-30. - CLI tests cover canonical signed delegation-token submission to `/v1/sessions`. - CLI tests cover canonical signed invocation request submission and issuer proof verification. -- `pnpm cli:audit` now checks 57 command/help surfaces across the Phase 22 CLI +- `pnpm cli:audit` now checks 59 command/help surfaces across the Phase 22 CLI matrix, including identity, attestations, cards, discovery providers, policy, sessions, invocation, approvals, evidence, revocation, incidents, kill switch, - daemon, demo, and adversarial simulation commands. + delegation, daemon, demo, and adversarial simulation commands. - `pnpm smoke:agentd` starts an isolated local daemon and exercises root `pnpm agentd` CLI flows for demo, signed invocation, canonical signed delegation-token session creation, all-provider discovery, and adversarial diff --git a/scripts/audit-cli-surface.mjs b/scripts/audit-cli-surface.mjs index 6d62c39..920952f 100644 --- a/scripts/audit-cli-surface.mjs +++ b/scripts/audit-cli-surface.mjs @@ -30,6 +30,7 @@ const checks = [ 'registry', 'relay', 'dht', + 'delegate', 'demo', 'simulate', 'daemon', @@ -70,6 +71,8 @@ const checks = [ { args: ['session', 'request', '--help'], contains: ['', '--capability ', '--requested-scopes '] }, { args: ['session', 'verify', '--help'], contains: ['', '--agentd-url '] }, { args: ['session', 'create', '--help'], contains: ['--token-file ', '--token-json ', '--delegator-public-key '] }, + { args: ['delegate', '--help'], contains: ['create'] }, + { args: ['delegate', 'create', '--help'], contains: ['--delegator ', '--delegatee ', '--capabilities ', '--agentd-url '] }, { args: ['invoke', '--help'], contains: ['--capability ', '--input ', '--dry-run', '--sign', '--requester-private-key-file '] }, { args: ['approval', '--help'], contains: ['request', 'list', 'approve', 'deny'] }, { args: ['approval', 'approve', '--help'], contains: ['', '--approver '] }, From 9421fdf31017bc6cf2f75cf5518a4678dcab08b8 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:39:47 +0300 Subject: [PATCH 279/282] test(docs): audit fides v2 documentation contract --- docs/status/fides-v2-implementation-status.md | 4 + package.json | 3 +- scripts/audit-fides-v2-docs.mjs | 224 ++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 scripts/audit-fides-v2-docs.mjs diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index efd7c7a..6918573 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -110,6 +110,10 @@ Last verified locally: 2026-05-30. - Public package README quality is now enforced by `pnpm package:hygiene` so publishable facade packages include installation, usage, status, and boundary guidance instead of placeholder package pages. +- `pnpm docs:audit` verifies that the required inspection, primitive map, + architecture, protocol, ADR, threat-model, getting-started, API, CLI, SDK, + and adversarial simulation docs exist and preserve the hard FIDES v2 + constraints. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. - Core tests for issuer-bound `DelegationTokenV2` canonical signatures and payload-hash tamper detection. diff --git a/package.json b/package.json index 0da2c21..32d816d 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,11 @@ "examples:audit": "node scripts/audit-examples.mjs", "api:audit": "node scripts/audit-agentd-api.mjs", "cli:audit": "node scripts/audit-cli-surface.mjs", + "docs:audit": "node scripts/audit-fides-v2-docs.mjs", "package:hygiene": "node scripts/check-package-hygiene.mjs", "package:packcheck": "node scripts/check-public-package-packs.mjs", "rust-adapter:audit": "node scripts/audit-rust-adapter-readiness.mjs", - "verify": "pnpm package:hygiene && pnpm api:audit && pnpm cli:audit && pnpm examples:audit && pnpm rust-adapter:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", + "verify": "pnpm package:hygiene && pnpm api:audit && pnpm cli:audit && pnpm docs:audit && pnpm examples:audit && pnpm rust-adapter:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", "verify:quick": "pnpm lint && pnpm test", "ci:local": "pnpm verify", "dev": "turbo run dev --parallel", diff --git a/scripts/audit-fides-v2-docs.mjs b/scripts/audit-fides-v2-docs.mjs new file mode 100644 index 0000000..fa21480 --- /dev/null +++ b/scripts/audit-fides-v2-docs.mjs @@ -0,0 +1,224 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const errors = [] + +const requiredDocs = [ + 'docs/inspection/fides-report.md', + 'docs/inspection/agit-report.md', + 'docs/inspection/osp-report.md', + 'docs/inspection/oaps-report.md', + 'docs/inspection/sardis-report.md', + 'docs/inspection/cross-repo-primitive-map.md', + 'docs/architecture/fides-v2-agent-trust-fabric.md', + 'docs/architecture/gap-analysis.md', + 'docs/architecture/implementation-plan.md', + 'docs/architecture/implementation-agent-prompt.md', + 'docs/protocol/canonical-object-signing.md', + 'docs/protocol/version-negotiation.md', + 'docs/protocol/error-vocabulary.md', + 'docs/protocol/privacy-model.md', + 'docs/protocol/identity-model.md', + 'docs/protocol/agent-card.md', + 'docs/protocol/capability-ontology.md', + 'docs/protocol/discovery.md', + 'docs/protocol/dht-discovery.md', + 'docs/protocol/relay-discovery.md', + 'docs/protocol/registry-federation.md', + 'docs/protocol/trust-model.md', + 'docs/protocol/reputation-model.md', + 'docs/protocol/policy-engine.md', + 'docs/protocol/delegation-and-sessions.md', + 'docs/protocol/evidence-ledger.md', + 'docs/protocol/runtime-attestation.md', + 'docs/protocol/revocation.md', + 'docs/protocol/incidents.md', + 'docs/protocol/approvals.md', + 'docs/protocol/kill-switch.md', + 'docs/protocol/interop-adapters.md', + 'docs/threat-model.md', + 'docs/getting-started.md', + 'docs/api-reference.md', + 'docs/cli-reference.md', + 'docs/sdk-reference.md', + 'docs/adversarial-simulation.md', + 'docs/adr/use-effect-internally.md', + 'docs/adr/ts-first-rust-adapter-ready.md', + 'docs/adr/oaps-concepts-ported.md', + 'docs/adr/sardis-patterns-only.md', +] + +for (const docPath of requiredDocs) { + const absolutePath = join(root, docPath) + if (!existsSync(absolutePath)) { + errors.push(`required FIDES v2 doc is missing: ${docPath}`) + continue + } + if (readFileSync(absolutePath, 'utf8').trim().length < 120) { + errors.push(`required FIDES v2 doc is too small to be useful: ${docPath}`) + } +} + +const inspectionReports = [ + 'docs/inspection/fides-report.md', + 'docs/inspection/agit-report.md', + 'docs/inspection/osp-report.md', + 'docs/inspection/oaps-report.md', + 'docs/inspection/sardis-report.md', +] + +for (const reportPath of inspectionReports) { + if (!existsSync(join(root, reportPath))) continue + requireMatches(reportPath, [ + /^##\s+\d+\.\s+Repo Purpose/m, + /^##\s+\d+\.\s+Main Packages\s*\/\s*Modules/m, + /^##\s+\d+\.\s+Existing .*Primitives/m, + /^##\s+\d+\.\s+(Relevant Files|Payment-Specific Items To Keep Separate)/m, + /^##\s+\d+\.\s+Reusable Components/m, + /^##\s+\d+\.\s+Missing Components/m, + /^##\s+\d+\.\s+Conflicts With FIDES v2 Architecture/m, + /^##\s+\d+\.\s+Recommended Action/m, + ]) +} + +requireContains('docs/inspection/cross-repo-primitive-map.md', [ + '| Primitive | FIDES | AGIT | OSP | OAPS | Sardis | Best source | Action |', + 'Agent identity', + 'Publisher identity', + 'Principal identity', + 'Canonical object signing', + 'DelegationToken', + 'SessionGrant', + 'EvidenceEvent', + 'Runtime attestation', + 'MCP adapter', + 'Sardis adapter', +]) + +requireContains('docs/architecture/fides-v2-agent-trust-fabric.md', [ + '### 1. Identity Layer', + '### 2. Attestation Layer', + '### 3. Agent Metadata Layer', + '### 4. Discovery Layer', + '### 5. Trust Layer', + '### 6. Reputation Layer', + '### 7. Policy Layer', + '### 8. Delegation Layer', + '### 9. Invocation Layer', + '### 10. Evidence Layer', + '### 11. Revocation Layer', + '### 12. Incident Layer', + '### 13. Registry Layer', + '### 14. Transport Layer', + '### 15. Runtime Layer', + '### 16. Developer Layer', + '### 17. Interop Layer', + 'FIDES v2 is TS-first and Rust adapter-ready', + 'FIDES must not depend on `@oaps/core` as a runtime dependency', + 'Payment-specific domain stays in Sardis', + 'Protocol objects, schemas, crypto, canonical JSON, signing, AgentCards, EvidenceEvents, DHT records, SessionGrants, attestations, revocations, and incidents remain framework-agnostic', + 'Public SDK APIs are Promise-based', +]) + +requireContains('docs/getting-started.md', [ + 'Discovery is not authority', + 'Identity is not trust', + 'Trust is not permission', + 'Policy and scoped session grants are the authority path', +]) + +requireContains('docs/protocol/privacy-model.md', [ + 'hash_only', + 'redacted', +]) + +requireContains('docs/protocol/canonical-object-signing.md', [ + 'canonical', + 'payload_hash', + 'signature', +]) + +requireContains('docs/adr/oaps-concepts-ported.md', [ + 'FIDES does not depend on `@oaps/core` at runtime', +]) + +requireContains('docs/adr/sardis-patterns-only.md', [ + 'Payment-specific domain remains in Sardis', +]) + +requireContains('docs/adr/use-effect-internally.md', [ + 'Protocol objects and public SDK APIs remain framework-agnostic', + 'Promise-based', +]) + +requireNoRuntimeDependency('@oaps/core') + +if (errors.length > 0) { + console.error('FIDES v2 docs audit failed:') + for (const error of errors) { + console.error(`- ${error}`) + } + process.exit(1) +} + +console.log(`FIDES v2 docs audit passed for ${requiredDocs.length} required docs.`) + +function requireContains(filePath, expectedSnippets) { + const absolutePath = join(root, filePath) + if (!existsSync(absolutePath)) return + + const contents = readFileSync(absolutePath, 'utf8') + for (const snippet of expectedSnippets) { + if (!contents.includes(snippet)) { + errors.push(`${filePath} is missing required text: ${snippet}`) + } + } +} + +function requireMatches(filePath, expectedPatterns) { + const absolutePath = join(root, filePath) + if (!existsSync(absolutePath)) return + + const contents = readFileSync(absolutePath, 'utf8') + for (const pattern of expectedPatterns) { + if (!pattern.test(contents)) { + errors.push(`${filePath} is missing required pattern: ${pattern}`) + } + } +} + +function requireNoRuntimeDependency(packageName) { + for (const packageJsonPath of findFiles(root, 'package.json')) { + const relativePath = relative(root, packageJsonPath) + if (relativePath.includes('node_modules')) continue + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + const runtimeDependencyMaps = [ + ['dependencies', pkg.dependencies], + ['peerDependencies', pkg.peerDependencies], + ['optionalDependencies', pkg.optionalDependencies], + ] + for (const [field, dependencies] of runtimeDependencyMaps) { + if (dependencies?.[packageName]) { + errors.push(`${relativePath} must not declare runtime dependency ${field}.${packageName}`) + } + } + } +} + +function findFiles(dir, fileName) { + const entries = readdirSync(dir) + const files = [] + for (const entry of entries) { + if (entry === 'node_modules' || entry === '.git' || entry === 'dist' || entry === '.turbo') continue + const absolutePath = join(dir, entry) + const stats = statSync(absolutePath) + if (stats.isDirectory()) { + files.push(...findFiles(absolutePath, fileName)) + } else if (stats.isFile() && entry === fileName) { + files.push(absolutePath) + } + } + return files +} From 4522ae3e689b5e95520b155cd6b2184e1f18cb33 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:48:25 +0300 Subject: [PATCH 280/282] feat(attestations): add generic attestation object --- docs/status/fides-v2-implementation-status.md | 2 + package.json | 3 +- packages/core/src/runtime-attestation.ts | 84 +++++ .../core/test/runtime-attestation.test.ts | 39 +++ scripts/audit-protocol-objects.mjs | 295 ++++++++++++++++++ 5 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 scripts/audit-protocol-objects.mjs diff --git a/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md index 6918573..5d9475b 100644 --- a/docs/status/fides-v2-implementation-status.md +++ b/docs/status/fides-v2-implementation-status.md @@ -114,6 +114,8 @@ Last verified locally: 2026-05-30. architecture, protocol, ADR, threat-model, getting-started, API, CLI, SDK, and adversarial simulation docs exist and preserve the hard FIDES v2 constraints. +- `pnpm protocol:audit` verifies source, docs, tests, and core barrel coverage + for all 30 required FIDES v2 protocol objects. - OpenAPI contract coverage for version-bound `SessionGrantV2` responses. - Core tests for issuer-bound `DelegationTokenV2` canonical signatures and payload-hash tamper detection. diff --git a/package.json b/package.json index 32d816d..6d4b2e2 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,11 @@ "api:audit": "node scripts/audit-agentd-api.mjs", "cli:audit": "node scripts/audit-cli-surface.mjs", "docs:audit": "node scripts/audit-fides-v2-docs.mjs", + "protocol:audit": "node scripts/audit-protocol-objects.mjs", "package:hygiene": "node scripts/check-package-hygiene.mjs", "package:packcheck": "node scripts/check-public-package-packs.mjs", "rust-adapter:audit": "node scripts/audit-rust-adapter-readiness.mjs", - "verify": "pnpm package:hygiene && pnpm api:audit && pnpm cli:audit && pnpm docs:audit && pnpm examples:audit && pnpm rust-adapter:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", + "verify": "pnpm package:hygiene && pnpm api:audit && pnpm cli:audit && pnpm docs:audit && pnpm protocol:audit && pnpm examples:audit && pnpm rust-adapter:audit && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm examples:typecheck && pnpm test", "verify:quick": "pnpm lint && pnpm test", "ci:local": "pnpm verify", "dev": "turbo run dev --parallel", diff --git a/packages/core/src/runtime-attestation.ts b/packages/core/src/runtime-attestation.ts index 0c489c2..0f0ea76 100644 --- a/packages/core/src/runtime-attestation.ts +++ b/packages/core/src/runtime-attestation.ts @@ -1,5 +1,47 @@ +import { signObject, verifyObject, type SignedObject } from './canonical-signer.js' import { hashProtocolPayload } from './protocol.js' +export type AttestationSubjectType = + | 'agent' + | 'publisher' + | 'principal' + | 'domain' + | 'package' + | 'wallet' + | 'passkey' + | 'runtime' + | 'build' + | 'peer' + +export interface Attestation { + schema_version: 'fides.attestation.v1' + id: string + issuer: string + subject: string + subject_type: AttestationSubjectType + provider: string + claims: Record + evidence_refs: string[] + issued_at: string + expires_at?: string + payload_hash: string + signature: string +} + +export type SignedAttestation = SignedObject + +export interface AttestationInput { + issuer: string + subject: string + subjectType: AttestationSubjectType + provider: string + claims?: Record + evidenceRefs?: string[] + issuedAt?: string + expiresAt?: string + signature?: string +} + export interface RuntimeAttestation { schema_version: 'fides.runtime_attestation.v1' id: string @@ -39,6 +81,48 @@ export interface ContainerBuildAttestationProvider extends AttestationProvider { const DEFAULT_ATTESTATION_TTL_MS = 3600_000 +export function createAttestation(input: AttestationInput): Attestation { + const issuedAt = input.issuedAt ?? new Date().toISOString() + const payload = { + schema_version: 'fides.attestation.v1' as const, + id: crypto.randomUUID(), + issuer: input.issuer, + subject: input.subject, + subject_type: input.subjectType, + provider: input.provider, + claims: input.claims ?? {}, + evidence_refs: input.evidenceRefs ?? [], + issued_at: issuedAt, + expires_at: input.expiresAt, + } + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + signature: input.signature ?? '', + } +} + +export function isAttestationExpired(attestation: Attestation, now: Date = new Date()): boolean { + return attestation.expires_at ? new Date(attestation.expires_at) <= now : false +} + +export function signAttestation( + attestation: Attestation, + privateKey: Uint8Array, + verificationMethod: string +): Promise { + return signObject(attestation, privateKey, { verificationMethod, proofPurpose: 'assertionMethod' }) +} + +export function verifySignedAttestation(signed: SignedAttestation): Promise { + return verifyObject(signed) +} + +export async function verifySignedAttestationIssuer(signed: SignedAttestation): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedAttestation(signed) +} + export function isRuntimeAttestationExpired(attestation: RuntimeAttestation, now: Date = new Date()): boolean { return new Date(attestation.expires_at) <= now } diff --git a/packages/core/test/runtime-attestation.test.ts b/packages/core/test/runtime-attestation.test.ts index 6c63888..ed0a76f 100644 --- a/packages/core/test/runtime-attestation.test.ts +++ b/packages/core/test/runtime-attestation.test.ts @@ -2,11 +2,50 @@ import { describe, expect, it } from 'vitest' import { MockTEEProvider, NullAttestationProvider, + createAttestation, + signAttestation, + verifySignedAttestation, + verifySignedAttestationIssuer, + isAttestationExpired, isRuntimeAttestationExpired, verifyRuntimeAttestation, } from '../src/runtime-attestation.js' +import { createAgentIdentity } from '../src/identity.js' describe('runtime attestation v2', () => { + it('creates and signs generic attestations with the canonical model', async () => { + const issuer = await createAgentIdentity() + const attestation = createAttestation({ + issuer: issuer.identity.did, + subject: 'did:fides:agent', + subjectType: 'agent', + provider: 'github', + claims: { handle: 'invoice-agent-publisher' }, + evidenceRefs: ['evt_attestation_1'], + }) + + expect(attestation).toMatchObject({ + schema_version: 'fides.attestation.v1', + id: expect.any(String), + issuer: issuer.identity.did, + subject: 'did:fides:agent', + subject_type: 'agent', + provider: 'github', + claims: { handle: 'invoice-agent-publisher' }, + evidence_refs: ['evt_attestation_1'], + signature: '', + }) + expect(attestation.payload_hash).toMatch(/^sha256:/) + expect(isAttestationExpired(attestation)).toBe(false) + + const signed = await signAttestation(attestation, issuer.privateKey, issuer.identity.did) + expect(await verifySignedAttestation(signed)).toBe(true) + expect(await verifySignedAttestationIssuer(signed)).toBe(true) + + signed.payload.claims.handle = 'tampered' + expect(await verifySignedAttestation(signed)).toBe(false) + }) + it('issues and verifies mock TEE attestations with the FIDES v2 schema', async () => { const provider = new MockTEEProvider() const attestation = await provider.issue({ diff --git a/scripts/audit-protocol-objects.mjs b/scripts/audit-protocol-objects.mjs new file mode 100644 index 0000000..6a3d4de --- /dev/null +++ b/scripts/audit-protocol-objects.mjs @@ -0,0 +1,295 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const errors = [] + +const requiredProtocolObjects = [ + { + name: 'AgentIdentity', + source: ['AgentIdentity'], + docs: ['AgentIdentity'], + tests: ['createAgentIdentity', 'AgentIdentity'], + }, + { + name: 'PublisherIdentity', + source: ['PublisherIdentity'], + docs: ['PublisherIdentity'], + tests: ['createPublisherIdentity', 'PublisherIdentity'], + }, + { + name: 'PrincipalIdentity', + source: ['PrincipalIdentity'], + docs: ['PrincipalIdentity'], + tests: ['createPrincipalIdentity', 'PrincipalIdentity'], + }, + { + name: 'TrustAnchor', + source: ['TrustAnchor', 'IdentityTrustAnchor', 'GovernedTrustAnchor'], + docs: ['TrustAnchor', 'trust anchor'], + tests: ['TrustAnchor', 'trust anchor'], + }, + { + name: 'Attestation', + source: ['interface Attestation ', 'createAttestation', 'SignedAttestation'], + docs: ['Attestation', 'attestation'], + tests: ['createAttestation', 'signAttestation'], + }, + { + name: 'RuntimeAttestation', + source: ['RuntimeAttestation'], + docs: ['RuntimeAttestation'], + tests: ['RuntimeAttestation', 'MockTEEProvider', 'verifyRuntimeAttestation'], + }, + { + name: 'AgentCard', + source: ['AgentCard'], + docs: ['AgentCard'], + tests: ['AgentCard', 'signAgentCard', 'verifySignedAgentCard'], + }, + { + name: 'CapabilityDescriptor', + source: ['CapabilityDescriptor'], + docs: ['CapabilityDescriptor'], + tests: ['CapabilityDescriptor', 'createCapabilityDescriptor'], + }, + { + name: 'CapabilityOntologyEntry', + source: ['CapabilityOntologyEntry', 'DEFAULT_CAPABILITY_ONTOLOGY'], + docs: ['CapabilityOntologyEntry', 'ontology'], + tests: ['CapabilityOntologyEntry', 'DEFAULT_CAPABILITY_ONTOLOGY', 'findCapabilityOntologyEntry'], + }, + { + name: 'DiscoveryQuery', + source: ['DiscoveryQuery'], + docs: ['DiscoveryQuery'], + tests: ['DiscoveryQuery', 'createDiscoveryQuery'], + }, + { + name: 'DiscoveryCandidate', + source: ['DiscoveryCandidate'], + docs: ['DiscoveryCandidate'], + tests: ['DiscoveryCandidate', 'createDiscoveryCandidate'], + }, + { + name: 'DHTPointerRecord', + source: ['DHTPointerRecord'], + docs: ['DHTPointerRecord'], + tests: ['DHTPointerRecord', 'createDHTPointerRecord'], + }, + { + name: 'RegistryIndexRecord', + source: ['RegistryIndexRecord'], + docs: ['RegistryIndexRecord'], + tests: ['RegistryIndexRecord', 'createRegistryIndexRecord'], + }, + { + name: 'RegistryPeerRecord', + source: ['RegistryPeerRecord'], + docs: ['RegistryPeerRecord'], + tests: ['RegistryPeerRecord', 'createRegistryPeerRecord'], + }, + { + name: 'TrustResult', + source: ['TrustResult'], + docs: ['TrustResult'], + tests: ['TrustResult', 'computeTrust'], + }, + { + name: 'ReputationRecord', + source: ['ReputationRecord'], + docs: ['ReputationRecord', 'reputation record'], + tests: ['ReputationRecord', 'createReputationRecord'], + }, + { + name: 'PolicyBundle', + source: ['PolicyBundle'], + docs: ['PolicyBundle'], + tests: ['PolicyBundle', 'evaluatePolicy'], + }, + { + name: 'PolicyDecision', + source: ['FidesPolicyDecision', 'PolicyResult'], + docs: ['PolicyDecision', 'policy decision'], + tests: ['FidesPolicyDecision', 'evaluateFidesPolicy', 'PolicyResult'], + }, + { + name: 'ApprovalRequest', + source: ['ApprovalRequest'], + docs: ['ApprovalRequest'], + tests: ['ApprovalRequest', 'createApprovalRequest'], + }, + { + name: 'ApprovalDecision', + source: ['ApprovalDecision'], + docs: ['ApprovalDecision'], + tests: ['ApprovalDecision', 'createApprovalDecision'], + }, + { + name: 'KillSwitchRule', + source: ['KillSwitchRule'], + docs: ['KillSwitchRule'], + tests: ['KillSwitchRule', 'createKillSwitchRule'], + }, + { + name: 'DelegationToken', + source: ['DelegationTokenV2', 'DelegationToken'], + docs: ['DelegationToken'], + tests: ['DelegationTokenV2', 'createDelegationTokenV2', 'createDelegationToken'], + }, + { + name: 'SessionGrant', + source: ['SessionGrantV2', 'SessionGrant'], + docs: ['SessionGrant'], + tests: ['SessionGrantV2', 'createSessionGrantV2', 'createSessionGrant'], + }, + { + name: 'InvocationRequest', + source: ['InvocationRequest'], + docs: ['InvocationRequest'], + tests: ['InvocationRequest', 'createInvocationRequest'], + }, + { + name: 'InvocationResult', + source: ['InvocationResult'], + docs: ['InvocationResult'], + tests: ['InvocationResult', 'createInvocationResult'], + }, + { + name: 'EvidenceEvent', + source: ['EvidenceEventV2', 'EvidenceEvent'], + docs: ['EvidenceEvent'], + tests: ['EvidenceEventV2', 'createEvidenceEventV2', 'appendEvidenceEvent'], + }, + { + name: 'RevocationRecord', + source: ['RevocationRecordV2', 'RevocationRecord'], + docs: ['RevocationRecord'], + tests: ['RevocationRecordV2', 'createRevocationRecordV2', 'createRevocationRecord'], + }, + { + name: 'IncidentRecord', + source: ['IncidentRecordV2', 'IncidentRecord'], + docs: ['IncidentRecord'], + tests: ['IncidentRecordV2', 'createIncidentRecordV2', 'createIncidentRecord'], + }, + { + name: 'ErrorEnvelope', + source: ['ErrorEnvelope'], + docs: ['ErrorEnvelope'], + tests: ['ErrorEnvelope', 'createErrorEnvelope'], + }, + { + name: 'VersionNegotiationRecord', + source: ['VersionNegotiationRecord'], + docs: ['VersionNegotiationRecord'], + tests: ['VersionNegotiationRecord', 'negotiateProtocolVersion'], + }, +] + +const sourceCorpus = readCorpus([ + 'packages/core/src', + 'packages/evidence/src', + 'packages/policy/src', +]) +const docsCorpus = readCorpus([ + 'docs/protocol', + 'docs/architecture', + 'docs/api-reference.md', + 'docs/cli-reference.md', + 'docs/sdk-reference.md', +]) +const testCorpus = readCorpus([ + 'packages/core/test', + 'packages/evidence/test', + 'packages/policy/test', + 'packages/invocation/test', + 'tests', +]) + +for (const object of requiredProtocolObjects) { + requireAny(sourceCorpus, object.source, `${object.name} source`) + requireAny(docsCorpus, object.docs, `${object.name} docs`) + requireAny(testCorpus, object.tests, `${object.name} tests`) +} + +requireContains(readFile('packages/core/src/index.ts'), [ + './identity.js', + './runtime-attestation.js', + './agent-card.js', + './capability.js', + './discovery.js', + './dht.js', + './trust.js', + './reputation.js', + './approval.js', + './delegation.js', + './invocation.js', + './registry.js', + './revocation.js', + './versioning.js', + './errors.js', +]) + +if (errors.length > 0) { + console.error('FIDES v2 protocol object audit failed:') + for (const error of errors) { + console.error(`- ${error}`) + } + process.exit(1) +} + +console.log(`FIDES v2 protocol object audit passed for ${requiredProtocolObjects.length} required objects.`) + +function requireAny(corpus, needles, label) { + if (needles.some(needle => corpus.includes(needle))) return + errors.push(`${label} is missing one of: ${needles.join(', ')}`) +} + +function requireContains(corpus, snippets) { + for (const snippet of snippets) { + if (!corpus.includes(snippet)) { + errors.push(`core package barrel export coverage is missing: ${snippet}`) + } + } +} + +function readCorpus(paths) { + return paths.map(readPath).join('\n') +} + +function readPath(path) { + const absolutePath = join(root, path) + if (!existsSync(absolutePath)) return '' + const stats = statSync(absolutePath) + if (stats.isFile()) return readFileSync(absolutePath, 'utf8') + + const chunks = [] + for (const filePath of findFiles(absolutePath)) { + chunks.push(`\n// ${relative(root, filePath)}\n`) + chunks.push(readFileSync(filePath, 'utf8')) + } + return chunks.join('\n') +} + +function readFile(path) { + const absolutePath = join(root, path) + return existsSync(absolutePath) ? readFileSync(absolutePath, 'utf8') : '' +} + +function findFiles(dir) { + const entries = readdirSync(dir) + const files = [] + for (const entry of entries) { + if (entry === 'node_modules' || entry === '.git' || entry === 'dist' || entry === '.turbo') continue + const absolutePath = join(dir, entry) + const stats = statSync(absolutePath) + if (stats.isDirectory()) { + files.push(...findFiles(absolutePath)) + } else if (stats.isFile() && /\.(ts|md)$/.test(entry)) { + files.push(absolutePath) + } + } + return files +} From 7e34fed1dcdf173b671fea70c4622cf7e00970b7 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:50:14 +0300 Subject: [PATCH 281/282] feat(attestations): expose generic attestation facade --- packages/attestations/README.md | 32 ++++++++++++++++++----- packages/attestations/src/index.ts | 9 +++++++ packages/attestations/test/facade.test.ts | 27 ++++++++++++++++++- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/attestations/README.md b/packages/attestations/README.md index e8b87bb..dfc29d4 100644 --- a/packages/attestations/README.md +++ b/packages/attestations/README.md @@ -1,10 +1,13 @@ # @fides/attestations -Runtime attestation and TEE-ready provider entrypoints for FIDES v2. +Generic attestations, runtime attestations, and TEE-ready provider entrypoints +for FIDES v2. -Runtime attestations bind an agent to code, runtime, policy, and enclave or -build measurements. FIDES uses them as trust and policy inputs, especially for -high-risk capabilities, while keeping protocol objects framework-agnostic. +Generic `Attestation` records express signed claims from a provider or trust +anchor about an agent, publisher, principal, domain, package, wallet, passkey, +runtime, build, or peer signal. Runtime attestations bind an agent to code, +runtime, policy, and enclave or build measurements. FIDES uses both as trust and +policy inputs while keeping protocol objects framework-agnostic. ## Installation @@ -18,15 +21,32 @@ npm install @fides/attestations import { MockTEEProvider, NullAttestationProvider, + createAttestation, createRuntimeAttestation, + signAttestation, + verifySignedAttestationIssuer, verifyRuntimeAttestation, } from '@fides/attestations' + +const attestation = createAttestation({ + issuer: 'did:fides:publisher', + subject: 'did:fides:agent', + subjectType: 'agent', + provider: 'github', + claims: { handle: 'fides-dev' }, + evidenceRefs: ['evt_github_1'], +}) + +const signed = await signAttestation(attestation, privateKey, 'did:fides:publisher') +await verifySignedAttestationIssuer(signed) ``` ## Status -`MockTEEProvider` and `NullAttestationProvider` are local development surfaces. -AWS Nitro, SGX, SEV, container image, and reproducible build providers are +Generic attestations and MockTEE runtime attestations are TypeScript-first local +protocol surfaces. `MockTEEProvider` and `NullAttestationProvider` are local +development surfaces. AWS Nitro, SGX, SEV, container image, reproducible build, +package registry, wallet, passkey, and peer attestation providers are adapter-ready interfaces, not production integrations in this package snapshot. ## License diff --git a/packages/attestations/src/index.ts b/packages/attestations/src/index.ts index ce99b33..283827f 100644 --- a/packages/attestations/src/index.ts +++ b/packages/attestations/src/index.ts @@ -1,12 +1,21 @@ export { MockTEEProvider, NullAttestationProvider, + createAttestation, createRuntimeAttestation, + isAttestationExpired, isRuntimeAttestationExpired, + signAttestation, + verifySignedAttestation, + verifySignedAttestationIssuer, verifyRuntimeAttestation, + type Attestation, + type AttestationInput, type AttestationProvider, + type AttestationSubjectType, type ContainerBuildAttestationProvider, type RuntimeAttestation, type RuntimeAttestationIssueInput, + type SignedAttestation, type TeeAttestationProvider, } from '@fides/core' diff --git a/packages/attestations/test/facade.test.ts b/packages/attestations/test/facade.test.ts index 1dea410..2178907 100644 --- a/packages/attestations/test/facade.test.ts +++ b/packages/attestations/test/facade.test.ts @@ -1,7 +1,32 @@ import { describe, expect, it } from 'vitest' -import { MockTEEProvider, verifyRuntimeAttestation } from '../src/index.js' +import { + MockTEEProvider, + createAttestation, + signAttestation, + verifyRuntimeAttestation, + verifySignedAttestationIssuer, +} from '../src/index.js' +import { createAgentIdentity } from '@fides/core' describe('@fides/attestations facade', () => { + it('exports generic canonical Attestation primitives', async () => { + const issuer = await createAgentIdentity() + const attestation = createAttestation({ + issuer: issuer.identity.did, + subject: 'did:fides:agent', + subjectType: 'agent', + provider: 'github', + claims: { handle: 'fides-dev' }, + evidenceRefs: ['evt_github_1'], + }) + + const signed = await signAttestation(attestation, issuer.privateKey, issuer.identity.did) + + expect(attestation.schema_version).toBe('fides.attestation.v1') + expect(attestation.payload_hash).toMatch(/^sha256:/) + expect(await verifySignedAttestationIssuer(signed)).toBe(true) + }) + it('exports MockTEE issue and verify primitives', async () => { const provider = new MockTEEProvider() const hash = (value: string) => `sha256:${value.repeat(64).slice(0, 64)}` From 1199fa313cb8bb549c55f3d483bcecaf43cae1b2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 30 May 2026 23:57:44 +0300 Subject: [PATCH 282/282] feat(agentd): add generic attestation api --- docs/api-reference.md | 8 +- docs/api/agentd.yaml | 101 +++++++++++++++-- packages/sdk/README.md | 17 ++- packages/sdk/src/fides-client.ts | 42 ++++++- packages/sdk/test/fides-client.test.ts | 57 ++++++++++ services/agentd/src/index.ts | 146 +++++++++++++++++++++++++ services/agentd/src/storage.ts | 3 + services/agentd/test/routes.test.ts | 59 ++++++++++ 8 files changed, 411 insertions(+), 22 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index a5637e4..ae8a5cc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -269,14 +269,16 @@ resolved with `POST /incidents/:id/resolve`. Revocation and incident creation append `revocation.recorded` and `incident.reported` evidence events and return `evidenceRefs`. -`POST /attestations` accepts two local attestation shapes. When the body +`POST /attestations` accepts three local attestation shapes. When the body contains `identity`, it adds a local mock identity trust anchor for `github`, `email`, `domain`, `package` (`npm` or `pypi`), or `wallet` and appends `attestation.issued` evidence without granting authority. When the body +contains generic `issuer`, `subject`, `subjectType`, and `provider` fields, it +creates a local `fides.attestation.v1` record with hash-only evidence. When the body contains `agentId` plus `codeHash`, `runtimeHash`, and `policyHash`, it issues a local FIDES v2 runtime attestation through the MockTEE provider. -`POST /attestations/:id/verify` verifies runtime provider, expiry, and hash -shape. Root `POST /sessions` can consume a valid `attestationId` as runtime +`POST /attestations/:id/verify` verifies generic attestation payload hashes and +runtime provider, expiry, and hash shape. Root `POST /sessions` can consume a valid `attestationId` as runtime attestation evidence for high-risk capability policy. Attestation issuance and verification append `attestation.issued`, `attestation.verified`, or `attestation.failed` evidence events and return `evidenceRefs`. diff --git a/docs/api/agentd.yaml b/docs/api/agentd.yaml index 307c927..85377a0 100644 --- a/docs/api/agentd.yaml +++ b/docs/api/agentd.yaml @@ -99,10 +99,12 @@ paths: post: operationId: createLocalAttestation tags: [Attestation] - summary: Issue a local identity or runtime attestation + summary: Issue a local identity, generic, or runtime attestation description: | Bodies with `identity` add local mock identity trust anchors for GitHub, email, domain, package registry, or wallet claims. Bodies with + `issuer`, `subject`, `subjectType`, and `provider` create a generic + `fides.attestation.v1` record. Bodies with `agentId`, `codeHash`, `runtimeHash`, and `policyHash` issue a local MockTEE runtime attestation. Attestation issuance records evidence and does not grant authority by itself. @@ -112,47 +114,52 @@ paths: $ref: "#/components/requestBodies/JsonObject" responses: "201": - description: Local identity trust-anchor attestation or runtime attestation issued + description: Local identity trust-anchor, generic, or runtime attestation issued content: application/json: schema: oneOf: - $ref: "#/components/schemas/LocalIdentityAttestationResponse" + - $ref: "#/components/schemas/LocalGenericAttestationResponse" - $ref: "#/components/schemas/LocalRuntimeAttestationResponse" /attestations/{id}: get: - operationId: getRuntimeAttestation + operationId: getLocalAttestation tags: [Attestation] - summary: Read a local runtime attestation + summary: Read a local generic or runtime attestation parameters: - $ref: "#/components/parameters/IdParam" responses: "200": - description: Local runtime attestation + description: Local generic or runtime attestation content: application/json: schema: - $ref: "#/components/schemas/LocalRuntimeAttestationResponse" + oneOf: + - $ref: "#/components/schemas/LocalGenericAttestationResponse" + - $ref: "#/components/schemas/LocalRuntimeAttestationResponse" "404": $ref: "#/components/responses/Error" /attestations/{id}/verify: post: - operationId: verifyRuntimeAttestation + operationId: verifyLocalAttestation tags: [Attestation] - summary: Verify a local runtime attestation + summary: Verify a local generic or runtime attestation security: - ApiKeyAuth: [] parameters: - $ref: "#/components/parameters/IdParam" responses: "200": - description: Local runtime attestation verification result + description: Local generic or runtime attestation verification result content: application/json: schema: - $ref: "#/components/schemas/LocalRuntimeAttestationVerificationResponse" + oneOf: + - $ref: "#/components/schemas/LocalGenericAttestationVerificationResponse" + - $ref: "#/components/schemas/LocalRuntimeAttestationVerificationResponse" /agent-cards: post: @@ -2208,6 +2215,67 @@ components: type: boolean enum: [false] + GenericAttestationV1: + type: object + required: + - schema_version + - id + - issuer + - subject + - subject_type + - provider + - claims + - evidence_refs + - issued_at + - payload_hash + - signature + properties: + schema_version: + type: string + enum: [fides.attestation.v1] + id: + type: string + issuer: + type: string + subject: + type: string + subject_type: + type: string + enum: [agent, publisher, principal, domain, package, wallet, passkey, runtime, build, peer] + provider: + type: string + claims: + type: object + additionalProperties: true + evidence_refs: + type: array + items: + type: string + issued_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + payload_hash: + type: string + signature: + type: string + + LocalGenericAttestationResponse: + type: object + required: [attestation, authorityGranted] + properties: + attestation: + $ref: "#/components/schemas/GenericAttestationV1" + evidenceRefs: + type: array + items: + type: string + authorityGranted: + type: boolean + enum: [false] + RuntimeAttestationV2: type: object required: @@ -2287,6 +2355,19 @@ components: error: type: string + LocalGenericAttestationVerificationResponse: + allOf: + - $ref: "#/components/schemas/LocalGenericAttestationResponse" + - type: object + required: [id, valid, authorityGranted] + properties: + id: + type: string + valid: + type: boolean + error: + type: string + IdentityResolutionResponse: type: object properties: diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a5eb5c6..f643969 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -178,6 +178,14 @@ const attestation = await client.attestations.runtime({ policyHash: `sha256:${'c'.repeat(64)}`, }) await client.attestations.verify(attestation.attestation.attestation_id) +const githubAttestation = await client.attestations.generic({ + issuer: 'did:fides:publisher', + subject: identity.identity.did, + subjectType: 'agent', + provider: 'github', + claims: { handle: 'fides-dev' }, +}) +await client.attestations.verify(githubAttestation.attestation.id) const session = await client.sessions.request({ principalId: 'did:fides:principal', requesterAgentId: 'did:fides:requester', @@ -240,10 +248,11 @@ helpers return typed `RevocationRecordV2` responses, and active revocations are authority overrides that deny matching trust and policy paths rather than grant new authority. Incident helpers return typed `IncidentRecordV2` responses; open incidents are policy-review inputs that affect trust and session policy until -resolved. Attestation helpers return typed local identity trust-anchor responses -or `RuntimeAttestation` responses. Runtime attestation helpers issue and verify -local MockTEE attestations that can satisfy high-risk session policy when passed -as an `attestationId`. Evidence helpers append hash-only events by default, inspect +resolved. Attestation helpers return typed local identity trust-anchor responses, +generic `Attestation` responses, or `RuntimeAttestation` responses. Generic +attestations record signed local claims without granting authority. Runtime +attestation helpers issue and verify local MockTEE attestations that can satisfy +high-risk session policy when passed as an `attestationId`. Evidence helpers append hash-only events by default, inspect individual events, verify the root hash chain, and export the current local ledger. diff --git a/packages/sdk/src/fides-client.ts b/packages/sdk/src/fides-client.ts index b76f424..ff1a92e 100644 --- a/packages/sdk/src/fides-client.ts +++ b/packages/sdk/src/fides-client.ts @@ -1,5 +1,6 @@ import { type AgentIdentity, + type Attestation, type AgentCard, type ApprovalDecision, type ApprovalRequest, @@ -670,6 +671,18 @@ export interface FidesRuntimeAttestationRequest { expiresAt?: string } +export interface FidesGenericAttestationRequest { + issuer: string + subject: string + subjectType: Attestation['subject_type'] + provider: string + claims?: Record + evidenceRefs?: string[] + issuedAt?: string + expiresAt?: string + signature?: string +} + export interface FidesIdentityAttestation { id: string schema_version: 'fides.identity_attestation.v1' @@ -695,13 +708,29 @@ export interface FidesRuntimeAttestationResponse { [key: string]: unknown } +export interface FidesGenericAttestationResponse { + attestation: Attestation + evidenceRefs?: string[] + authorityGranted?: false + [key: string]: unknown +} + export interface FidesRuntimeAttestationVerificationResponse extends FidesRuntimeAttestationResponse { id: string valid: boolean error?: string } -export type FidesAttestationResponse = FidesIdentityAttestationResponse | FidesRuntimeAttestationResponse +export interface FidesGenericAttestationVerificationResponse extends FidesGenericAttestationResponse { + id: string + valid: boolean + error?: string +} + +export type FidesAttestationResponse = + | FidesIdentityAttestationResponse + | FidesRuntimeAttestationResponse + | FidesGenericAttestationResponse export interface FidesInvocationResponse { authorityGranted: boolean @@ -1065,6 +1094,9 @@ export class FidesClient { create: (body: Record): Promise => ( this.post('/attestations', body) as Promise ), + generic: (body: FidesGenericAttestationRequest): Promise => ( + this.post('/attestations', body) as Promise + ), github: (body: FidesGithubAttestationRequest): Promise => ( this.post('/attestations', { type: 'github', ...body }) as Promise ), @@ -1083,11 +1115,11 @@ export class FidesClient { runtime: (body: FidesRuntimeAttestationRequest): Promise => ( this.post('/attestations', body) as Promise ), - get: (attestationId: string): Promise => ( - this.get(`/attestations/${encodeURIComponent(attestationId)}`) as Promise + get: (attestationId: string): Promise => ( + this.get(`/attestations/${encodeURIComponent(attestationId)}`) as Promise ), - verify: (attestationId: string): Promise => ( - this.post(`/attestations/${encodeURIComponent(attestationId)}/verify`, {}) as Promise + verify: (attestationId: string): Promise => ( + this.post(`/attestations/${encodeURIComponent(attestationId)}/verify`, {}) as Promise ), } diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts index 4ee27ea..f8fcbeb 100644 --- a/packages/sdk/test/fides-client.test.ts +++ b/packages/sdk/test/fides-client.test.ts @@ -996,6 +996,63 @@ describe('FidesClient', () => { expect(verified.authorityGranted).toBe(false) }) + it('types generic attestation issue and verification responses', async () => { + const attestation = { + schema_version: 'fides.attestation.v1', + id: 'att_generic_1', + issuer: 'did:fides:publisher', + subject: 'did:fides:agent', + subject_type: 'agent', + provider: 'github', + claims: { handle: 'fides-dev' }, + evidence_refs: [], + issued_at: '2026-05-30T00:00:00.000Z', + payload_hash: 'sha256:payload', + signature: 'local-attestation:payload', + } + + vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/attestations/att_generic_1/verify')) { + return new Response(JSON.stringify({ + id: 'att_generic_1', + valid: true, + attestation, + evidenceRefs: ['evt_generic_verified'], + authorityGranted: false, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + if (String(url).endsWith('/attestations/att_generic_1') && init?.method === 'GET') { + return new Response(JSON.stringify({ attestation, authorityGranted: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(JSON.stringify({ + attestation, + evidenceRefs: ['evt_generic_issued'], + authorityGranted: false, + }), { status: 201, headers: { 'Content-Type': 'application/json' } }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + const issued = await client.attestations.generic({ + issuer: 'did:fides:publisher', + subject: 'did:fides:agent', + subjectType: 'agent', + provider: 'github', + claims: { handle: 'fides-dev' }, + }) + expect(issued.attestation.schema_version).toBe('fides.attestation.v1') + expect(issued.authorityGranted).toBe(false) + + const fetched = await client.attestations.get('att_generic_1') + expect(fetched.attestation.schema_version).toBe('fides.attestation.v1') + + const verified = await client.attestations.verify('att_generic_1') + expect(verified.valid).toBe(true) + expect(verified.authorityGranted).toBe(false) + }) + it('types root evidence ledger responses as non-authorizing audit records', async () => { const event = { schema_version: 'fides.evidence_event.v1', diff --git a/services/agentd/src/index.ts b/services/agentd/src/index.ts index c88b748..e9a4455 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -31,6 +31,7 @@ import { authorizeDelegation, authorizeDelegationV2, authorizeSessionInvocation, + createAttestation, createAgentIdentity, computeCapabilityReputation, computeTrustResult, @@ -52,6 +53,7 @@ import { createSessionGrantV2, hashAgentCard, hashProtocolPayload, + isAttestationExpired, isKillSwitchRuleActive, MockTEEProvider as CoreMockTEEProvider, negotiateProtocolVersion, @@ -80,6 +82,7 @@ import { validateJsonSchemaValue, verifyIncidentRecord, verifyRevocationRecord, + type Attestation, type AgentIdentity, type AgentCard, type ApprovalDecision, @@ -178,6 +181,7 @@ const localApprovalDecisions = new Map() const localKillSwitchRules = new Map() const localRevocationRecords = new Map() const localIncidentRecords = new Map() +const localGenericAttestations = new Map() const localRuntimeAttestations = new Map() let localEvidenceEvents: EvidenceEventV2[] = [] interface LocalSessionRecord { @@ -300,6 +304,7 @@ function hydrateLocalState(snapshot: LocalDaemonStateSnapshot): void { replaceMap(localKillSwitchRules, mapValues(snapshot.killSwitchRules as KillSwitchRule[])) replaceMap(localRevocationRecords, mapValues(snapshot.revocationRecords as RevocationRecordV2[])) replaceMap(localIncidentRecords, mapValues(snapshot.incidentRecords as IncidentRecordV2[])) + replaceMap(localGenericAttestations, mapValues(snapshot.genericAttestations as Attestation[])) localRuntimeAttestations.clear() for (const attestation of snapshot.runtimeAttestations as RuntimeAttestation[]) { if (attestation?.attestation_id) localRuntimeAttestations.set(attestation.attestation_id, attestation) @@ -334,6 +339,7 @@ function localStateSnapshot(): LocalDaemonStateSnapshot { killSwitchRules: Array.from(localKillSwitchRules.values()), revocationRecords: Array.from(localRevocationRecords.values()), incidentRecords: Array.from(localIncidentRecords.values()), + genericAttestations: Array.from(localGenericAttestations.values()), runtimeAttestations: Array.from(localRuntimeAttestations.values()), evidenceEvents: localEvidenceEvents, sessionGrants: Array.from(localSessionGrants.values()), @@ -2416,6 +2422,11 @@ app.post('/attestations', async (c) => { return c.json(identityAttestation.body, identityAttestation.status) } + const genericAttestation = issueLocalGenericAttestation(body) + if (genericAttestation) { + return c.json(genericAttestation.body, genericAttestation.status) + } + const agentId = typeof body.agentId === 'string' ? body.agentId : typeof body.agent_id === 'string' @@ -2482,6 +2493,11 @@ app.post('/attestations', async (c) => { app.get('/attestations/:id', (c) => { const id = c.req.param('id') + const genericAttestation = localGenericAttestations.get(id) + if (genericAttestation) { + return c.json({ attestation: genericAttestation, authorityGranted: false }) + } + const attestation = localRuntimeAttestations.get(id) if (!attestation) { return c.json(localError('ATTESTATION_NOT_FOUND', 'attestation not found', { id }), 404) @@ -2491,6 +2507,25 @@ app.get('/attestations/:id', (c) => { app.post('/attestations/:id/verify', async (c) => { const id = c.req.param('id') + const genericAttestation = localGenericAttestations.get(id) + if (genericAttestation) { + const valid = verifyLocalGenericAttestation(genericAttestation) + const event = appendRootEvidence({ + type: valid ? 'attestation.verified' : 'attestation.failed', + actor: genericAttestation.issuer, + subject: genericAttestation.subject, + output: { valid, attestation_id: id }, + decision: valid ? 'verified' : 'failed', + privacy_mode: 'hash_only', + metadata: { + attestation_id: id, + provider: genericAttestation.provider, + subject_type: genericAttestation.subject_type, + }, + }) + return c.json({ id, valid, attestation: genericAttestation, evidenceRefs: [event.event_id], authorityGranted: false }) + } + const attestation = localRuntimeAttestations.get(id) if (!attestation) { const failed = appendRootEvidence({ @@ -2597,6 +2632,117 @@ function issueLocalIdentityAttestation(body: Record): { body: R } } +function issueLocalGenericAttestation(body: Record): { body: Record; status: 201 | 400 } | null { + const schemaVersion = typeof body.schema_version === 'string' ? body.schema_version : undefined + const subject = typeof body.subject === 'string' ? body.subject : undefined + const subjectType = typeof body.subjectType === 'string' + ? body.subjectType + : typeof body.subject_type === 'string' + ? body.subject_type + : undefined + const provider = typeof body.provider === 'string' ? body.provider : undefined + const issuer = typeof body.issuer === 'string' ? body.issuer : undefined + + if (schemaVersion !== 'fides.attestation.v1' && (!subject || !subjectType || !provider || !issuer)) { + return null + } + if (!subject || !subjectType || !provider || !issuer) { + return { + status: 400, + body: localError('REQUEST_INVALID', 'issuer, subject, subjectType, and provider are required for generic attestations', { + fields: ['issuer', 'subject', 'subjectType', 'provider'], + }), + } + } + if (!isAttestationSubjectType(subjectType)) { + return { + status: 400, + body: localError('REQUEST_INVALID', 'subjectType must be agent, publisher, principal, domain, package, wallet, passkey, runtime, build, or peer', { field: 'subjectType' }), + } + } + + const claims = isRecord(body.claims) ? body.claims : {} + const evidenceRefs = Array.isArray(body.evidenceRefs) + ? body.evidenceRefs.map(String) + : Array.isArray(body.evidence_refs) + ? body.evidence_refs.map(String) + : [] + const attestation = createAttestation({ + issuer, + subject, + subjectType, + provider, + claims, + evidenceRefs, + issuedAt: typeof body.issuedAt === 'string' + ? body.issuedAt + : typeof body.issued_at === 'string' + ? body.issued_at + : undefined, + expiresAt: typeof body.expiresAt === 'string' + ? body.expiresAt + : typeof body.expires_at === 'string' + ? body.expires_at + : undefined, + signature: typeof body.signature === 'string' && body.signature.length > 0 + ? body.signature + : undefined, + }) + const stored = { + ...attestation, + signature: attestation.signature || localGenericAttestationSignature(attestation), + } + localGenericAttestations.set(stored.id, stored) + const event = appendRootEvidence({ + type: 'attestation.issued', + actor: issuer, + subject, + output: stored, + decision: 'issued', + privacy_mode: 'hash_only', + metadata: { + attestation_id: stored.id, + provider, + subject_type: subjectType, + }, + }) + + return { + status: 201, + body: { attestation: stored, evidenceRefs: [event.event_id], authorityGranted: false }, + } +} + +function verifyLocalGenericAttestation(attestation: Attestation): boolean { + const { payload_hash: _payloadHash, signature: _signature, ...payload } = attestation + const expectedPayloadHash = hashProtocolPayload(payload) + return attestation.schema_version === 'fides.attestation.v1' && + attestation.payload_hash === expectedPayloadHash && + !isAttestationExpired(attestation) && + attestation.signature === localGenericAttestationSignature(attestation) +} + +function localGenericAttestationSignature(attestation: Attestation): string { + return `local-attestation:${attestation.payload_hash.slice('sha256:'.length)}` +} + +function isAttestationSubjectType(value: string): value is Attestation['subject_type'] { + return value === 'agent' || + value === 'publisher' || + value === 'principal' || + value === 'domain' || + value === 'package' || + value === 'wallet' || + value === 'passkey' || + value === 'runtime' || + value === 'build' || + value === 'peer' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function createLocalIdentityTrustAnchor(body: Record): IdentityTrustAnchor | null { const rawType = typeof body.type === 'string' ? body.type diff --git a/services/agentd/src/storage.ts b/services/agentd/src/storage.ts index 7170ce6..d4f2afa 100644 --- a/services/agentd/src/storage.ts +++ b/services/agentd/src/storage.ts @@ -52,6 +52,7 @@ export interface LocalDaemonStateSnapshot { killSwitchRules: unknown[] revocationRecords: unknown[] incidentRecords: unknown[] + genericAttestations: unknown[] runtimeAttestations: unknown[] evidenceEvents: unknown[] sessionGrants: unknown[] @@ -940,6 +941,7 @@ export function emptyLocalDaemonStateSnapshot(updatedAt = new Date().toISOString killSwitchRules: [], revocationRecords: [], incidentRecords: [], + genericAttestations: [], runtimeAttestations: [], evidenceEvents: [], sessionGrants: [], @@ -967,6 +969,7 @@ export function normalizeLocalDaemonStateSnapshot(value: unknown): LocalDaemonSt killSwitchRules: Array.isArray(input.killSwitchRules) ? input.killSwitchRules : [], revocationRecords: Array.isArray(input.revocationRecords) ? input.revocationRecords : [], incidentRecords: Array.isArray(input.incidentRecords) ? input.incidentRecords : [], + genericAttestations: Array.isArray(input.genericAttestations) ? input.genericAttestations : [], runtimeAttestations: Array.isArray(input.runtimeAttestations) ? input.runtimeAttestations : [], evidenceEvents: Array.isArray(input.evidenceEvents) ? input.evidenceEvents : [], sessionGrants: Array.isArray(input.sessionGrants) ? input.sessionGrants : [], diff --git a/services/agentd/test/routes.test.ts b/services/agentd/test/routes.test.ts index 15652c1..1303e26 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -2240,6 +2240,65 @@ describe('Agentd Service Routes', () => { ])) }) + it('issues, reads, and verifies generic attestations without granting authority', async () => { + const issued = await app.request('/attestations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + issuer: 'did:fides:publisher', + subject: 'did:fides:agent', + subjectType: 'agent', + provider: 'github', + claims: { handle: 'fides-dev' }, + }), + }) + + expect(issued.status).toBe(201) + const issuedData = await issued.json() + expect(issuedData.authorityGranted).toBe(false) + expect(issuedData.attestation).toMatchObject({ + schema_version: 'fides.attestation.v1', + issuer: 'did:fides:publisher', + subject: 'did:fides:agent', + subject_type: 'agent', + provider: 'github', + claims: { handle: 'fides-dev' }, + signature: expect.stringMatching(/^local-attestation:/), + }) + expect(issuedData.evidenceRefs).toHaveLength(1) + + const shown = await app.request(`/attestations/${issuedData.attestation.id}`) + expect(shown.status).toBe(200) + await expect(shown.json()).resolves.toMatchObject({ + attestation: { id: issuedData.attestation.id, schema_version: 'fides.attestation.v1' }, + authorityGranted: false, + }) + + const verified = await app.request(`/attestations/${issuedData.attestation.id}/verify`, { method: 'POST' }) + expect(verified.status).toBe(200) + const verifiedData = await verified.json() + expect(verifiedData.valid).toBe(true) + expect(verifiedData.authorityGranted).toBe(false) + expect(verifiedData.evidenceRefs).toHaveLength(1) + + const evidence = await app.request('/evidence') + const evidenceData = await evidence.json() + expect(evidenceData.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event_id: issuedData.evidenceRefs[0], + type: 'attestation.issued', + subject: 'did:fides:agent', + privacy_mode: 'hash_only', + }), + expect.objectContaining({ + event_id: verifiedData.evidenceRefs[0], + type: 'attestation.verified', + subject: 'did:fides:agent', + privacy_mode: 'hash_only', + }), + ])) + }) + it('records failed attestation verification evidence for missing attestations', async () => { const verified = await app.request('/attestations/att_missing/verify', { method: 'POST' }) expect(verified.status).toBe(404)