diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7f3e35..48ab6ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,27 +38,17 @@ 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' + - name: Agentd CLI smoke + run: pnpm smoke:agentd + env: + AGENTD_DX_SMOKE_PORT: '4819' + docker-build: runs-on: ubuntu-latest strategy: 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: | 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 eb26729..6bac383 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 @@ -53,60 +55,87 @@ pnpm install pnpm build ``` -### Basic Usage +### Start agentd + +```bash +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 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 +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 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. `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 ```typescript -import { createIdentity, classifyCapabilityRisk, createDelegationToken } from '@fides/core' -import { evaluatePolicy } from '@fides/policy' -import { evaluateGuard, createTrustContext } from '@fides/guard' -import { createEvidenceChain, appendEvidenceEvent } 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' }) - -// Classify capability risk -const risk = classifyCapabilityRisk('email:send') // 'high' - -// Delegate capabilities with constraints -const token = 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(), +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'] }], }) -// 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() -chain = appendEvidenceEvent(chain, { - id: 'e1', type: 'invoke', timestamp: new Date().toISOString(), - actor: alice.did, action: 'email:send', payload: {}, - privacy: { level: 'redacted' }, -}, 'signature-hex') - -// 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() ``` --- @@ -114,49 +143,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 │ +└──────────────────────────────────────────────────────────────┘ ``` --- @@ -170,7 +188,9 @@ const decision = await evaluateGuard({ | `@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 | @@ -221,13 +241,29 @@ 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. + +To smoke test the actual local daemon plus CLI demo/simulation path with +isolated state: + +```bash +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 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 @@ -236,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. @@ -305,6 +344,10 @@ 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 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 | | `pnpm clean` | Clean build artifacts | | `pnpm demo` | Run the primitive-level v2 demo | @@ -359,22 +402,14 @@ 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. + +See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the current v2 release snapshot, +verified gates, local mock surfaces, adapter-ready surfaces, and release +checklist. --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..c4b0068 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,140 @@ +# 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. + +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 + +- 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 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 + +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/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..5a2e54c --- /dev/null +++ b/docs/adversarial-simulation.md @@ -0,0 +1,55 @@ +# Adversarial Simulation + +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: + +- `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 + +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/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..ae8a5cc --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,284 @@ +# 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 /identities` +- `GET /identities` +- `GET /identities/:id` +- `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 /delegations` +- `POST /sessions` +- `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 /revocations` +- `GET /revocations` +- `GET /revocations/:id` +- `POST /incidents` +- `GET /incidents` +- `GET /incidents/:id` +- `POST /incidents/:id/resolve` +- `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/start` +- `POST /registry/publish` +- `POST /registry/search` +- `GET /registry/index` +- `POST /relay/start` +- `POST /relay/register` +- `POST /relay/discover` +- `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` + +`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` +- `POST /dht/publish` +- `GET /dht/find` +- `POST /dht/find` +- `POST /demo/run` +- `POST /simulate/adversarial` +- `POST /evidence` +- `GET /evidence` +- `GET /evidence/:eventId` +- `POST /evidence/verify` +- `POST /evidence/export` + +The root v2 endpoints are local-first daemon surfaces. Registry and relay are +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 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. +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 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 +`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. + +`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 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, +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 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 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 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. + +`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. 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. +`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. +`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. 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 +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 +`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 `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 +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 +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. + +`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`. 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; 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 +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 +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`. 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`. Revocation and incident creation +append `revocation.recorded` and `incident.reported` evidence events and return +`evidenceRefs`. + +`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 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 77e6ede..85377a0 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 @@ -49,553 +50,1561 @@ paths: schema: $ref: "#/components/schemas/HealthResponse" - /v1/identities/{did}: - get: - operationId: resolveIdentity + /identities: + post: + operationId: createLocalIdentity tags: [Identity] - summary: Resolve identity via discovery proxy - description: Proxy request to the discovery service for identity resolution. - parameters: - - $ref: "#/components/parameters/DidParam" + summary: Create a local FIDES identity + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" responses: - "200": - description: Identity resolved - content: - application/json: - schema: - $ref: "#/components/schemas/IdentityResolutionResponse" - "404": - description: Identity not found + "201": + description: Created local identity without private key material content: application/json: schema: - $ref: "#/components/schemas/IdentityResolutionResponse" - "502": - description: Discovery service unreachable + $ref: "#/components/schemas/LocalIdentityResponse" + get: + operationId: listLocalIdentities + tags: [Identity] + summary: List local FIDES identities + responses: + "200": + description: Local identity summaries without private key material content: application/json: schema: - $ref: "#/components/schemas/IdentityResolutionResponse" + $ref: "#/components/schemas/LocalIdentityListResponse" - /v1/identities/domain/verify: + /identities/{id}: get: - operationId: verifyIdentityDomain + operationId: getLocalIdentity tags: [Identity] - summary: Verify a domain-to-DID DNS binding - description: Resolves `_fides.` TXT records and checks for an exact `fides-did=` token. + summary: Read a local FIDES identity parameters: - - name: domain - in: query - required: true - schema: - type: string - example: example.com - - name: did - in: query - required: true - schema: - type: string - example: "did:fides:agentd-test-01" + - $ref: "#/components/parameters/IdParam" responses: "200": - description: Domain binding verified - content: - application/json: - schema: - $ref: "#/components/schemas/DomainVerificationResponse" - "400": - description: Missing query parameters + description: Local identity without private key material content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "422": - description: Domain binding was not verified + $ref: "#/components/schemas/LocalIdentityResponse" + "404": + $ref: "#/components/responses/Error" + + /attestations: + post: + operationId: createLocalAttestation + tags: [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. + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + description: Local identity trust-anchor, generic, or runtime attestation issued content: application/json: schema: - $ref: "#/components/schemas/DomainVerificationResponse" + oneOf: + - $ref: "#/components/schemas/LocalIdentityAttestationResponse" + - $ref: "#/components/schemas/LocalGenericAttestationResponse" + - $ref: "#/components/schemas/LocalRuntimeAttestationResponse" - /v1/cards/{did}: + /attestations/{id}: get: - operationId: getAgentCard - tags: [Card] - summary: Get AgentCard via registry proxy + operationId: getLocalAttestation + tags: [Attestation] + summary: Read a local generic or runtime attestation parameters: - - $ref: "#/components/parameters/DidParam" + - $ref: "#/components/parameters/IdParam" responses: "200": - description: Card found + description: Local generic or runtime attestation content: application/json: schema: - $ref: "#/components/schemas/CardResponse" + oneOf: + - $ref: "#/components/schemas/LocalGenericAttestationResponse" + - $ref: "#/components/schemas/LocalRuntimeAttestationResponse" "404": - description: Card not found + $ref: "#/components/responses/Error" + + /attestations/{id}/verify: + post: + operationId: verifyLocalAttestation + tags: [Attestation] + summary: Verify a local generic or runtime attestation + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + description: Local generic or runtime attestation verification result content: application/json: schema: - $ref: "#/components/schemas/CardResponse" - "403": - description: Registry card is private + oneOf: + - $ref: "#/components/schemas/LocalGenericAttestationVerificationResponse" + - $ref: "#/components/schemas/LocalRuntimeAttestationVerificationResponse" + + /agent-cards: + post: + operationId: createLocalAgentCard + tags: [Card] + summary: Create a local AgentCard + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + description: Local AgentCard created and validated content: application/json: schema: - $ref: "#/components/schemas/CardResponse" - "502": - description: Registry unreachable + $ref: "#/components/schemas/LocalAgentCardCreateResponse" + + /agent-cards/{id}: + get: + operationId: getLocalAgentCard + tags: [Card] + summary: Read a local AgentCard + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + description: Local AgentCard and optional signed canonical object content: application/json: schema: - $ref: "#/components/schemas/CardResponse" + $ref: "#/components/schemas/LocalAgentCardResponse" + "404": + $ref: "#/components/responses/Error" - /v1/trust/{did}/score: - get: - operationId: getTrustScore - tags: [Trust] - summary: Get trust score via trust-graph proxy + /agent-cards/{id}/sign: + post: + operationId: signLocalAgentCard + tags: [Card] + summary: Sign a local AgentCard + security: + - ApiKeyAuth: [] parameters: - - $ref: "#/components/parameters/DidParam" + - $ref: "#/components/parameters/IdParam" + requestBody: + $ref: "#/components/requestBodies/JsonObject" responses: "200": - description: Trust score (returns default 0.5 on failure) + description: Identity-bound signed AgentCard content: application/json: schema: - $ref: "#/components/schemas/TrustScoreResponse" + $ref: "#/components/schemas/LocalAgentCardSignResponse" - /v1/policy/evaluate: + /agent-cards/{id}/verify: post: - operationId: evaluatePolicy - tags: [Policy] - summary: Evaluate a policy bundle + operationId: verifyLocalAgentCard + tags: [Card] + summary: Verify a local signed AgentCard security: - ApiKeyAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PolicyEvaluationRequest" + parameters: + - $ref: "#/components/parameters/IdParam" responses: "200": - description: Evaluation result + description: Local AgentCard verification result content: application/json: schema: - $ref: "#/components/schemas/PolicyEvaluationResponse" + $ref: "#/components/schemas/LocalAgentCardVerifyResponse" - /v1/sessions: + /agents/register: post: - operationId: createSession - tags: [Authority] - summary: Create a delegated session + operationId: registerLocalAgent + tags: [Discovery] + summary: Register a local AgentCard for discovery 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. requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SessionCreateRequest" + $ref: "#/components/requestBodies/JsonObject" responses: "201": - description: Session created + description: Local agent registered as a discovery candidate content: application/json: schema: - $ref: "#/components/schemas/SessionCreateResponse" - "400": - $ref: "#/components/responses/BadRequest" - "409": - description: Delegation rejected + $ref: "#/components/schemas/LocalAgentRegistration" + + /agents: + get: + operationId: listLocalAgents + tags: [Discovery] + summary: List locally registered agents + responses: + "200": + description: Locally registered discovery candidates content: application/json: schema: - $ref: "#/components/schemas/AuthorityErrorResponse" + $ref: "#/components/schemas/LocalAgentListResponse" - /v1/sessions/{id}: + /agents/{id}: get: - operationId: getSession - tags: [Authority] - summary: Get a delegated session + operationId: getLocalAgent + tags: [Discovery] + summary: Inspect a locally registered agent parameters: - - name: id - in: path - required: true - schema: - type: string + - $ref: "#/components/parameters/IdParam" responses: "200": - description: Session found + description: Local registration detail content: application/json: schema: - $ref: "#/components/schemas/SessionLookupResponse" + $ref: "#/components/schemas/LocalAgentDetailResponse" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/Error" - /v1/sessions/{id}/revoke: + /discover: post: - operationId: revokeSession - tags: [Authority] - summary: Revoke a delegated session + 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: [] - parameters: - - name: id - in: path - required: true - schema: - type: string requestBody: - required: false - content: - application/json: - schema: - $ref: "#/components/schemas/SessionRevokeRequest" + $ref: "#/components/requestBodies/JsonObject" responses: "200": - description: Session revoked + description: Capability-scoped trust signal; never grants invocation authority content: application/json: schema: - $ref: "#/components/schemas/SessionRevokeResponse" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/LocalTrustEvaluationResponse" - /v1/revocations: + /trust/{id}: + get: + operationId: getLocalTrust + tags: [Trust] + summary: Read local trust results for an agent + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + description: Local trust results for an agent + content: + application/json: + schema: + $ref: "#/components/schemas/LocalTrustListResponse" + + /reputation/update: post: - operationId: recordRevocation - tags: [Authority] - summary: Record a signed revocation + operationId: updateLocalReputation + tags: [Trust] + summary: Update capability-specific local reputation security: - ApiKeyAuth: [] - description: | - Stores a signed RevocationRecord locally and propagates it to - trust-graph. In production, or whenever authority signature - verification is required, `revokerPublicKey` must be included. requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RevocationSubmitRequest" + $ref: "#/components/requestBodies/JsonObject" responses: - "201": - description: Revocation recorded + "200": + description: Capability-specific reputation signal; never grants invocation authority content: application/json: schema: - $ref: "#/components/schemas/RevocationSubmitResponse" - "400": - $ref: "#/components/responses/BadRequest" + $ref: "#/components/schemas/LocalReputationUpdateResponse" - /v1/revocations/{did}: + /reputation/{id}: get: - operationId: getRevocation - tags: [Authority] - summary: Get revocation status for a DID + operationId: getLocalReputation + tags: [Trust] + summary: Read local reputation for an agent parameters: - - $ref: "#/components/parameters/DidParam" + - $ref: "#/components/parameters/IdParam" responses: "200": - description: Revocation status + description: Local capability-specific reputation records for an agent content: application/json: schema: - $ref: "#/components/schemas/RevocationStatusResponse" + $ref: "#/components/schemas/LocalReputationListResponse" - /v1/incidents: + /policy/evaluate: post: - operationId: recordIncident - tags: [Authority] - summary: Record a signed incident + operationId: evaluateLocalPolicy + tags: [Policy] + summary: Evaluate policy before execution security: - ApiKeyAuth: [] - description: | - Stores a signed IncidentRecord locally and propagates it to - trust-graph. In production, or whenever authority signature - verification is required, `reporterPublicKey` must be included. requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/IncidentSubmitRequest" + $ref: "#/components/requestBodies/JsonObject" responses: - "201": - description: Incident recorded + "200": + description: FIDES v2 policy decision. This never grants invocation authority by itself. content: application/json: schema: - $ref: "#/components/schemas/IncidentSubmitResponse" - "400": - $ref: "#/components/responses/BadRequest" + $ref: "#/components/schemas/LocalPolicyEvaluationResponse" - /v1/incidents/{did}: + /approvals: + post: + operationId: createLocalApprovalRequest + tags: [Policy] + summary: Create an approval request + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + description: Created approval request; no invocation authority is granted + content: + application/json: + schema: + $ref: "#/components/schemas/LocalApprovalRequestResponse" get: - operationId: listIncidents - tags: [Authority] - summary: List incidents for a DID + operationId: listLocalApprovals + tags: [Policy] + summary: List local approval requests + responses: + "200": + description: Local approval requests and decisions + content: + application/json: + schema: + $ref: "#/components/schemas/LocalApprovalListResponse" + + /approvals/{id}/approve: + post: + operationId: approveLocalApprovalRequest + tags: [Policy] + summary: Approve a pending approval request + security: + - ApiKeyAuth: [] parameters: - - $ref: "#/components/parameters/DidParam" + - $ref: "#/components/parameters/IdParam" + requestBody: + $ref: "#/components/requestBodies/JsonObject" responses: "200": - description: Incident list and aggregate impact + description: Approval decision recorded; no invocation authority is granted content: application/json: schema: - $ref: "#/components/schemas/IncidentListResponse" + $ref: "#/components/schemas/LocalApprovalDecisionResponse" - /v1/authorize: + /approvals/{id}/deny: post: - operationId: authorizeAction - tags: [Authority] - summary: Evaluate unified authorization for an agent action + operationId: denyLocalApprovalRequest + tags: [Policy] + summary: Deny a pending approval request security: - ApiKeyAuth: [] - description: | - Combines optional delegated session checks, local revocations, - incident impact, policy evaluation, runtime trust context, and kill - switch state before returning an allow, deny, or approval-required - decision. + parameters: + - $ref: "#/components/parameters/IdParam" requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AuthorizationRequest" + $ref: "#/components/requestBodies/JsonObject" responses: "200": - description: Authorization allowed or approval required + description: Approval decision recorded; no invocation authority is granted content: application/json: schema: - $ref: "#/components/schemas/AuthorizationResponse" - "400": - $ref: "#/components/responses/BadRequest" - "403": - description: Authorization denied + $ref: "#/components/schemas/LocalApprovalDecisionResponse" + + /delegations: + post: + operationId: createLocalDelegation + tags: [Authority] + summary: Create a local DelegationToken + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + description: DelegationToken created; still requires policy-checked SessionGrant before invocation content: application/json: schema: - $ref: "#/components/schemas/AuthorizationResponse" + $ref: "#/components/schemas/LocalDelegationResponse" - /v1/evidence: + /sessions: post: - operationId: submitEvidence - tags: [Evidence] - summary: Submit evidence event + operationId: createLocalSession + tags: [Authority] + summary: Create a scoped SessionGrant security: - ApiKeyAuth: [] - description: | - Appends an evidence event to the chain for a given actor DID. - Events are hash-chained and Merkle-rooted. requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EvidenceSubmitRequest" + $ref: "#/components/requestBodies/JsonObject" responses: "201": - description: Evidence accepted + description: Policy-checked local SessionGrant content: application/json: schema: - $ref: "#/components/schemas/EvidenceSubmitResponse" - "400": - $ref: "#/components/responses/BadRequest" + $ref: "#/components/schemas/LocalSessionResponse" - /v1/evidence/{did}: + /sessions/{id}: get: - operationId: getEvidence - tags: [Evidence] - summary: Retrieve evidence chain for a DID + operationId: getLocalSession + tags: [Authority] + summary: Read a local SessionGrant parameters: - - $ref: "#/components/parameters/DidParam" + - $ref: "#/components/parameters/IdParam" responses: "200": - description: Evidence chain + description: Stored local SessionGrant content: application/json: schema: - $ref: "#/components/schemas/EvidenceChainResponse" + $ref: "#/components/schemas/LocalSessionResponse" + "404": + $ref: "#/components/responses/Error" - /v1/evidence/{did}/verify: + /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": + description: Evidence event appended to the local hash chain + content: + application/json: + schema: + $ref: "#/components/schemas/LocalEvidenceAppendResponse" get: - operationId: verifyEvidence + operationId: listLocalEvidence tags: [Evidence] - summary: Verify evidence chain integrity for a DID - description: | - Returns evidence chain integrity metadata without returning full event payloads. - parameters: - - $ref: "#/components/parameters/DidParam" + summary: List local evidence events responses: "200": - description: Evidence chain verification result + description: Local evidence ledger state content: application/json: schema: - $ref: "#/components/schemas/EvidenceVerificationResponse" + $ref: "#/components/schemas/LocalEvidenceListResponse" - /v1/authority/propagations/pending: + /evidence/{id}: get: - operationId: listPendingAuthorityPropagations - tags: [Authority] - summary: List due authority propagation retries - description: | - Returns failed revocation and incident propagation attempts that are due - for retry against the trust-graph service. + operationId: getLocalEvidenceEvent + tags: [Evidence] + summary: Inspect a local evidence event parameters: - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - default: 25 + - $ref: "#/components/parameters/IdParam" responses: "200": - description: Pending propagation records + description: Local evidence event content: application/json: schema: - $ref: "#/components/schemas/AuthorityPropagationListResponse" + $ref: "#/components/schemas/LocalEvidenceEventResponse" + "404": + $ref: "#/components/responses/Error" - /v1/authority/propagations/retry: + /evidence/verify: post: - operationId: retryAuthorityPropagations - tags: [Authority] - summary: Retry due authority propagations + operationId: verifyLocalEvidence + tags: [Evidence] + summary: Verify the local evidence hash chain + security: + - ApiKeyAuth: [] + responses: + "200": + description: Local evidence hash-chain verification result + content: + application/json: + schema: + $ref: "#/components/schemas/LocalEvidenceVerificationResponse" + + /evidence/export: + post: + operationId: exportLocalEvidence + tags: [Evidence] + summary: Export privacy-aware local evidence security: - ApiKeyAuth: [] - description: | - Replays due revocation and incident propagation records to trust-graph, - updates outbox status, and appends local evidence for each attempt. requestBody: - required: false - content: - application/json: - schema: - $ref: "#/components/schemas/AuthorityPropagationRetryRequest" + $ref: "#/components/requestBodies/JsonObject" responses: "200": - description: Retry summary + description: Privacy-aware local evidence export content: application/json: schema: - $ref: "#/components/schemas/AuthorityPropagationRetryResponse" + $ref: "#/components/schemas/LocalEvidenceExportResponse" - /v1/attest: + /revocations: post: - operationId: createAttestation - tags: [Attestation] - summary: Create runtime attestation + operationId: recordLocalRevocation + tags: [Authority] + summary: Record a local revocation security: - ApiKeyAuth: [] - description: | - Generates a TEE runtime attestation using the configured attestation - provider (mock provider in development). requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AttestationRequest" + $ref: "#/components/requestBodies/JsonObject" responses: "201": - description: Attestation created + description: Revocation recorded; active records override matching trust and policy evaluation content: application/json: schema: - $ref: "#/components/schemas/RuntimeAttestation" - "400": - $ref: "#/components/responses/BadRequest" - - /v1/killswitch/status: + $ref: "#/components/schemas/LocalRevocationRecordResponse" get: - operationId: getKillSwitchStatus - tags: [Kill Switch] - summary: Get kill switch status + operationId: listLocalRevocations + tags: [Authority] + summary: List local revocations responses: "200": - description: Kill switch state + description: Local revocation records and active subset content: application/json: schema: - type: object - properties: - global: - type: boolean + $ref: "#/components/schemas/LocalRevocationListResponse" - /v1/killswitch/engage: - post: - operationId: engageKillSwitch - tags: [Kill Switch] - summary: Engage kill switch - security: - - ApiKeyAuth: [] - description: | - Engage a kill switch at global, agent, or capability level. - Global overrides all. Agent overrides capability for that agent. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/KillSwitchRequest" + /revocations/{id}: + get: + operationId: getLocalRevocation + tags: [Authority] + summary: Inspect a local revocation + parameters: + - $ref: "#/components/parameters/IdParam" responses: "200": - description: Kill switch engaged + description: Local revocation status and record content: application/json: schema: - $ref: "#/components/schemas/KillSwitchResponse" + $ref: "#/components/schemas/LocalRevocationStatusResponse" + "404": + $ref: "#/components/responses/Error" - /v1/killswitch/disengage: + /incidents: post: - operationId: disengageKillSwitch - tags: [Kill Switch] - summary: Disengage kill switch + operationId: recordLocalIncident + tags: [Authority] + summary: Record a local incident security: - ApiKeyAuth: [] requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/KillSwitchRequest" + $ref: "#/components/requestBodies/JsonObject" responses: - "200": - description: Kill switch disengaged + "201": + description: Incident recorded; open incidents require policy review for matching target agents content: application/json: schema: - $ref: "#/components/schemas/KillSwitchResponse" - - /metrics: + $ref: "#/components/schemas/LocalIncidentRecordResponse" get: - operationId: getMetrics - tags: [Health] - summary: Prometheus metrics + operationId: listLocalIncidents + tags: [Authority] + summary: List local incidents responses: "200": - description: Metrics in Prometheus text format + description: Local incident records and open subset content: - text/plain: + application/json: schema: - type: string + $ref: "#/components/schemas/LocalIncidentListResponse" -components: - securitySchemes: - ApiKeyAuth: + /incidents/{id}: + get: + operationId: getLocalIncident + tags: [Authority] + summary: Inspect a local incident + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + description: Local incident record + content: + application/json: + schema: + $ref: "#/components/schemas/LocalIncidentRecordResponse" + "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": + description: Resolved local incident record + content: + application/json: + schema: + $ref: "#/components/schemas/LocalIncidentRecordResponse" + + /killswitch: + post: + operationId: createLocalKillSwitchRule + tags: [Kill Switch] + summary: Enable a local kill switch rule + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + 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": + description: Local kill switch rules and active subset + content: + application/json: + schema: + $ref: "#/components/schemas/LocalKillSwitchListResponse" + + /killswitch/{id}: + delete: + operationId: disableLocalKillSwitchRule + tags: [Kill Switch] + summary: Disable a local kill switch rule + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/IdParam" + responses: + "200": + description: Disabled kill switch rule + content: + application/json: + schema: + $ref: "#/components/schemas/LocalKillSwitchRuleResponse" + + /dht/start: + post: + operationId: startLocalDht + tags: [Discovery] + summary: Start the local DHT simulator + security: + - ApiKeyAuth: [] + responses: + "200": + description: Local in-memory DHT simulator status + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDhtStartResponse" + + /dht/publish: + post: + operationId: publishLocalDhtPointer + tags: [Discovery] + summary: Publish a signed local DHT capability pointer + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + description: Local DHT pointer published; pointers are not trust sources + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDhtPublishResponse" + + /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": + description: Local DHT pointer lookup result + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDhtFindResponse" + post: + operationId: postFindLocalDhtPointers + tags: [Discovery] + summary: Find local DHT pointers by request body + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "200": + description: Local DHT pointer lookup result + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDhtFindResponse" + + /registry/start: + post: + operationId: startLocalRegistry + tags: [Discovery] + summary: Start the local registry mode + security: + - ApiKeyAuth: [] + responses: + "200": + description: Local registry simulator status + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRegistryStartResponse" + + /registry/publish: + post: + operationId: publishLocalRegistryRecord + tags: [Discovery] + summary: Publish a local registry record + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + description: Local registry record published + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRegistryPublishResponse" + + /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": + description: Local registry index; registry records are candidates only + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRegistryIndexResponse" + + /relay/start: + post: + operationId: startLocalRelay + tags: [Discovery] + summary: Start the local relay simulator + security: + - ApiKeyAuth: [] + responses: + "200": + description: Local relay simulator status + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRelayStartResponse" + + /relay/register: + post: + operationId: registerLocalRelayPresence + tags: [Discovery] + summary: Register local relay presence + security: + - ApiKeyAuth: [] + requestBody: + $ref: "#/components/requestBodies/JsonObject" + responses: + "201": + description: Local relay presence registered; relay presence is not authority + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRelayRegisterResponse" + + /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": + description: Local FIDES well-known discovery document + content: + application/json: + schema: + $ref: "#/components/schemas/WellKnownFidesResponse" + + /.well-known/agents.json: + get: + operationId: getWellKnownAgents + tags: [Discovery] + summary: Read local well-known agent index + responses: + "200": + description: Local well-known agent index + content: + application/json: + schema: + $ref: "#/components/schemas/WellKnownAgentsResponse" + + /.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": + description: Local well-known AgentCard document + content: + application/json: + schema: + $ref: "#/components/schemas/WellKnownAgentResponse" + + /demo/run: + post: + operationId: runLocalDemo + tags: [Authority] + summary: Run the full local Agent Trust Fabric demo + security: + - ApiKeyAuth: [] + responses: + "200": + description: Full local demo result with trust, policy, invocation, and evidence summary + content: + application/json: + schema: + $ref: "#/components/schemas/LocalDemoRunResponse" + + /simulate/adversarial: + post: + operationId: runAdversarialSimulation + tags: [Authority] + summary: Run local adversarial simulation scenarios + security: + - ApiKeyAuth: [] + responses: + "200": + description: Local adversarial simulation detection result + content: + application/json: + schema: + $ref: "#/components/schemas/LocalAdversarialSimulationResponse" + + /v1/identities/{did}: + get: + operationId: resolveIdentity + tags: [Identity] + summary: Resolve identity via discovery proxy + description: Proxy request to the discovery service for identity resolution. + parameters: + - $ref: "#/components/parameters/DidParam" + responses: + "200": + description: Identity resolved + content: + application/json: + schema: + $ref: "#/components/schemas/IdentityResolutionResponse" + "404": + description: Identity not found + content: + application/json: + schema: + $ref: "#/components/schemas/IdentityResolutionResponse" + "502": + description: Discovery service unreachable + content: + application/json: + schema: + $ref: "#/components/schemas/IdentityResolutionResponse" + + /v1/identities/domain/verify: + get: + operationId: verifyIdentityDomain + tags: [Identity] + summary: Verify a domain-to-DID DNS binding + description: Resolves `_fides.` TXT records and checks for an exact `fides-did=` token. + parameters: + - name: domain + in: query + required: true + schema: + type: string + example: example.com + - name: did + in: query + required: true + schema: + type: string + example: "did:fides:agentd-test-01" + responses: + "200": + description: Domain binding verified + content: + application/json: + schema: + $ref: "#/components/schemas/DomainVerificationResponse" + "400": + description: Missing query parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "422": + description: Domain binding was not verified + content: + application/json: + schema: + $ref: "#/components/schemas/DomainVerificationResponse" + + /v1/cards/{did}: + get: + operationId: getAgentCard + tags: [Card] + summary: Get AgentCard via registry proxy + parameters: + - $ref: "#/components/parameters/DidParam" + responses: + "200": + description: Card found + content: + application/json: + schema: + $ref: "#/components/schemas/CardResponse" + "404": + description: Card not found + content: + application/json: + schema: + $ref: "#/components/schemas/CardResponse" + "403": + description: Registry card is private + content: + application/json: + schema: + $ref: "#/components/schemas/CardResponse" + "502": + description: Registry unreachable + content: + application/json: + schema: + $ref: "#/components/schemas/CardResponse" + + /v1/trust/{did}/score: + get: + operationId: getTrustScore + tags: [Trust] + summary: Get trust score via trust-graph proxy + parameters: + - $ref: "#/components/parameters/DidParam" + responses: + "200": + description: Trust score (returns default 0.5 on failure) + content: + application/json: + schema: + $ref: "#/components/schemas/TrustScoreResponse" + + /v1/policy/evaluate: + post: + operationId: evaluatePolicy + tags: [Policy] + summary: Evaluate a policy bundle + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PolicyEvaluationRequest" + responses: + "200": + description: Evaluation result + content: + application/json: + schema: + $ref: "#/components/schemas/PolicyEvaluationResponse" + + /v1/sessions: + post: + operationId: createSession + tags: [Authority] + summary: Create a delegated session + security: + - ApiKeyAuth: [] + description: | + 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: + application/json: + schema: + $ref: "#/components/schemas/SessionCreateRequest" + responses: + "201": + description: Session created + content: + application/json: + schema: + $ref: "#/components/schemas/SessionCreateResponse" + "400": + $ref: "#/components/responses/BadRequest" + "409": + description: Delegation rejected + content: + application/json: + schema: + $ref: "#/components/schemas/AuthorityErrorResponse" + + /v1/sessions/{id}: + get: + operationId: getSession + tags: [Authority] + summary: Get a delegated session + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Session found + content: + application/json: + schema: + $ref: "#/components/schemas/SessionLookupResponse" + "404": + $ref: "#/components/responses/NotFound" + + /v1/sessions/{id}/revoke: + post: + operationId: revokeSession + tags: [Authority] + summary: Revoke a delegated session + security: + - ApiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/SessionRevokeRequest" + responses: + "200": + description: Session revoked + content: + application/json: + schema: + $ref: "#/components/schemas/SessionRevokeResponse" + "404": + $ref: "#/components/responses/NotFound" + + /v1/revocations: + post: + operationId: recordRevocation + tags: [Authority] + summary: Record a signed revocation + security: + - ApiKeyAuth: [] + description: | + Stores a signed RevocationRecord locally and propagates it to + trust-graph. In production, or whenever authority signature + verification is required, `revokerPublicKey` must be included. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RevocationSubmitRequest" + responses: + "201": + description: Revocation recorded + content: + application/json: + schema: + $ref: "#/components/schemas/RevocationSubmitResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /v1/revocations/{did}: + get: + operationId: getRevocation + tags: [Authority] + summary: Get revocation status for a DID + parameters: + - $ref: "#/components/parameters/DidParam" + responses: + "200": + description: Revocation status + content: + application/json: + schema: + $ref: "#/components/schemas/RevocationStatusResponse" + + /v1/incidents: + post: + operationId: recordIncident + tags: [Authority] + summary: Record a signed incident + security: + - ApiKeyAuth: [] + description: | + Stores a signed IncidentRecord locally and propagates it to + trust-graph. In production, or whenever authority signature + verification is required, `reporterPublicKey` must be included. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IncidentSubmitRequest" + responses: + "201": + description: Incident recorded + content: + application/json: + schema: + $ref: "#/components/schemas/IncidentSubmitResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /v1/incidents/{did}: + get: + operationId: listIncidents + tags: [Authority] + summary: List incidents for a DID + parameters: + - $ref: "#/components/parameters/DidParam" + responses: + "200": + description: Incident list and aggregate impact + content: + application/json: + schema: + $ref: "#/components/schemas/IncidentListResponse" + + /v1/authorize: + post: + operationId: authorizeAction + tags: [Authority] + summary: Evaluate unified authorization for an agent action + security: + - ApiKeyAuth: [] + description: | + Combines optional delegated session checks, local revocations, + incident impact, policy evaluation, runtime trust context, and kill + switch state before returning an allow, deny, or approval-required + decision. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AuthorizationRequest" + responses: + "200": + description: Authorization allowed or approval required + content: + application/json: + schema: + $ref: "#/components/schemas/AuthorizationResponse" + "400": + $ref: "#/components/responses/BadRequest" + "403": + description: Authorization denied + content: + application/json: + schema: + $ref: "#/components/schemas/AuthorizationResponse" + + /v1/evidence: + post: + operationId: submitEvidence + tags: [Evidence] + summary: Submit evidence event + security: + - ApiKeyAuth: [] + description: | + Appends an evidence event to the chain for a given actor DID. + Events are hash-chained and Merkle-rooted. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EvidenceSubmitRequest" + responses: + "201": + description: Evidence accepted + content: + application/json: + schema: + $ref: "#/components/schemas/EvidenceSubmitResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /v1/evidence/{did}: + get: + operationId: getEvidence + tags: [Evidence] + summary: Retrieve evidence chain for a DID + parameters: + - $ref: "#/components/parameters/DidParam" + responses: + "200": + description: Evidence chain + content: + application/json: + schema: + $ref: "#/components/schemas/EvidenceChainResponse" + + /v1/evidence/{did}/verify: + get: + operationId: verifyEvidence + tags: [Evidence] + summary: Verify evidence chain integrity for a DID + description: | + Returns evidence chain integrity metadata without returning full event payloads. + parameters: + - $ref: "#/components/parameters/DidParam" + responses: + "200": + description: Evidence chain verification result + content: + application/json: + schema: + $ref: "#/components/schemas/EvidenceVerificationResponse" + + /v1/authority/propagations/pending: + get: + operationId: listPendingAuthorityPropagations + tags: [Authority] + summary: List due authority propagation retries + description: | + Returns failed revocation and incident propagation attempts that are due + for retry against the trust-graph service. + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + default: 25 + responses: + "200": + description: Pending propagation records + content: + application/json: + schema: + $ref: "#/components/schemas/AuthorityPropagationListResponse" + + /v1/authority/propagations/retry: + post: + operationId: retryAuthorityPropagations + tags: [Authority] + summary: Retry due authority propagations + security: + - ApiKeyAuth: [] + description: | + Replays due revocation and incident propagation records to trust-graph, + updates outbox status, and appends local evidence for each attempt. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/AuthorityPropagationRetryRequest" + responses: + "200": + description: Retry summary + content: + application/json: + schema: + $ref: "#/components/schemas/AuthorityPropagationRetryResponse" + + /v1/attest: + post: + operationId: createAttestation + tags: [Attestation] + summary: Create runtime attestation + security: + - ApiKeyAuth: [] + description: | + Generates a TEE runtime attestation using the configured attestation + provider (mock provider in development). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AttestationRequest" + responses: + "201": + description: Attestation created + content: + application/json: + schema: + $ref: "#/components/schemas/RuntimeAttestation" + "400": + $ref: "#/components/responses/BadRequest" + + /v1/killswitch/status: + get: + operationId: getKillSwitchStatus + tags: [Kill Switch] + summary: Get kill switch status + responses: + "200": + description: Kill switch state + content: + application/json: + schema: + type: object + properties: + global: + type: boolean + + /v1/killswitch/engage: + post: + operationId: engageKillSwitch + tags: [Kill Switch] + summary: Engage kill switch + security: + - ApiKeyAuth: [] + description: | + Engage a kill switch at global, agent, or capability level. + Global overrides all. Agent overrides capability for that agent. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/KillSwitchRequest" + responses: + "200": + description: Kill switch engaged + content: + application/json: + schema: + $ref: "#/components/schemas/KillSwitchResponse" + + /v1/killswitch/disengage: + post: + operationId: disengageKillSwitch + tags: [Kill Switch] + summary: Disengage kill switch + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/KillSwitchRequest" + responses: + "200": + description: Kill switch disengaged + content: + application/json: + 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 + tags: [Health] + summary: Prometheus metrics + responses: + "200": + description: Metrics in Prometheus text format + content: + text/plain: + schema: + type: string + +components: + securitySchemes: + ApiKeyAuth: type: apiKey in: header name: X-API-Key @@ -608,171 +1617,2184 @@ components: `agentd:evidence:write`, `agentd:attest:write`, `agentd:killswitch:write`, or `*`. - parameters: - DidParam: - name: did - in: path - required: true - schema: - type: string - example: "did:fides:agentd-test-01" + parameters: + IdParam: + name: id + in: path + required: true + schema: + type: string + + DidParam: + name: did + in: path + required: true + schema: + 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] + 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 + - 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 + - POLICY_DENIED + - APPROVAL_REQUIRED + - APPROVAL_NOT_FOUND + - SESSION_EXPIRED + - SESSION_NOT_FOUND + - SESSION_SCOPE_INVALID + - ATTESTATION_INVALID + - ATTESTATION_EXPIRED + - ATTESTATION_NOT_FOUND + - 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 + properties: + status: + type: string + enum: [healthy, degraded] + service: + type: string + example: agentd + uptime: + type: integer + timestamp: + type: string + format: date-time + checks: + type: object + properties: + discovery: + type: string + enum: [connected, unreachable] + trustGraph: + type: string + enum: [connected, unreachable] + registry: + type: string + enum: [connected, unreachable] + authorityStore: + type: string + enum: [ready, unavailable] + authorityStore: + type: object + properties: + kind: + type: string + enum: [memory, file, postgres] + 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" + + 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 + + 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, authorityGranted] + properties: + accepted: + type: boolean + pointer: + $ref: "#/components/schemas/LocalDiscoveryRecord" + authorityGranted: + type: boolean + enum: [false] + + 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, authorityGranted] + properties: + accepted: + type: boolean + record: + $ref: "#/components/schemas/LocalDiscoveryRecord" + authorityGranted: + type: boolean + enum: [false] + + 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, authorityGranted] + properties: + accepted: + type: boolean + record: + $ref: "#/components/schemas/LocalDiscoveryRecord" + authorityGranted: + type: boolean + enum: [false] + + 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] + 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] + + 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: + - 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 + + 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: + did: + type: string + status: + type: string + enum: [resolved, not-found, unreachable] + data: + type: object + error: + type: string + service: + type: string + + DomainVerificationResponse: + type: object + required: [domain, did, recordName, verified] + properties: + domain: + type: string + example: example.com + did: + type: string + example: "did:fides:agentd-test-01" + recordName: + type: string + example: _fides.example.com + verified: + type: boolean + reason: + 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 + + 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 + + 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] + 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: + $ref: "#/components/schemas/SessionGrantV2" + signedSession: + type: object + description: Canonically signed SessionGrant. + signedSessionVerified: + type: boolean + versionNegotiation: + $ref: "#/components/schemas/VersionNegotiationRecord" + policy: + type: object + trust: + type: object + evidenceRefs: + type: array + 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] + 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 + 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 + properties: + did: + type: string + card: + type: object + error: + type: string + + TrustScoreResponse: + type: object + properties: + did: + type: string + score: + type: number + directTrusters: + type: integer + transitiveTrusters: + type: integer + source: + type: string + enum: [default, fallback] + + PolicyEvaluationRequest: + type: object + properties: + agentDid: + type: string + capabilityId: + type: string + policy: + $ref: "#/components/schemas/PolicyBundle" + context: + type: object + + PolicyBundle: + type: object + properties: + id: + type: string + version: + type: string + rules: + type: array + items: + $ref: "#/components/schemas/PolicyRule" + defaultAction: + type: string + enum: [allow, deny, approve-required] + + PolicyRule: + type: object + properties: + id: + type: string + condition: + type: object + action: + type: string + enum: [allow, deny, approve-required, dry-run] + explanation: + type: string + + PolicyEvaluationResponse: + type: object + properties: + decision: + type: string + matchedRules: + type: array + items: + type: object + 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 - schemas: - ErrorResponse: + LocalPolicyEvaluationResponse: + type: object + properties: + policy: + $ref: "#/components/schemas/FidesPolicyDecision" + trust: + type: object + authorityGranted: + type: boolean + enum: [false] + requiresSessionGrant: + type: boolean + 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] + + 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" + + 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" + + 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: [error] + required: [records, open] properties: - error: - type: string + records: + type: array + items: + $ref: "#/components/schemas/IncidentRecordV2" + open: + type: array + items: + $ref: "#/components/schemas/IncidentRecordV2" - HealthResponse: + 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: - status: + schema_version: type: string - enum: [healthy, degraded] - service: + enum: [fides.evidence_event.v1] + id: type: string - example: agentd - uptime: - type: integer + 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 - checks: - type: object - properties: - discovery: - type: string - enum: [connected, unreachable] - trustGraph: - type: string - enum: [connected, unreachable] - registry: - type: string - enum: [connected, unreachable] - authorityStore: - type: string - enum: [ready, unavailable] - authorityStore: - type: object - properties: - kind: - type: string - enum: [memory, file, postgres] - detail: - type: string - - IdentityResolutionResponse: - type: object - properties: - did: + prev_event_hash: type: string - status: + payload_hash: type: string - enum: [resolved, not-found, unreachable] - data: - type: object - error: + event_hash: type: string - service: + signature: type: string + metadata: + type: object + additionalProperties: true - DomainVerificationResponse: + LocalEvidenceAppendResponse: type: object - required: [domain, did, recordName, verified] + required: [accepted, event, authorityGranted] properties: - domain: - type: string - example: example.com - did: - type: string - example: "did:fides:agentd-test-01" - recordName: - type: string - example: _fides.example.com - verified: + accepted: type: boolean - reason: + 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 - enum: [invalid-domain, invalid-did, record-not-found, resolver-error] + nullable: true + authorityGranted: + type: boolean + enum: [false] - CardResponse: + LocalEvidenceVerificationResponse: type: object + required: [valid, count, lastHash, scope, checkedAt] properties: - did: + valid: + type: boolean + count: + type: integer + lastHash: type: string - card: - type: object - error: + nullable: true + scope: + type: string + enum: [root-local-evidence-ledger] + checkedAt: type: string + format: date-time - TrustScoreResponse: + LocalEvidenceExportResponse: type: object + required: [format, exportedAt, valid, count, privacyMode, includeMetadata, events] properties: - did: + format: type: string - score: - type: number - directTrusters: - type: integer - transitiveTrusters: + enum: [json] + exportedAt: + type: string + format: date-time + valid: + type: boolean + count: type: integer - source: + privacyMode: type: string - enum: [default, fallback] + includeMetadata: + type: boolean + nullable: true + events: + type: array + items: + $ref: "#/components/schemas/EvidenceEventV2" - PolicyEvaluationRequest: + LocalDemoAuthoritySummary: type: object + required: [discoveryGrantsAuthority, policyBeforeExecution, evidenceProduced] properties: - agentDid: - type: string - capabilityId: - type: string - policy: - $ref: "#/components/schemas/PolicyBundle" - context: - type: object + discoveryGrantsAuthority: + type: boolean + enum: [false] + identityEqualsTrust: + type: boolean + enum: [false] + trustScoreEqualsPermission: + type: boolean + enum: [false] + policyBeforeExecution: + type: boolean + evidenceProduced: + type: boolean - PolicyBundle: + LocalDemoRunResponse: type: object + required: [status, mode, steps, identities, discovery, verification, authority, surfaces, limitations] properties: - id: + status: type: string - version: + enum: [executed] + mode: type: string - rules: + enum: [local-first] + steps: type: array items: - $ref: "#/components/schemas/PolicyRule" - defaultAction: - type: string - enum: [allow, deny, approve-required] + 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 - PolicyRule: + LocalAdversarialScenario: type: object + required: [name, detected, outcome, evidenceRef] properties: - id: + name: type: string - condition: - type: object - action: + detected: + type: boolean + outcome: type: string - enum: [allow, deny, approve-required, dry-run] - explanation: + evidenceRef: type: string + policy: + type: object + additionalProperties: true + trust: + type: object + additionalProperties: true + reputation: + type: object + additionalProperties: true + errors: + type: array + items: + type: string - PolicyEvaluationResponse: + LocalAdversarialSimulationResponse: type: object + required: [status, mode, detections, scenarios, evidence, authority, limitations] properties: - decision: + status: type: string - matchedRules: + enum: [detected, partial] + mode: + type: string + enum: [local-first] + detections: type: array items: - type: object - explanation: + 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 @@ -803,7 +3825,7 @@ components: properties: level: type: string - enum: [public, private, redacted, hash-only] + enum: [public, private, redacted, hash_only] redactionKey: type: string @@ -924,6 +3946,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: @@ -952,10 +4055,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: @@ -968,7 +4073,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 @@ -977,6 +4085,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 @@ -1186,7 +4297,7 @@ components: properties: decision: type: string - enum: [allow, deny, approve-required] + enum: [allow, deny, approve-required, dry-run] explanation: type: string factors: diff --git a/docs/api/discovery.yaml b/docs/api/discovery.yaml index dc1ae87..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 @@ -384,6 +390,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: @@ -610,7 +620,7 @@ components: RegisterAgentRequest: type: object - required: [did, name, url] + required: [did, name] properties: did: type: string @@ -620,11 +630,11 @@ 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: - type: string + $ref: "#/components/schemas/AgentProvider" capabilities: type: object skills: @@ -660,7 +670,8 @@ components: type: string example: ed25519 provider: - type: string + allOf: + - $ref: "#/components/schemas/AgentProvider" nullable: true capabilities: type: object @@ -686,6 +697,23 @@ 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] + 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. + reasons: + type: array + items: + type: string + description: Machine-readable explanation codes for candidate-only discovery semantics. DiscoveryDocument: type: object @@ -725,7 +753,7 @@ components: version: type: string provider: - type: string + $ref: "#/components/schemas/AgentProvider" capabilities: type: object skills: @@ -753,10 +781,24 @@ components: MessageResponse: type: object + required: [message, authorityGranted] properties: message: type: string example: "Agent deregistered" + authorityGranted: + type: boolean + enum: [false] + + AgentProvider: + type: object + required: [organization] + properties: + organization: + type: string + url: + type: string + format: uri responses: BadRequest: 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/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) 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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..93a8b91 --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,223 @@ +# 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` +- `register` +- `agents` +- `discover` +- `trust` +- `policy` +- `approval` +- `session` +- `invoke` +- `authorize` +- `attest` +- `runtime` +- `revoke` +- `incident` +- `killswitch` +- `registry` +- `relay` +- `dht` +- `evidence` +- `demo` +- `simulate` +- `daemon` + +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 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:... +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 +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 +agentd registry start +agentd registry publish did:fides:... +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 --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 --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 +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 +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 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_... +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 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 +``` + +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. 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. +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. + +`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 +candidates, registry records, relay presence records, or DHT pointers only; +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. `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`. + +`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. 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 +`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. + +`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`, +`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`. + +`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`. + +`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 `/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` +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` +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. + +`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 +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/deployment.md b/docs/deployment.md index 7f04457..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 | @@ -16,7 +44,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 +93,31 @@ 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 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 | Variable | Default | Required | Description | diff --git a/docs/getting-started.md b/docs/getting-started.md index 564505c..d7c706f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,573 +1,381 @@ -# 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 +pnpm install +pnpm build ``` -### 2. Install Dependencies +The examples below assume the `agentd` binary is on your `PATH`. From a fresh +checkout, you can run the same commands through the root workspace script: ```bash -pnpm install +pnpm agentd ``` -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 +Use `pnpm --silent agentd ... --json` when piping JSON output to another tool, +because pnpm prints script banners by default. -### 3. Start PostgreSQL +## Start The Local Daemon -Start the PostgreSQL database using Docker Compose: +The root v2 API is served by `agentd` on `http://localhost:7345`. ```bash -docker compose up -d +pnpm agentd:dev ``` -This will start: -- PostgreSQL 16 on port 5432 -- Database name: `fides` -- Username: `fides` -- Password: `fides` +In another shell: -**Verify PostgreSQL is running:** ```bash -docker ps +curl http://localhost:7345/health ``` -You should see a container named `fides-postgres-1` in the running state. +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. -### 4. Build All Packages +The CLI daemon launcher exposes the same controls: ```bash -pnpm build +pnpm agentd daemon start --port 7345 --sqlite-path /tmp/fides-demo.sqlite +pnpm agentd daemon start --port 7345 --local-state memory ``` -This compiles TypeScript to JavaScript for all packages and services. - -### 5. Start Development Servers - -Start all services in development mode: +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 dev +pnpm smoke:agentd ``` -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` +## Create Local Identities -**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 - -### 1. Create Your First Identity - -Create a new agent identity using the CLI: +Create a principal, publisher, requester agent, and target agent. ```bash -fides init --name "My First Agent" +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 ``` -**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 -``` - -**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...==' +agentd card sign did:fides:invoice-agent --agentd-url http://localhost:7345 +agentd card verify did:fides:invoice-agent --agentd-url http://localhost:7345 ``` -**Output:** -``` -✓ Signature verified successfully -✓ Issuer: did:fides:ABC... -✓ Timestamp valid (not expired) +All signed protocol objects use the shared canonical signing model. -Request is authentic. -``` - -### 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` - -**Interactive prompt:** -- Enter your password to sign the attestation - -**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 -``` +Registration only makes the agent discoverable. It does not grant authority. -### 6. Discover an Agent and Check Reputation +## Discover By Capability -Look up another agent's identity and reputation: +Local discovery: ```bash -fides discover did:fides:7nK9fV3hP8xRqW2mTgJvCz4YpLnH5QbM3kD6sF2gR9Xa -``` - -**Output:** +agentd discover --capability invoice.reconcile --provider local --agentd-url http://localhost:7345 ``` -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) -``` - -## 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 -``` - -### Basic Example +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 -```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 graph inspect did:fides:invoice-agent --agentd-url http://localhost:7345 ``` -### Verifying Requests - -```typescript -import { Fides } from '@fides/sdk' - -const fides = new Fides({ - discoveryUrl: 'http://localhost:3100', - trustUrl: 'http://localhost:3200', -}) +Reputation is also capability-specific and can be principal/publisher-aware. -// 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 +agentd reputation did:fides:invoice-agent --capability invoice.reconcile --agentd-url http://localhost:7345 ``` -### Discovering Identities +Trust graph inspection, trust, and reputation are signals. Policy is the +authority. -```typescript -import { Fides } from '@fides/sdk' +## Evaluate Policy -const fides = new Fides({ - discoveryUrl: 'http://localhost:3100', - trustUrl: 'http://localhost:3200', -}) - -// 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: +For high-risk actions, request dry-run or approval-gated behavior: -```typescript -const fides = new Fides({ - discoveryUrl: 'http://localhost:4000', - trustUrl: 'http://localhost:3200', -}) -``` - -### 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 +pnpm api:audit +pnpm smoke:agentd ``` ## 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/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`. +- Read `docs/protocol/evidence-ledger.md`. +- Read `docs/cli-reference.md`. +- Read `docs/sdk-reference.md`. 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..cb5c7ef 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 | 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 | +| 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 | 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 | +| 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 | `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 | +| 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/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 | +| 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 | +| 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 | 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 | -| 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..9a5c500 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 | 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`. | +| Canonical object signing | Present | `packages/core/src/canonical-signer.ts`. | +| HTTP message signatures | Present in SDK | `packages/sdk/src/signing/`. | +| 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`. | +| 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, 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 | 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`. | +| 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 | 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 | 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. | +| 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, 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/`. | +| 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. +- 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 + +- `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 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. + +## 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. 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. 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. 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. 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 diff --git a/docs/protocol/agent-card.md b/docs/protocol/agent-card.md new file mode 100644 index 0000000..c6080c4 --- /dev/null +++ b/docs/protocol/agent-card.md @@ -0,0 +1,50 @@ +# 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 + +`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 + +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. + +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/docs/protocol/approvals.md b/docs/protocol/approvals.md new file mode 100644 index 0000000..c95c50d --- /dev/null +++ b/docs/protocol/approvals.md @@ -0,0 +1,42 @@ +# 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 `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. + +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 +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/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..0aa9816 --- /dev/null +++ b/docs/protocol/capability-ontology.md @@ -0,0 +1,39 @@ +# 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 + +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`. + +`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. + +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..2aa3496 --- /dev/null +++ b/docs/protocol/delegation-and-sessions.md @@ -0,0 +1,126 @@ +# 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` +- `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` +- `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` +- `required_versions` when applicable +- `negotiated_version` +- `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. + +`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` +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. + +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 +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. `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. + +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 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 +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 +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/docs/protocol/dht-discovery.md b/docs/protocol/dht-discovery.md new file mode 100644 index 0000000..f4503f5 --- /dev/null +++ b/docs/protocol/dht-discovery.md @@ -0,0 +1,50 @@ +# 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` +- `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. +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 +`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. +2. Query DHT for pointers. +3. Verify pointer signature and expiry. +4. Resolve AgentCard. +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. 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 +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/docs/protocol/discovery.md b/docs/protocol/discovery.md new file mode 100644 index 0000000..cb9afc8 --- /dev/null +++ b/docs/protocol/discovery.md @@ -0,0 +1,95 @@ +# 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` +- `packages/discovery/src/relay-provider.ts` +- `packages/discovery/src/dht-provider.ts` +- `packages/discovery/src/federation-provider.ts` +- `services/agentd/src/index.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. + +## 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. + +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. + `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 +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. +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 +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. 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 +non-search peers, but a federated candidate remains untrusted until the normal +AgentCard, trust, reputation, revocation, incident, policy, session, and +evidence pipeline completes. diff --git a/docs/protocol/error-vocabulary.md b/docs/protocol/error-vocabulary.md new file mode 100644 index 0000000..849a8dc --- /dev/null +++ b/docs/protocol/error-vocabulary.md @@ -0,0 +1,72 @@ +# 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, incident, kill +switch, and version errors. + +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` +- `APPROVAL_REQUIRED` +- `APPROVAL_NOT_FOUND` +- `SESSION_EXPIRED` +- `SESSION_NOT_FOUND` +- `ATTESTATION_INVALID` +- `ATTESTATION_NOT_FOUND` +- `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, 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. + +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/protocol/evidence-ledger.md b/docs/protocol/evidence-ledger.md new file mode 100644 index 0000000..11bbc6b --- /dev/null +++ b/docs/protocol/evidence-ledger.md @@ -0,0 +1,64 @@ +# 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 + +`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. + +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: + +- 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. 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. + +## 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/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/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..47749eb --- /dev/null +++ b/docs/protocol/incidents.md @@ -0,0 +1,37 @@ +# 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. + +`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. + +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 +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/interop-adapters.md b/docs/protocol/interop-adapters.md new file mode 100644 index 0000000..4a3511a --- /dev/null +++ b/docs/protocol/interop-adapters.md @@ -0,0 +1,51 @@ +# Interop Adapters + +FIDES exposes adapter interfaces for external protocols without runtime dependencies on those protocols. + +Current implementation anchor: + +- `packages/adapters/src/index.ts` +- `packages/adapters/README.md` + +## Adapter Kinds + +- MCP +- A2A +- OAPS +- OSP +- AP2 +- x402 +- Sardis + +Adapters map identity, AgentCards, capabilities, trust, 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/docs/protocol/kill-switch.md b/docs/protocol/kill-switch.md new file mode 100644 index 0000000..62089bb --- /dev/null +++ b/docs/protocol/kill-switch.md @@ -0,0 +1,34 @@ +# 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. + +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 +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/policy-engine.md b/docs/protocol/policy-engine.md new file mode 100644 index 0000000..09ddc3c --- /dev/null +++ b/docs/protocol/policy-engine.md @@ -0,0 +1,46 @@ +# 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. + +## 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/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..3296faa --- /dev/null +++ b/docs/protocol/registry-federation.md @@ -0,0 +1,63 @@ +# 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` +- `packages/discovery/src/federation-provider.ts` +- `services/registry/src/index.ts` +- `services/agentd/src/index.ts` + +## Registry Modes + +- hosted +- public +- private + +## Records + +- `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. 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`. + +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, +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/docs/protocol/relay-discovery.md b/docs/protocol/relay-discovery.md new file mode 100644 index 0000000..6c9ed5a --- /dev/null +++ b/docs/protocol/relay-discovery.md @@ -0,0 +1,43 @@ +# 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` +- `services/agentd/src/index.ts` +- `packages/sdk/src/relay/client.ts` + +## Relay Provides + +- online presence +- rendezvous +- 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. + +`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 +- 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..ab62021 --- /dev/null +++ b/docs/protocol/reputation-model.md @@ -0,0 +1,24 @@ +# 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 + +- canonical `ReputationRecord` id, issuer, subject, and payload hash +- 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. + +`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/docs/protocol/revocation.md b/docs/protocol/revocation.md new file mode 100644 index 0000000..c2fed00 --- /dev/null +++ b/docs/protocol/revocation.md @@ -0,0 +1,36 @@ +# 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. + +`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. + +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 +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/docs/protocol/runtime-attestation.md b/docs/protocol/runtime-attestation.md new file mode 100644 index 0000000..ea1ad18 --- /dev/null +++ b/docs/protocol/runtime-attestation.md @@ -0,0 +1,59 @@ +# 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 + +## 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. + +## 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/docs/protocol/trust-model.md b/docs/protocol/trust-model.md new file mode 100644 index 0000000..db49bb1 --- /dev/null +++ b/docs/protocol/trust-model.md @@ -0,0 +1,46 @@ +# 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: + +- id +- issuer +- subject +- agent id +- capability +- score +- band +- reasons +- risk flags +- 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 + +- 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..0fac75c --- /dev/null +++ b/docs/protocol/version-negotiation.md @@ -0,0 +1,35 @@ +# 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` +- `packages/core/src/discovery.ts` +- `packages/discovery/src/orchestrator.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. +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 +`rejectedPointers` so callers can explain why a capability match was not usable. diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md new file mode 100644 index 0000000..51040bf --- /dev/null +++ b/docs/sdk-reference.md @@ -0,0 +1,317 @@ +# 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 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) +const card = await client.cards.create({ + identity: identity.identity, + name: 'Invoice Agent', + capabilities: [{ id: 'invoice.reconcile', riskClass: 'medium' }], +}) +await client.cards.sign({ id: identity.identity.did }) +await client.cards.verify(identity.identity.did) +await client.cards.get(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' }) +// 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', + 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.discovery.federation({ capability: 'invoice.reconcile' }) +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', + 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', + agentId: identity.identity.did, + 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', + capabilities: ['invoice.reconcile'], + audience: [identity.identity.did], +}) +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 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 attestation = await client.attestations.runtime({ + 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) +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({ + 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', + supported_versions: ['fides.v2.0'], +}) +await client.dht.publish({ + capability: 'invoice.reconcile', + agentId: identity.identity.did, +}) +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', + 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. +} +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') +} +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({ privacy_mode: 'hash_only', include_metadata: false }) +``` + +`identity.createAgent`, `identity.list`, and `identity.show` target the root +`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 +`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. `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. 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 +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 +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. 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. 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`. 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. 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. +`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. +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 + +`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/docs/status/fides-v2-implementation-status.md b/docs/status/fides-v2-implementation-status.md new file mode 100644 index 0000000..5d9475b --- /dev/null +++ b/docs/status/fides-v2-implementation-status.md @@ -0,0 +1,483 @@ +# 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` +- 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, + 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. +- 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. +- 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. +- 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. +- `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 + `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. +- Runtime attestation schema and local MockTEE provider. +- 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. +- Public target-structure facade packages for crypto, identity, attestations, + cards, trust, reputation, delegation, invocation, DHT, relay, registry, + 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. +- 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. +- 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 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. + +## Production-Like + +- Canonical object signing and verification primitives. +- 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. +- 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, + 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. +- 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. +- 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. +- 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. +- `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. +- 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. +- `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, + 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 + simulation. +- 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. +- 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 + +- 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, denial modes, and optional canonical signed + invocation requests through SDK and CLI. +- 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` | +| 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` | +| Effect-ready runtime workflow boundary | `packages/runtime-effect` | +| 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` | +| Local daemon package boundary | `packages/daemon` | +| Adapters | `packages/adapters` | + +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 + +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 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 +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` +- `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` + +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 verify +pnpm examples:audit +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 +pnpm --filter @fides/cli build +pnpm package:hygiene +pnpm api:audit +pnpm cli:audit +pnpm rust-adapter:audit +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 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 +``` + +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"`. +- 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 + `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`. +- adversarial simulation returned `brokenEvidenceChainValid: false`. + +## Known Limitations + +- The full pivot is not complete. +- 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. +- 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 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 + +- Keep full `pnpm verify` green before release. +- Push `fides-v2-agent-trust-fabric` and open/update a PR. +- 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. + +## Commit History Summary + +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` +- `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` +- `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` +- `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` +- `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` +- `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` diff --git a/examples/README.md b/examples/README.md index 9956068..9171366 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,16 +26,42 @@ pnpm demo npx tsx examples/demo.ts ``` +Type-check every example agent and demo contract: + +```bash +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` + +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. +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. @@ -49,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. @@ -66,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. @@ -84,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. @@ -102,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/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/examples/calendar-agent.ts b/examples/calendar-agent.ts index 4955994..a963911 100644 --- a/examples/calendar-agent.ts +++ b/examples/calendar-agent.ts @@ -1,333 +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 { 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 { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } 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 calendarAgent = createIdentity('did:fides:calendar-agent', 'agent', { - name: 'Calendar Assistant', - version: '1.0.0', - }) - const user = createIdentity('did:fides:user-alice', 'principal', { - name: 'Alice', - type: 'individual', - }) - - 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:create', - name: 'Create Event', - description: 'Create 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', - requiresApproval: false, - requiresRuntimeAttestation: false, - }, - { - id: 'calendar:list', - 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:create', 'calendar:list', '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() - // 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 resolved = await localDiscovery.resolve(calendarAgent.did) - console.log(` Registered: ${resolved ? '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 = createDelegationToken({ - delegator: user.did, - delegatee: calendarAgent.did, - capabilities: ['calendar:create', 'calendar:list'], - constraints: { - maxActions: 50, - allowedContexts: ['work', 'personal'], - }, - expiresAt: new Date(Date.now() + 86400000).toISOString(), // 24h - }) - delegation.signature = 'mock-delegation-sig' - - 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, - } - - // 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:create', - 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:list', - 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, 'mock-signature') - 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:create', - 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:create', - 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() -} - -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/demo.ts b/examples/demo.ts index 04dd47d..e6fcd3a 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -9,16 +9,18 @@ */ import { - createIdentity, + createAgentIdentity, + createPrincipalIdentity, classifyCapabilityRisk, validateAgentCard, createDelegationToken, validateDelegationToken, + signDelegationToken, type AgentCard, type CapabilityDescriptor, } from '@fides/core' -import { evaluatePolicy } from '@fides/policy' -import { createEvidenceChain, appendEvidenceEvent, buildMerkleRoot, verifyEvidenceChain } from '@fides/evidence' +import { evaluatePolicy, type PolicyBundle } from '@fides/policy' +import { createEvidenceChain, appendEvidenceEvent, buildMerkleRoot, verifyEvidenceChain, hashEvidenceValue } from '@fides/evidence' import { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' import { evaluateGuard, createTrustContext } from '@fides/guard' @@ -28,9 +30,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, privateKey: charliePrivateKey } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Charlie User', + }) console.log(` Alice: ${alice.did}`) console.log(` Bob: ${bob.did}`) console.log(` Charlie: ${charlie.did}`) @@ -38,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'] }, @@ -48,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'] }, @@ -67,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', }, ], @@ -85,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'], + capabilities: ['email.send', 'calendar.schedule'], 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 = { @@ -116,18 +121,18 @@ 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}`) 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, 'demo-signature') + chain = appendEvidenceEvent(chain, event, localEvidenceSignature(event)) } console.log(` Events: ${chain.events.length}`) console.log(` Chain valid: ${verifyEvidenceChain(chain)}`) @@ -157,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, @@ -167,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, @@ -177,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, @@ -189,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/full-demo/README.md b/examples/full-demo/README.md new file mode 100644 index 0000000..a75f3fd --- /dev/null +++ b/examples/full-demo/README.md @@ -0,0 +1,38 @@ +# Full Demo + +This directory captures the executable full-demo contract for FIDES v2. + +Run the manifest: + +```bash +pnpm exec tsx examples/full-demo/run.ts +``` + +Run the local daemon endpoint: + +```bash +pnpm agentd demo run --agentd-url http://localhost:7345 +``` + +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: + +- 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. +- 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 new file mode 100644 index 0000000..b56457d --- /dev/null +++ b/examples/full-demo/run.ts @@ -0,0 +1,62 @@ +/** + * Full FIDES v2 demo contract. + * + * This is a deterministic, local-first scenario description that mirrors the + * 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 + */ + +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', + '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', + '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: 'manifest'; execution: string; steps: readonly string[] } { + return { + status: 'manifest', + execution: 'agentd demo run executes this contract against local daemon state and verifies signed provider records without granting discovery authority', + 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..a7df5f8 100644 --- a/examples/invoice-agent.ts +++ b/examples/invoice-agent.ts @@ -1,404 +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 { 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 { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } 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 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', - type: 'individual', - }) - const cfo = createIdentity('did:fides:cfo', 'principal', { - name: 'CFO', - type: 'individual', - }) - - 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:create', - name: 'Create Invoice', - description: 'Generate a new invoice from order data', - 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', - 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:list', - 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:create', 'invoice:approve', 'invoice:list'], - 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 = createDelegationToken({ - delegator: cfo.did, - delegatee: invoiceAgent.did, - capabilities: ['invoice:create', 'invoice:approve'], - constraints: { - maxActions: 100, - maxSpend: '50000.00', - allowedContexts: ['business'], - forbiddenContexts: ['personal', 'test'], - }, - expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(), // 7 days - }) - cfoDelegation.signature = 'mock-cfo-sig' - - 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 = createDelegationToken({ - delegator: financeManager.did, - delegatee: invoiceAgent.did, - capabilities: ['invoice:list', 'invoice:create'], - constraints: { - maxActions: 50, - maxSpend: '10000.00', - allowedContexts: ['business'], - }, - expiresAt: new Date(Date.now() + 86400000).toISOString(), // 24h - }) - mgrDelegation.signature = 'mock-mgr-sig' - - 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() - 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() - - // ─── 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, - } - - // Scenario 1: High trust, normal invoice - const normalResult = evaluatePolicy(invoicePolicy, { - reputationScore: 0.9, - invoiceAmount: 5000, - suspiciousFlags: 0, - }) - console.log(` High trust, $5,000 invoice: ${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: ${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: ${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:create', - 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:create', - 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:create', - 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, 'mock-signature') - 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:create', - policy: invoicePolicy, - context: { invoiceAmount: 5000, suspiciousFlags: 0 }, - trust: goodTrust, - }) - - console.log(` Scenario: Good agent, $5,000 invoice`) - 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:create', - 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(' ✅ Financial risk classification (high 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() -} - -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 new file mode 100644 index 0000000..5a2b006 --- /dev/null +++ b/examples/malicious-agent.ts @@ -0,0 +1 @@ +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 f5c1a2f..d367e49 100644 --- a/examples/payment-agent.ts +++ b/examples/payment-agent.ts @@ -1,437 +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 { 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 { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } 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 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', - type: 'organization', - }) - const customer = createIdentity('did:fides:customer-bob', 'principal', { - name: 'Bob Customer', - type: 'individual', - }) - - 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: 'payment:charge', - name: 'Charge Payment', - description: 'Process a payment charge', - 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' } } }, - 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', - 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 = { - id: paymentAgent.did, - identity: paymentAgent, - capabilities, - endpoints: [ - { - url: 'https://payment-agent.example.com/fides', - protocol: 'https', - capabilities: ['payment:charge', 'payment:refund', 'payment:status'], - 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 = createDelegationToken({ - delegator: merchant.did, - delegatee: paymentAgent.did, - capabilities: ['payment:charge', 'payment:refund'], - constraints: { - maxActions: 1000, - maxSpend: '100000.00', - allowedContexts: ['production'], - forbiddenContexts: ['test', 'staging'], - }, - expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), // 30 days - }) - merchantDelegation.signature = 'mock-merchant-sig' - - 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() - 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() - - // ─── 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, - } - - // 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: 'payment:charge', - 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: 'payment:charge', - 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: 'payment:charge', - 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: 'payment:charge', - 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: 'payment:refund', - target: 'REF-001', - payload: { transactionId: 'TXN-001', amount: 500 }, - privacy: { level: 'redacted' as const }, - }, - ] - - for (const evt of paymentEvents) { - evidenceChain = appendEvidenceEvent(evidenceChain, evt, 'mock-signature') - 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: 'payment:charge', - 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: 'payment:charge', - 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: 'payment:charge', - 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 (payment keywords)') - 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() -} - -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 6046f27..01d6e9b 100644 --- a/examples/requester-agent.ts +++ b/examples/requester-agent.ts @@ -1,507 +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 { 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 { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, buildMerkleRoot } 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 requesterAgent = createIdentity('did:fides:requester', 'agent', { - name: 'Task Orchestrator', - version: '1.0.0', - }) - const user = createIdentity('did:fides:user-alice', 'principal', { - name: 'Alice', - type: 'individual', - }) - - 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 calendarAgent = createIdentity('did:fides:calendar-svc', 'agent', { - name: 'Calendar Service', - }) - const calendarCapabilities: CapabilityDescriptor[] = [ - { - id: 'calendar:create', - name: 'Create Event', - description: 'Create 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', - requiresApproval: false, - requiresRuntimeAttestation: false, - }, - { - id: 'calendar:list', - 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:create', 'calendar:list'], auth: 'signature' }, - ], - policies: [{ requiresRuntimeAttestation: false, requiresApproval: false, minTrustScore: 0.5 }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - - // Payment service provider - const paymentAgent = createIdentity('did:fides:payment-svc', 'agent', { - name: 'Payment Service', - }) - const paymentCapabilities: CapabilityDescriptor[] = [ - { - id: 'payment:charge', - name: 'Charge Payment', - description: 'Process a payment charge', - inputSchema: { type: 'object', properties: { amount: { type: 'number' }, currency: { type: 'string' } }, required: ['amount', 'currency'] }, - outputSchema: { type: 'object', properties: { transactionId: { 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' }, - ], - policies: [{ requiresRuntimeAttestation: true, requiresApproval: true, minTrustScore: 0.8 }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - - // Invoice service provider - const invoiceAgent = createIdentity('did:fides:invoice-svc', 'agent', { - name: 'Invoice Service', - }) - const invoiceCapabilities: CapabilityDescriptor[] = [ - { - id: 'invoice:create', - name: 'Create Invoice', - description: 'Generate an invoice', - inputSchema: { type: 'object', properties: { orderId: { type: 'string' }, amount: { type: 'number' } }, required: ['orderId', 'amount'] }, - outputSchema: { type: 'object', properties: { invoiceId: { type: 'string' } } }, - riskLevel: 'high', - requiresApproval: false, - requiresRuntimeAttestation: true, - }, - { - id: 'invoice:list', - 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:create', 'invoice:list'], 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() - localDiscovery.registerCard(calendarCard) - localDiscovery.registerCard(paymentCard) - localDiscovery.registerCard(invoiceCard) - - 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() - console.log(` Total agents in discovery: ${allAgents.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({ - delegator: user.did, - delegatee: requesterAgent.did, - capabilities: ['calendar:create', 'payment:charge', 'invoice:create'], - constraints: { - maxActions: 20, - maxSpend: '5000.00', - allowedContexts: ['work'], - }, - expiresAt: new Date(Date.now() + 86400000).toISOString(), - }) - userDelegation.signature = 'mock-user-sig' - - 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, - } - - 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:create', - 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:create successfully`) - evidenceChain.events.length // just to reference the chain - - // Record evidence - const calendarEvent = { - id: 'req-001', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: requesterAgent.did, - action: 'calendar:create', - target: calendarAgent.did, - 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) - } else { - console.log(` │ 1d. ❌ Invocation blocked: ${calendarGuard.explanation}`) - } - console.log(` │`) - - // Flow 2: Invoke payment service (high risk, high trust) - console.log(` ┌─ Flow 2: Process 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: 'payment:charge', - policy: requesterPolicy, - context: { requestCount: 5, reputationScore: paymentTrustScore }, - trust: paymentTrust, - }) - console.log(` │ 2c. Guard decision: ${paymentGuard.decision}`) - - if (paymentGuard.decision === 'allow') { - console.log(` │ 2d. ✅ Invoked payment:charge successfully`) - const paymentEvent = { - id: 'req-002', - type: 'capability_invoke', - timestamp: new Date().toISOString(), - actor: requesterAgent.did, - action: 'payment:charge', - target: paymentAgent.did, - 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) - } else { - console.log(` │ 2d. ❌ Invocation blocked: ${paymentGuard.explanation}`) - } - console.log(` │`) - - // Flow 3: Invoke invoice service (high risk, medium trust) - console.log(` ┌─ Flow 3: Create 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:create', - 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:create 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:create', 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: 'payment:charge', 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:create', decision: invoiceGuard.decision }, - privacy: { level: 'public' as const }, - }, - ] - - let fullChain = createEvidenceChain() - for (const evt of [...evidenceChain.events, ...policyEvents]) { - fullChain = appendEvidenceEvent(fullChain, evt, 'mock-signature') - 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() -} - -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/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/package.json b/package.json index 3cf0510..6d4b2e2 100644 --- a/package.json +++ b/package.json @@ -17,16 +17,26 @@ "test": "turbo run test", "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", + "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", - "verify": "pnpm package:hygiene && pnpm build && pnpm package:packcheck && pnpm lint && pnpm typecheck && pnpm test", + "rust-adapter:audit": "node scripts/audit-rust-adapter-readiness.mjs", + "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", + "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", "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/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..86c2378 --- /dev/null +++ b/packages/adapters/README.md @@ -0,0 +1,47 @@ +# @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. + +## 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. +- `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/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..30e7d47 --- /dev/null +++ b/packages/adapters/src/index.ts @@ -0,0 +1,389 @@ +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', + 'oaps', + 'osp', + 'ap2', + 'x402', + 'sardis', +] as const + +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 + 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 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 + fidesAgentId?: string + fidesPublisherId?: string + fidesPrincipalId?: string + capabilityIds?: string[] + supportsDelegation?: boolean + supportsPolicy?: boolean + supportsEvidence?: boolean + supportsInvocation?: boolean + 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 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 + toFidesMapping(external: TExternal): Promise | AdapterMapping + toFidesMappingSet?(external: TExternal): Promise | InteropMappingSet + fromFidesMappingSet?(mapping: InteropMappingSet): Promise | TExternal +} + +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 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', + 'trust', + '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, + } +} + +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 new file mode 100644 index 0000000..1a1036f --- /dev/null +++ b/packages/adapters/test/adapters.test.ts @@ -0,0 +1,217 @@ +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', () => { + it('declares the required adapter kinds without external runtime dependencies', () => { + expect(ADAPTER_KINDS).toEqual([ + 'mcp', + 'a2a', + 'oaps', + 'osp', + 'ap2', + 'x402', + 'sardis', + ]) + }) + + 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', + 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) + }) + + 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', + 'trust', + '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')).toContain('trust') + 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'], + }) + }) + + 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/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/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..dfc29d4 --- /dev/null +++ b/packages/attestations/README.md @@ -0,0 +1,54 @@ +# @fides/attestations + +Generic attestations, runtime attestations, and TEE-ready provider entrypoints +for FIDES v2. + +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 + +```bash +npm install @fides/attestations +``` + +## Usage + +```typescript +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 + +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 + +MIT diff --git a/packages/attestations/package.json b/packages/attestations/package.json new file mode 100644 index 0000000..d579b75 --- /dev/null +++ b/packages/attestations/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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..283827f --- /dev/null +++ b/packages/attestations/src/index.ts @@ -0,0 +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 new file mode 100644 index 0000000..2178907 --- /dev/null +++ b/packages/attestations/test/facade.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +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)}` + 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/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..babf66a --- /dev/null +++ b/packages/cards/README.md @@ -0,0 +1,36 @@ +# @fides/cards + +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 { + 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/cards/package.json b/packages/cards/package.json new file mode 100644 index 0000000..a854761 --- /dev/null +++ b/packages/cards/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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/cli/README.md b/packages/cli/README.md index 0414564..ebe79b9 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,16 +17,49 @@ The CLI wraps identity initialization, request signing and verification, discove npm install -g @fides/cli ``` +From the monorepo checkout: + +```bash +pnpm agentd +``` + +Use `pnpm --silent agentd ... --json` when piping JSON output to another tool, +because pnpm prints script banners by default. + ## Usage ```bash -fides init --name payment-agent -fides sign https://api.example.com/data --method GET -fides card publish agent-card.json --registry-url http://localhost:7346 -fides daemon status --agentd-url http://localhost:7345 +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 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 + +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` 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 diff --git a/packages/cli/package.json b/packages/cli/package.json index 54ebb11..cc4442a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,12 +16,15 @@ "node": ">=22.0.0" }, "bin": { - "fides": "./dist/index.js" + "fides": "./dist/index.js", + "agentd": "./dist/index.js" }, "files": ["dist", "README.md", "LICENSE"], "sideEffects": false, "scripts": { "build": "tsc", + "fides": "node dist/index.js", + "agentd": "node dist/index.js", "lint": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", 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/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/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/commands/attest.ts b/packages/cli/src/commands/attest.ts new file mode 100644 index 0000000..b404ded --- /dev/null +++ b/packages/cli/src/commands/attest.ts @@ -0,0 +1,121 @@ +import { Command } from 'commander' +import { getJson, 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('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') + .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) + }) + + 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 +} + +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/src/commands/authority-utils.ts b/packages/cli/src/commands/authority-utils.ts index 5f445a7..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,11 +99,15 @@ 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 requestJson(url, { method: 'GET', headers }) +} + +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 } - return payload + + return requestJson(url, { method: 'DELETE', headers }) } 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/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 4b2e477..58aac98 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 } } @@ -29,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) @@ -45,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) { @@ -148,10 +162,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]) } @@ -184,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/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/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..e4b7913 --- /dev/null +++ b/packages/cli/src/commands/dht.ts @@ -0,0 +1,75 @@ +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('[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`, { + 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) { + 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/discover.ts b/packages/cli/src/commands/discover.ts index d25d0f3..299bd08 100644 --- a/packages/cli/src/commands/discover.ts +++ b/packages/cli/src/commands/discover.ts @@ -2,15 +2,34 @@ 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 { parseList, postJson, printResult } from './authority-utils.js'; + +const DISCOVERY_PROVIDERS = ['local', 'well-known', 'registry', 'relay', 'dht', 'federation'] 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, 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') + .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) => { 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 +40,78 @@ export function createDiscoverCommand(): Command { return cmd; } +async function discoverCapability( + intent: string | undefined, + options: { + capability: string + provider?: string + allProviders?: boolean + constraints?: string + supportedVersions?: string + requiredVersions?: string + agentdUrl: string + json?: boolean + } +): Promise { + 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 } : {}), + 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}` + 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), + } + } + })) + + 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/src/commands/evidence.ts b/packages/cli/src/commands/evidence.ts new file mode 100644 index 0000000..047a625 --- /dev/null +++ b/packages/cli/src/commands/evidence.ts @@ -0,0 +1,95 @@ +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('--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 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)) + process.exitCode = 1 + } + }) + + return cmd +} + +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/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/commands/identity.ts b/packages/cli/src/commands/identity.ts index 7679854..d7eea20 100644 --- a/packages/cli/src/commands/identity.ts +++ b/packages/cli/src/commands/identity.ts @@ -1,11 +1,159 @@ 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' +import { getJson, postJson, printResult } from './authority-utils.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('--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, + }) + 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('--agentd-url ', 'List identities through a local agentd root v2 API instead of local files') + .option('--json', 'Emit JSON output') + .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, + 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('--agentd-url ', 'Read the identity through a local agentd root v2 API instead of local files') + .option('--json', 'Emit JSON output') + .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}`) + 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 +219,115 @@ 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 +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, '') +} 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/invoke.ts b/packages/cli/src/commands/invoke.ts new file mode 100644 index 0000000..1d585cc --- /dev/null +++ b/packages/cli/src/commands/invoke.ts @@ -0,0 +1,183 @@ +import { readFileSync } from 'node:fs' +import { + createInvocationRequest, + deriveEd25519PublicKeyHex, + didFromPublicKey, + signInvocationRequest, + type SessionGrantV2, + type SignedInvocationRequest, +} from '@fides/core' +import { Command } from 'commander' +import { getJson, 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('--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') + .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 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) { + 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 {} +} + +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 + 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') + } + 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 as SessionGrantV2 +} 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/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/src/commands/registry.ts b/packages/cli/src/commands/registry.ts new file mode 100644 index 0000000..4b13534 --- /dev/null +++ b/packages/cli/src/commands/registry.ts @@ -0,0 +1,81 @@ +import { Command } from 'commander' +import { getJson, parseList, 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('--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) { + 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..e191a6d 100644 --- a/packages/cli/src/commands/relay.ts +++ b/packages/cli/src/commands/relay.ts @@ -1,10 +1,64 @@ 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') .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('--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) { + 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 +154,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/commands/reputation.ts b/packages/cli/src/commands/reputation.ts new file mode 100644 index 0000000..89e56d9 --- /dev/null +++ b/packages/cli/src/commands/reputation.ts @@ -0,0 +1,81 @@ +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') + .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') + .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(/\/+$/, '') +} + +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/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/src/commands/session.ts b/packages/cli/src/commands/session.ts index b9fed90..dc3dafa 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') @@ -18,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) @@ -52,3 +113,33 @@ export function createSessionCommand(): Command { return cmd } + +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/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/commands/trust.ts b/packages/cli/src/commands/trust.ts index 4098869..7bee7a4 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,25 @@ 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.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) { + 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 +111,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 70f01af..bf69c8f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,10 +5,13 @@ 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'; 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'; @@ -16,16 +19,25 @@ 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'; +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'; +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' }; const program = new Command(); program - .name('fides') + .name(inferCliName()) .version(packageJson.version) .description('FIDES v2 - Agent Trust Fabric'); @@ -34,10 +46,13 @@ program.addCommand(createInitCommand()); program.addCommand(createSignCommand()); program.addCommand(createVerifyCommand()); program.addCommand(createTrustCommand()); +program.addCommand(createGraphCommand()); +program.addCommand(createReputationCommand()); program.addCommand(createDiscoverCommand()); program.addCommand(createStatusCommand()); program.addCommand(createCardCommand()); program.addCommand(createPolicyCommand()); +program.addCommand(createApprovalCommand()); program.addCommand(createRuntimeCommand()); program.addCommand(createKillswitchCommand()); program.addCommand(createDaemonCommand()); @@ -45,9 +60,23 @@ program.addCommand(createDelegateCommand()); program.addCommand(createSessionCommand()); program.addCommand(createRevokeCommand()); program.addCommand(createIncidentCommand()); +program.addCommand(createAttestCommand()); program.addCommand(createPropagationCommand()); 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()); +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/commands.test.ts b/packages/cli/test/commands.test.ts index 158e2ad..f0468b7 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'), }, @@ -107,6 +108,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 = { @@ -145,6 +162,225 @@ describe('CLI Commands', () => { }); }); + describe('v2 command surface', () => { + 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 { 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'); + 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(createRegisterCommand().name()).toBe('register'); + 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'); + expect(createDemoCommand().name()).toBe('demo'); + 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', () => { + 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', + 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({ + 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' }, + }); + }); + + 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', () => { it('publishes a validated AgentCard to the registry', async () => { const fs = await import('node:fs'); @@ -239,6 +475,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', () => { @@ -412,9 +707,208 @@ 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"}', + '--supported-versions', + 'fides.v2.0,fides.v2.1', + '--required-versions', + 'fides.v2.0', + '--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' }, + supported_versions: ['fides.v2.0', 'fides.v2.1'], + required_versions: ['fides.v2.0'], + }), + }) + ); + }); + + 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' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 6, + 'http://agentd.test/discover/federation', + expect.objectContaining({ method: 'POST' }) + ); + 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', () => { + 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(); @@ -525,6 +1019,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', @@ -534,11 +1034,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); @@ -565,11 +1093,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); @@ -579,6 +1113,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(); }); @@ -591,11 +1128,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); @@ -606,6 +1149,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); }); }); @@ -635,6 +1179,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({ @@ -679,27 +1268,85 @@ 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 a delegator public key when provided', async () => { + 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 token = { - id: 'tok-1', - delegator: 'did:fides:principal', - delegatee: 'did:fides:agent', - capabilities: ['payments.execute'], - constraints: {}, - issuedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 3600_000).toISOString(), - nonce: 'nonce-1', - audience: ['agentd'], - signature: 'sig', + 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 delegatorPublicKey = '11'.repeat(32); + + 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 () => { + const mockFetch = vi.fn(async () => new Response(JSON.stringify({ + session: { id: 'sess-1', sessionKey: 'redacted' }, + }), { status: 201, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch; + vi.stubGlobal('fetch', mockFetch); + + const token = { + id: 'tok-1', + delegator: 'did:fides:principal', + delegatee: 'did:fides:agent', + capabilities: ['payments.execute'], + constraints: {}, + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + nonce: 'nonce-1', + audience: ['agentd'], + signature: 'sig', + }; + const delegatorPublicKey = '11'.repeat(32); const { createSessionCommand } = await import('../src/commands/session.js'); const cmd = createSessionCommand(); @@ -760,6 +1407,515 @@ 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('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('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' }, + 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('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('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('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: [ + { agent_id: 'did:fides:agent', capability: 'invoice.reconcile' }, + { agent_id: 'did:fides:agent', capability: 'calendar.schedule' }, + ], + 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(); + const reputationShortcut = 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' }); + await reputationShortcut.parseAsync([ + 'did:fides:agent', + '--capability', + 'invoice.reconcile', + '--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' })); + 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('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' }, + 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, @@ -971,6 +2127,414 @@ 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', + '--supported-versions', + 'fides.v2.0', + '--required-versions', + 'fides.v2.0', + '--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', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }), + }) + ); + }); + + 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', + '--supported-versions', + 'fides.v2.0', + '--required-versions', + 'fides.v2.0', + '--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', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }), + }) + ); + }); + + 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, + 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', + }), + }) + ); + }); + + 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('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({ @@ -999,5 +2563,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, + }), + }) + ); + }); }); }); diff --git a/packages/cli/test/entrypoint.test.ts b/packages/cli/test/entrypoint.test.ts new file mode 100644 index 0000000..cc9a237 --- /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: 15000, + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Error: fetch failed') + expect(result.stderr).not.toContain('UnhandledPromiseRejection') + }) +}) 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() + }) +}) 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') + }) +}) 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/core/src/agent-card.ts b/packages/core/src/agent-card.ts index 1961d1a..dbb3aa3 100644 --- a/packages/core/src/agent-card.ts +++ b/packages/core/src/agent-card.ts @@ -7,8 +7,12 @@ */ 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' +import { FIDES_PROTOCOL_VERSION } from './protocol.js' +import bs58 from 'bs58' export interface EndpointDescriptor { /** Endpoint URL */ @@ -21,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 @@ -33,8 +50,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) */ @@ -43,8 +64,24 @@ 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. */ + 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 +91,27 @@ 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) +} + +export async function verifySignedAgentCardIdentity(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. */ @@ -67,9 +125,56 @@ 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') + 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`) + } + } + 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/src/approval.ts b/packages/core/src/approval.ts new file mode 100644 index 0000000..01658e3 --- /dev/null +++ b/packages/core/src/approval.ts @@ -0,0 +1,204 @@ +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 + issuer: string + subject: 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 + issuer: string + subject: 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(), + issuer: input.requesterAgentId, + subject: input.targetAgentId, + 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(), + issuer: input.approverId, + subject: input.approvalRequestId, + 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 async function verifySignedApprovalRequestIssuer(signed: SignedApprovalRequest): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedApprovalRequest(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 async function verifySignedApprovalDecisionIssuer(signed: SignedApprovalDecision): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedApprovalDecision(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) +} + +export async function verifySignedKillSwitchRuleIssuer(signed: SignedKillSwitchRule): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedKillSwitchRule(signed) +} diff --git a/packages/core/src/capability.ts b/packages/core/src/capability.ts index dd41d96..8cc3317 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,186 @@ 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 findCapabilityOntologyEntry(id: string): CapabilityOntologyEntry | undefined { + return DEFAULT_CAPABILITY_ONTOLOGY.find(entry => entry.id === id) +} + +export function createCapabilityDescriptor(input: { + id: string + namespace?: string + action?: string + resource?: string + name?: string + description?: string + inputSchema?: JSONSchema + outputSchema?: JSONSchema + riskLevel?: CapabilityDescriptor['riskLevel'] + requiredScopes?: string[] + supportedControls?: CapabilityControl[] + supportsDryRun?: boolean + 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, + 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 ?? ontologyEntry?.description ?? input.id, + inputSchema: input.inputSchema ?? { type: 'object' }, + outputSchema: input.outputSchema ?? { type: 'object' }, + 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'), + } +} + +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/delegation.ts b/packages/core/src/delegation.ts index b8f47af..60225ce 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 { FIDES_PROTOCOL_VERSION, FIDES_SUPPORTED_PROTOCOL_VERSIONS, hashProtocolPayload } from './protocol.js' import * as ed from '@noble/ed25519' import { bytesToHex } from '@noble/hashes/utils' @@ -31,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 @@ -39,6 +58,32 @@ export interface SessionGrant { boundTo?: string } +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 + capability: string + scopes: string[] + constraints: Record + policy_hash: string + trust_result_hash: string + issued_at: string + expires_at: string + nonce: string + audience: string[] + supported_versions: string[] + required_versions?: string[] + negotiated_version: string + issuer: string + payload_hash: string +} + +export type SignedSessionGrantV2 = SignedObject + export interface DelegationInput { delegator: string delegatee: string @@ -48,6 +93,36 @@ 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 + principalId: string + capability: string + scopes: string[] + constraints?: Record + policyHash: string + trustResultHash: string + audience?: string[] + supportedVersions?: string[] + requiredVersions?: string[] + negotiatedVersion?: string + issuer: string + issuedAt?: string + expiresAt: string + nonce?: string +} + export function createDelegationToken(input: DelegationInput): DelegationToken { return { id: crypto.randomUUID(), @@ -63,6 +138,65 @@ 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 + ? 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, + session_id: sessionId, + subject: input.targetAgentId, + 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], + supported_versions: supportedVersions, + ...(input.requiredVersions?.length ? { required_versions: input.requiredVersions } : {}), + negotiated_version: negotiatedVersion, + 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 +233,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 +249,109 @@ 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') + 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') + 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') + 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, + verificationMethod: string +): Promise { + return signObject(session, privateKey, { verificationMethod, proofPurpose: 'delegation' }) +} + +export function verifySignedSessionGrantV2(signed: SignedSessionGrantV2): Promise { + 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/src/dht.ts b/packages/core/src/dht.ts new file mode 100644 index 0000000..468b97b --- /dev/null +++ b/packages/core/src/dht.ts @@ -0,0 +1,124 @@ +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 (!options.card.capabilities.some(capability => capability.id === record.capability)) { + errors.push('DHT pointer capability is not advertised by AgentCard') + } + } + + 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, + 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/discovery.ts b/packages/core/src/discovery.ts new file mode 100644 index 0000000..ad23676 --- /dev/null +++ b/packages/core/src/discovery.ts @@ -0,0 +1,96 @@ +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' + 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 + authority: 'candidate_only' + verified: boolean + rank: number + explanations: string[] + evidence_refs: string[] + errors: ErrorEnvelope[] + versionNegotiation?: VersionNegotiationRecord +} + +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[] + evidenceRefs?: string[] + errors?: ErrorEnvelope[] + versionNegotiation?: VersionNegotiationRecord +}): 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 }), + 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 }), + } +} + +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/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..5f0caf9 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,273 @@ +export type FidesErrorCategory = + | 'identity' + | 'agent_card' + | 'capability' + | 'trust' + | 'policy' + | 'approval' + | 'session' + | 'attestation' + | 'discovery' + | 'dht' + | 'evidence' + | 'revocation' + | 'incident' + | 'kill_switch' + | 'version' + | 'request' + | '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', + }, + IDENTITY_KEY_UNBOUND: { + category: 'identity', + severity: 'critical', + 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', + retryable: false, + message: 'AgentCard 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', + }, + 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', + 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', + }, + APPROVAL_NOT_FOUND: { + category: 'approval', + severity: 'error', + retryable: false, + message: 'Approval request was not found', + }, + SESSION_EXPIRED: { + category: 'session', + severity: 'error', + 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', + 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', + }, + ATTESTATION_NOT_FOUND: { + category: 'attestation', + severity: 'error', + retryable: false, + message: 'Attestation was not found', + }, + 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', + }, + 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', + retryable: false, + message: 'An active incident requires review before execution', + }, + INCIDENT_INVALID: { + category: 'incident', + severity: 'error', + 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 + +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/identity.ts b/packages/core/src/identity.ts index 0a9971e..739b2e9 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,10 @@ 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) */ publisher?: PublisherIdentity /** The principal this agent acts for (optional) */ @@ -28,6 +65,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 +74,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 +91,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 +107,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 +153,108 @@ 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) + 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') + } + 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. + * Creates an AgentIdentity from an existing did:fides identifier. + * + * Deprecated: new code should use createAgentIdentity/createPublisherIdentity/ + * createPrincipalIdentity so the private key material is returned with the + * identity. This compatibility helper is intentionally fail-closed: a FIDES + * identity must not invent a public key that is not bound to its DID. */ export function createIdentity(did: string, type: 'agent' | 'publisher' | 'principal' | 'trust-anchor', metadata: Record = {}): AgentIdentity & { metadata: Record } { - const publicKey = crypto.getRandomValues(new Uint8Array(32)) + const publicKey = publicKeyFromDid(did) + return { did, publicKey, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ff94ddf..bc280c1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,12 +12,23 @@ */ 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' 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 './dht.js' +export * from './trust.js' +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/invocation.ts b/packages/core/src/invocation.ts new file mode 100644 index 0000000..f897437 --- /dev/null +++ b/packages/core/src/invocation.ts @@ -0,0 +1,337 @@ +import { signObject, verifyObject, type SignedObject } from './canonical-signer.js' +import { hashProtocolPayload } from './protocol.js' +import type { JSONSchema } from './capability.js' +import { isSessionGrantV2Expired, 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 + subject: 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 + subject: 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 interface SchemaValidationResult { + valid: boolean + 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, + 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, + 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, + subject: input.invocationRequestId, + 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 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`) + } + } + + 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 } +} + +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, + verificationMethod: string +): Promise { + return signObject(request, privateKey, { verificationMethod, proofPurpose: 'capabilityInvocation' }) +} + +export function verifySignedInvocationRequest(signed: SignedInvocationRequest): Promise { + 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, + verificationMethod: string +): Promise { + return signObject(result, privateKey, { verificationMethod, proofPurpose: 'capabilityInvocation' }) +} + +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/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/registry.ts b/packages/core/src/registry.ts new file mode 100644 index 0000000..abce234 --- /dev/null +++ b/packages/core/src/registry.ts @@ -0,0 +1,147 @@ +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 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, + verificationMethod: string +): Promise { + return signObject(record, privateKey, { verificationMethod, proofPurpose: 'assertionMethod' }) +} + +export function verifySignedRegistryIndexRecord(signed: SignedRegistryIndexRecord): Promise { + 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, + verificationMethod: string +): Promise { + return signObject(record, privateKey, { verificationMethod, proofPurpose: 'assertionMethod' }) +} + +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/src/reputation.ts b/packages/core/src/reputation.ts new file mode 100644 index 0000000..467da8b --- /dev/null +++ b/packages/core/src/reputation.ts @@ -0,0 +1,103 @@ +import { hashProtocolPayload, type HashValue } from './protocol.js' + +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' + id: string + issuer: string + subject: string + 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 + payload_hash: HashValue +} + +export interface ComputeCapabilityReputationInput { + agentId: string + issuer?: string + recordId?: 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 computedAt = input.computedAt ?? new Date().toISOString() + + const score = clamp01( + (successRate * 0.48) + + (volumeConfidence * 0.22) + + (publisherWeight * 0.20) + + 0.10 - + incidentPenalty - + contextBoundaryPenalty + ) + + 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, + 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: computedAt, + } satisfies Omit + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + +export function createReputationRecord(input: ComputeCapabilityReputationInput): ReputationRecord { + return computeCapabilityReputation(input) +} diff --git a/packages/core/src/revocation.ts b/packages/core/src/revocation.ts index bd69958..51cba0f 100644 --- a/packages/core/src/revocation.ts +++ b/packages/core/src/revocation.ts @@ -5,10 +5,90 @@ * 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 + subject: 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 + issuer: string + subject: 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 +142,101 @@ 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, + subject: input.targetId, + 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(), + issuer: input.reporter, + subject: input.targetAgentId, + 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 async function verifySignedRevocationRecordV2Issuer(signed: SignedRevocationRecordV2): Promise { + return signed.proof.verificationMethod === signed.payload.issuer && await verifySignedRevocationRecordV2(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) +} + +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/src/runtime-attestation.ts b/packages/core/src/runtime-attestation.ts new file mode 100644 index 0000000..0f0ea76 --- /dev/null +++ b/packages/core/src/runtime-attestation.ts @@ -0,0 +1,239 @@ +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 + 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 + code_hash: string + runtime_hash: string + policy_hash: string + enclave_measurement?: string + issued_at: string + expires_at: string + payload_hash: 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 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 +} + +export function createRuntimeAttestation(input: RuntimeAttestationIssueInput & { + provider: RuntimeAttestation['provider'] + issuer?: string + signature?: string +}): RuntimeAttestation { + const issuedAt = input.issuedAt ?? new Date().toISOString() + const id = crypto.randomUUID() + const payload = { + schema_version: 'fides.runtime_attestation.v1', + id, + issuer: input.issuer ?? input.provider, + subject: input.agentId, + attestation_id: id, + 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(), + } satisfies Omit + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + signature: input.signature ?? localRuntimeAttestationSignature(payload), + } +} + +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, + }) + } + + async verify(attestation: RuntimeAttestation): Promise { + if (attestation.provider !== this.provider) return false + if (isRuntimeAttestationExpired(attestation)) return false + 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) && + 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) +} + +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/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/src/trust.ts b/packages/core/src/trust.ts new file mode 100644 index 0000000..b193860 --- /dev/null +++ b/packages/core/src/trust.ts @@ -0,0 +1,171 @@ +import type { CapabilityControl, CapabilityDescriptor } from './capability.js' +import { hashProtocolPayload, type HashValue } from './protocol.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' + id: string + issuer: string + subject: string + agent_id: string + capability: string + score: number + band: TrustBand + reasons: TrustReason[] + risk_flags: string[] + 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[] + 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') + + 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)), + band: trustBandForScore(score), + reasons, + risk_flags: riskFlags, + evidence_refs: input.evidenceRefs ?? [], + required_controls: Array.from(requiredControls), + computed_at: computedAt, + } satisfies Omit + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} 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/agent-card.test.ts b/packages/core/test/agent-card.test.ts index 6b0c320..3b4aace 100644 --- a/packages/core/test/agent-card.test.ts +++ b/packages/core/test/agent-card.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from 'vitest' -import { validateAgentCard } 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' describe('AgentCard', () => { const validCard: AgentCard = { @@ -52,5 +59,106 @@ describe('AgentCard', () => { expect(result.valid).toBe(false) 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, + 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', () => { + 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 () => { + 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, + }, + ], + 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', + } + + 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(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) + 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) + }) }) }) diff --git a/packages/core/test/approval.test.ts b/packages/core/test/approval.test.ts new file mode 100644 index 0000000..8e6326e --- /dev/null +++ b/packages/core/test/approval.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { createIdentityKeyPair } from '../src/identity.js' +import { + createApprovalDecision, + createApprovalRequest, + createKillSwitchRule, + isKillSwitchRuleActive, + signApprovalDecision, + 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: requester.did, + 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, 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, + approverId: approver.did, + decision: 'approved', + reason: 'Runtime attestation accepted', + constraints: { dryRunOnly: true }, + }) + + 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) + expect(await verifySignedApprovalDecisionIssuer(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) + 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) + }) +}) diff --git a/packages/core/test/capability.test.ts b/packages/core/test/capability.test.ts index 3c63ae0..1e6ea53 100644 --- a/packages/core/test/capability.test.ts +++ b/packages/core/test/capability.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest' -import { classifyCapabilityRisk } from '../src/capability.js' +import { + DEFAULT_CAPABILITY_ONTOLOGY, + classifyCapabilityRisk, + createCapabilityDescriptor, + findCapabilityOntologyEntry, + parseCapabilityId, +} from '../src/capability.js' describe('CapabilityDescriptor', () => { describe('classifyCapabilityRisk', () => { @@ -23,4 +29,92 @@ 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 explicit 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: 'high', + requiredScopes: ['payments:prepare'], + requiresApproval: true, + requiresRuntimeAttestation: true, + supportsDryRun: true, + supportsHumanApproval: true, + supportsPolicyProof: true, + }) + }) + + 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) + + 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']), + }) + }) + }) }) 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() diff --git a/packages/core/test/dht.test.ts b/packages/core/test/dht.test.ts new file mode 100644 index 0000000..3d70523 --- /dev/null +++ b/packages/core/test/dht.test.ts @@ -0,0 +1,118 @@ +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 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({ + ...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') + }) + + 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') + }) +}) diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts new file mode 100644 index 0000000..0120c77 --- /dev/null +++ b/packages/core/test/errors.test.ts @@ -0,0 +1,106 @@ +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) + }) + + 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', + }) + }) + + it('covers root local API validation and lookup failures', () => { + expect(createErrorEnvelope('REQUEST_INVALID')).toMatchObject({ + code: 'REQUEST_INVALID', + 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', + }) + 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/packages/core/test/identity.test.ts b/packages/core/test/identity.test.ts index 10d0f7a..785fe9d 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,71 @@ 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) + }) + + 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', () => { it('should return displayName for principal', () => { const principal: PrincipalIdentity = { diff --git a/packages/core/test/invocation.test.ts b/packages/core/test/invocation.test.ts new file mode 100644 index 0000000..483a64f --- /dev/null +++ b/packages/core/test/invocation.test.ts @@ -0,0 +1,222 @@ +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, + validateInvocationRequestAgainstSessionGrant, + validateJsonSchemaValue, + verifySignedInvocationRequestIssuer, + verifySignedInvocationRequest, + verifySignedInvocationResultIssuer, + 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() + 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) + 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 () => { + 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('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('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({ + issuer: target.did, + invocationRequestId: 'inv_req_1', + status: 'completed', + output: { ok: true }, + evidenceRefs: ['evt_1'], + }) + + 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) + expect(await verifySignedInvocationResultIssuer(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/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/registry.test.ts b/packages/core/test/registry.test.ts new file mode 100644 index 0000000..5fd4e87 --- /dev/null +++ b/packages/core/test/registry.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { createIdentityKeyPair } from '../src/identity.js' +import { + createRegistryIndexRecord, + createRegistryPeerRecord, + isRegistryIndexRecordExpired, + isRegistryPeerRecordExpired, + signRegistryIndexRecord, + signRegistryPeerRecord, + verifySignedRegistryIndexRecord, + verifySignedRegistryIndexRecordIssuer, + verifySignedRegistryPeerRecord, + verifySignedRegistryPeerRecordIssuer, +} 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) + expect(await verifySignedRegistryIndexRecordIssuer(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) + 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 () => { + 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/core/test/reputation.test.ts b/packages/core/test/reputation.test.ts new file mode 100644 index 0000000..82effb0 --- /dev/null +++ b/packages/core/test/reputation.test.ts @@ -0,0 +1,87 @@ +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', + issuer: 'did:fides:reputation-engine', + recordId: 'rep_payments_execute', + 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(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) + }) + + 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.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) + }) +}) 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..954e987 --- /dev/null +++ b/packages/core/test/revocation-incident-v2.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { createIdentityKeyPair } from '../src/identity.js' +import { + createIncidentRecordV2, + createRevocationRecordV2, + resolveIncidentRecordV2, + signIncidentRecordV2, + signRevocationRecordV2, + verifySignedIncidentRecordV2, + verifySignedIncidentRecordV2Issuer, + verifySignedRevocationRecordV2, + verifySignedRevocationRecordV2Issuer, +} 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, + 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) + expect(await verifySignedRevocationRecordV2Issuer(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', + issuer: reporter.did, + subject: 'did:fides:agent', + 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) + expect(incident.payload_hash).toMatch(/^sha256:/) + + 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) + }) +}) diff --git a/packages/core/test/runtime-attestation.test.ts b/packages/core/test/runtime-attestation.test.ts new file mode 100644 index 0000000..ed0a76f --- /dev/null +++ b/packages/core/test/runtime-attestation.test.ts @@ -0,0 +1,121 @@ +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({ + 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', + 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)}`, + runtime_hash: `sha256:${'b'.repeat(64)}`, + 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(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({ + 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(attestation.issuer).toBe('null') + expect(attestation.subject).toBe('did:fides:agent') + expect(attestation.payload_hash).toMatch(/^sha256:/) + expect(await provider.verify(attestation)).toBe(false) + }) +}) 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..15cd1be --- /dev/null +++ b/packages/core/test/session-grant-v2.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { createIdentityKeyPair } from '../src/identity.js' +import { + createSessionGrantV2, + isSessionGrantV2Expired, + signSessionGrantV2, + validateSessionGrantV2, + verifySignedSessionGrantV2, + verifySignedSessionGrantV2Issuer, +} 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', + 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', + capability: 'invoice.reconcile', + scopes: ['invoice:read'], + 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) + 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) + 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 () => { + 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', + ], + }) + }) + + 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/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/core/test/trust.test.ts b/packages/core/test/trust.test.ts new file mode 100644 index 0000000..54bdf38 --- /dev/null +++ b/packages/core/test/trust.test.ts @@ -0,0 +1,150 @@ +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', + 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', + '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'])) + }) + + 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/core/test/versioning.test.ts b/packages/core/test/versioning.test.ts new file mode 100644 index 0000000..a5ed3d8 --- /dev/null +++ b/packages/core/test/versioning.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + defaultVersionNegotiationRecord, + isSupportedProtocolVersion, + 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', () => { + 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) + }) + + 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/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..4cd075e --- /dev/null +++ b/packages/crypto/README.md @@ -0,0 +1,36 @@ +# @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. + +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, + 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/crypto/package.json b/packages/crypto/package.json new file mode 100644 index 0000000..41a7165 --- /dev/null +++ b/packages/crypto/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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/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..206d5d4 --- /dev/null +++ b/packages/daemon/README.md @@ -0,0 +1,35 @@ +# @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, well-known endpoint +helpers, and a Promise-based client factory. + +## Installation + +```bash +npm install @fides/daemon +``` + +## Usage + +```typescript +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/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/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..400605c --- /dev/null +++ b/packages/delegation/README.md @@ -0,0 +1,34 @@ +# @fides/delegation + +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, + 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/delegation/package.json b/packages/delegation/package.json new file mode 100644 index 0000000..fe86c65 --- /dev/null +++ b/packages/delegation/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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..bade4fa --- /dev/null +++ b/packages/dht/README.md @@ -0,0 +1,34 @@ +# @fides/dht + +DHT pointer record entrypoints for FIDES v2 discovery. + +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, + 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/dht/package.json b/packages/dht/package.json new file mode 100644 index 0000000..42a38f9 --- /dev/null +++ b/packages/dht/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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/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 diff --git a/packages/discovery/src/dht-provider.ts b/packages/discovery/src/dht-provider.ts index 7086d95..639dbe3 100644 --- a/packages/discovery/src/dht-provider.ts +++ b/packages/discovery/src/dht-provider.ts @@ -1,4 +1,15 @@ -import type { AgentCard, SignedAgentCard } from '@fides/core' +import { + cardSupportsCapability, + createDiscoveryCandidate, + hashCapability, + verifySignedAgentCardIdentity, + verifyDHTPointerRecord, + type AgentCard, + type DHTPointerRecord, + type DiscoveryCandidate, + type DiscoveryQuery, + type SignedAgentCard, +} from '@fides/core' import { DiscoveryProvider } from './provider.js' /** @@ -18,11 +29,19 @@ 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 + 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 { @@ -44,7 +63,38 @@ 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 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: true, + rank: 50, + explanations: ['DHT returned a signed capability pointer; DHT is not an authority source'], + errors: [], + })) + } + return candidates.slice(0, query.limit ?? candidates.length) + } + 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 @@ -58,10 +108,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/src/federation-provider.ts b/packages/discovery/src/federation-provider.ts new file mode 100644 index 0000000..44b72c8 --- /dev/null +++ b/packages/discovery/src/federation-provider.ts @@ -0,0 +1,110 @@ +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, + 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 ?? []), + ], + }) + } + } + 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/src/local-provider.ts b/packages/discovery/src/local-provider.ts index 03d6bf3..1efbc89 100644 --- a/packages/discovery/src/local-provider.ts +++ b/packages/discovery/src/local-provider.ts @@ -1,9 +1,22 @@ -import type { AgentCard, SignedAgentCard } from '@fides/core' +import { + cardSupportsCapability, + createDiscoveryCandidate, + verifySignedAgentCardIdentity, + 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' import { homedir } from 'node:os' +interface LocalAgentRecord { + card: AgentCard + signedCard?: SignedAgentCard +} + /** * LocalDiscoveryProvider discovers agents via a local file store. * @@ -22,13 +35,37 @@ 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 { + const records = await this.listResolvedRecords() + return records + .filter(record => cardSupportsCapability(record.card, query.capability)) + .map((record, index) => createDiscoveryCandidate({ + provider: this.name, + card: record.card, + capability: query.capability, + verified: record.verified, + 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 { + 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) + store.set(did, { card: card.payload as AgentCard, signedCard: card }) this.saveStore(store) } @@ -43,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) } @@ -52,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 async listResolvedRecords(): Promise> { + const records: Array = [] + for (const record of this.loadStore().values()) { + records.push(await this.resolveRecord(record)) + } + return records } - private loadStore(): Map { + 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() } @@ -68,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 { @@ -78,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) } } @@ -97,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/src/orchestrator.ts b/packages/discovery/src/orchestrator.ts index 37fe13f..05635d1 100644 --- a/packages/discovery/src/orchestrator.ts +++ b/packages/discovery/src/orchestrator.ts @@ -1,4 +1,11 @@ -import type { AgentCard } from '@fides/core' +import { + cardSupportsCapability, + createDiscoveryCandidate, + negotiateDiscoveryCandidateVersion, + type AgentCard, + type DiscoveryCandidate, + type DiscoveryQuery, +} from '@fides/core' import { DiscoveryProvider } from './provider.js' /** @@ -8,6 +15,43 @@ 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(...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, + })) + } + } + } 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 { @@ -45,3 +89,24 @@ 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, + authority: 'candidate_only' as const, + versionNegotiation, + evidence_refs: candidate.evidence_refs ?? [], + errors: (candidate.errors ?? []).filter(error => error.code !== 'VERSION_INCOMPATIBLE'), + explanations: [ + ...(candidate.explanations ?? []), + `Protocol version ${versionNegotiation.negotiated_version} is compatible`, + ], + }] + }) +} 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/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/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/dht-provider.test.ts b/packages/discovery/test/dht-provider.test.ts new file mode 100644 index 0000000..c267db4 --- /dev/null +++ b/packages/discovery/test/dht-provider.test.ts @@ -0,0 +1,151 @@ +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([]) + }) + + 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 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() + + 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([]) + }) +}) 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([]) + }) +}) diff --git a/packages/discovery/test/local-provider.test.ts b/packages/discovery/test/local-provider.test.ts new file mode 100644 index 0000000..c9fb784 --- /dev/null +++ b/packages/discovery/test/local-provider.test.ts @@ -0,0 +1,102 @@ +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('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() + 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/orchestrator.test.ts b/packages/discovery/test/orchestrator.test.ts index a4db832..e1e80c9 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,137 @@ describe('DiscoveryOrchestrator', () => { expect(result).toEqual(mockCard) }) + + 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: card.id, + card, + 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) + expect(candidates[0].versionNegotiation).toMatchObject({ + 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') + }) + + 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', + 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', + authority: 'candidate_only', + verified: false, + evidence_refs: [], + }) + 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 () => { + 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].authority).toBe('candidate_only') + expect(candidates[0].evidence_refs).toEqual([]) + expect(candidates[0].explanations[0]).toContain('invoice.reconcile') + }) }) 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() + }) +}) 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() + }) }) diff --git a/packages/evidence/README.md b/packages/evidence/README.md index a360ee8..7f0280b 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,11 +15,18 @@ npm install @fides/evidence ## Usage ```typescript -import { appendEvidenceEvent, createEvidenceChain, verifyEvidenceChain } from '@fides/evidence' +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(), @@ -25,9 +34,17 @@ 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') +const included = verifyMerkleProof(proof) ``` ## License diff --git a/packages/evidence/src/index.ts b/packages/evidence/src/index.ts index d85e6df..04bf004 100644 --- a/packages/evidence/src/index.ts +++ b/packages/evidence/src/index.ts @@ -6,10 +6,10 @@ 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' + level: 'public' | 'private' | 'redacted' | 'hash_only' | 'hash-only' redactionKey?: string } @@ -32,11 +32,358 @@ 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 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' + +export interface EvidenceEventV2 { + schema_version: 'fides.evidence_event.v1' + id: string + event_id: string + issuer: 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 + issued_at: string + timestamp: string + prev_event_hash: string + payload_hash: string + event_hash: string + signature: string + metadata?: Record +} + +export interface EvidenceEventV2ExportOptions { + privacy_mode?: EvidencePrivacyMode + include_metadata?: boolean +} + +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))))}` +} + +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' +): EvidenceEventV2 { + const eventId = input.event_id ?? crypto.randomUUID() + const timestamp = input.timestamp ?? new Date().toISOString() + const eventPayload: Omit = { + schema_version: 'fides.evidence_event.v1', + id: eventId, + event_id: eventId, + issuer: input.actor, + type: input.type, + actor: input.actor, + privacy_mode: input.privacy_mode ?? defaultPrivacyMode(input), + issued_at: timestamp, + timestamp, + 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 eventWithoutHash: Omit = { + ...eventPayload, + payload_hash: hashEvidenceValue(eventPayload), + } + const event_hash = hashEvidenceValue(eventWithoutHash) + return { + ...eventWithoutHash, + event_hash, + signature: '', + } +} + +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, + 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 + 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: { + type: 'Ed25519Signature2024', + created: event.timestamp, + verificationMethod, + proofPurpose: 'assertionMethod', + canonicalizationAlgorithm: 'https://fides.dev/canonical-json/v1', + proofValue: signature, + }, + }) +} + +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 +): 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 + if (!verifyUnsignedEvidenceEventV2(event)) return false + } + 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. */ @@ -49,8 +396,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]) } @@ -60,6 +406,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. */ @@ -116,9 +528,16 @@ 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: 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..e16fc90 100644 --- a/packages/evidence/test/evidence.test.ts +++ b/packages/evidence/test/evidence.test.ts @@ -1,6 +1,24 @@ import { describe, it, expect } from 'vitest' -import { createEvidenceChain, appendEvidenceEvent, verifyEvidenceChain, redactEvent } from '../src/index.js' +import { + appendEvidenceEvent, + appendEvidenceEventV2, + buildEvidenceMerkleProof, + buildMerkleProof, + createEvidenceChain, + createEvidenceEventV2, + exportEvidenceEventsV2, + hashEvidenceValue, + normalizeEvidenceEventsV2, + redactEvidenceEventV2, + redactEvent, + signEvidenceEventV2, + verifyEvidenceChain, + verifyEvidenceEventV2, + verifyEvidenceEventsV2, + verifyMerkleProof, +} 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', () => { @@ -36,6 +54,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, { @@ -69,6 +135,145 @@ 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() }) + + 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.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:/) + 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) + 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', () => { + 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) + }) + + 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', + 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' }) + }) }) 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/demo.ts b/packages/guard/demo.ts index fbca377..56e8ce1 100644 --- a/packages/guard/demo.ts +++ b/packages/guard/demo.ts @@ -5,10 +5,10 @@ * Run: pnpm demo */ -import { createIdentity, 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' @@ -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, privateKey: charliePrivateKey } = await createPrincipalIdentity({ + type: 'individual', + displayName: 'Charlie User', + }) console.log(` Alice: ${alice.did}`) console.log(` Bob: ${bob.did}`) console.log(` Charlie: ${charlie.did}`) @@ -57,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}`) @@ -90,10 +95,10 @@ 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') + chain = appendEvidenceEvent(chain, evt, localEvidenceSignature(evt)) } console.log(` Events: ${chain.events.length}`) console.log(` Chain valid: ${verifyEvidenceChain(chain)}`) @@ -137,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) 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/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..86f14d1 --- /dev/null +++ b/packages/identity/README.md @@ -0,0 +1,37 @@ +# @fides/identity + +Identity entrypoints for FIDES v2 agents, publishers, principals, and trust +anchors. + +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/identity/package.json b/packages/identity/package.json new file mode 100644 index 0000000..8ff79c8 --- /dev/null +++ b/packages/identity/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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..89e8a42 --- /dev/null +++ b/packages/incidents/README.md @@ -0,0 +1,36 @@ +# @fides/incidents + +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 { + 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/incidents/package.json b/packages/incidents/package.json new file mode 100644 index 0000000..0783a8e --- /dev/null +++ b/packages/incidents/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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..e6f163e --- /dev/null +++ b/packages/invocation/README.md @@ -0,0 +1,34 @@ +# @fides/invocation + +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, + 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/invocation/package.json b/packages/invocation/package.json new file mode 100644 index 0000000..e3194ec --- /dev/null +++ b/packages/invocation/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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/policy/README.md b/packages/policy/README.md index 316519a..5b6a373 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,47 @@ 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', + 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, + band: 'high', + reasons: [], + risk_flags: [], + evidence_refs: ['evt_1'], + required_controls: [], + computed_at: new Date().toISOString(), + payload_hash: 'sha256:...', + }, + 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 30ac7de..8710a80 100644 --- a/packages/policy/src/index.ts +++ b/packages/policy/src/index.ts @@ -1,3 +1,5 @@ +import { hashProtocolPayload, type CapabilityControl, type CapabilityDescriptor, type HashValue, type TrustResult } from '@fides/core' + /** * FIDES v2 Policy Engine * @@ -39,6 +41,83 @@ export interface PolicyResult { matchedRules: string[] } +export type FidesPolicyDecisionAction = + | 'allow' + | 'deny' + | 'require_approval' + | 'dry_run_only' + | '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' + message: string + evidence_refs: string[] +} + +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: PolicyReason[] + 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 + 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 +178,115 @@ export function evaluatePolicy(bundle: PolicyBundle, context: PolicyContext): Po } } +function createDecision( + input: FidesPolicyEvaluationInput, + decision: FidesPolicyDecisionAction, + 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), + ])) + + 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, + 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, + issued_at: evaluatedAt, + evaluated_at: evaluatedAt, + } satisfies Omit + + return { + ...payload, + payload_hash: hashProtocolPayload(payload), + } +} + +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..7190f1a --- /dev/null +++ b/packages/policy/test/policy-v2.test.ts @@ -0,0 +1,134 @@ +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', + id: `trust_${band}_${score}`, + issuer: 'did:fides:trust-engine', + subject: 'did:fides:agent', + 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', + payload_hash: `sha256:${'0'.repeat(64)}`, +}) + +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', + capability: capability('low'), + 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') + }) + + 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) + }) + + 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') + }) +}) 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/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..beb34a8 --- /dev/null +++ b/packages/registry/README.md @@ -0,0 +1,35 @@ +# @fides/registry + +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, + 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/registry/package.json b/packages/registry/package.json new file mode 100644 index 0000000..56664f8 --- /dev/null +++ b/packages/registry/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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..c6b56c8 --- /dev/null +++ b/packages/relay/README.md @@ -0,0 +1,29 @@ +# @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. + +## 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/relay/package.json b/packages/relay/package.json new file mode 100644 index 0000000..77e2aca --- /dev/null +++ b/packages/relay/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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..eeb835b --- /dev/null +++ b/packages/reputation/README.md @@ -0,0 +1,31 @@ +# @fides/reputation + +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, + 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/reputation/package.json b/packages/reputation/package.json new file mode 100644 index 0000000..8662471 --- /dev/null +++ b/packages/reputation/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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..c61c4b5 --- /dev/null +++ b/packages/revocation/README.md @@ -0,0 +1,35 @@ +# @fides/revocation + +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, + 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/revocation/package.json b/packages/revocation/package.json new file mode 100644 index 0000000..5f179b1 --- /dev/null +++ b/packages/revocation/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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/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/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..100da11 --- /dev/null +++ b/packages/runtime-effect/README.md @@ -0,0 +1,31 @@ +# @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. + +## 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/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/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/packages/rust-sdk/README.md b/packages/rust-sdk/README.md index 58cf1d0..2210fd2 100644 --- a/packages/rust-sdk/README.md +++ b/packages/rust-sdk/README.md @@ -1,23 +1,59 @@ -# 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. +- No Rust crate is required or published yet. + +## 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', +]) +``` diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 820ccb3..f643969 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1,47 +1,285 @@ # @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' - -const fides = new Fides({ - discoveryUrl: 'http://localhost:3100', - trustUrl: 'http://localhost:3200', - apiKey: process.env.FIDES_API_KEY, +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' }) +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', +}) +const graph = await client.graph.inspect(target.did) +console.log(graph.authorityGranted) // false + +const policy = await client.policy.evaluate({ + principalId: principal.did, + requesterAgentId: requester.did, + agentId: target.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], }) -// Verify requests -const result = await fides.verifyRequest(incomingRequest) +const session = await client.sessions.request({ + principalId: principal.did, + requesterAgentId: requester.did, + agentId: target.did, + capability: 'invoice.reconcile', + requestedScopes: ['invoice:read'], +}) -// Trust attestations -await fides.trust('did:fides:...', TrustLevel.HIGH) +const result = await client.invoke({ + sessionId: session.session.session_id, + input: { invoiceId: 'inv_123' }, +}) -// Reputation scores -const score = await fides.getReputation('did:fides:...') +await client.evidence.verify() + +console.log({ trust: trust.trust.band, policy: policy.policy.decision, result }) ``` ## 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) + +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) + +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' }) +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', +}) +const graph = await client.graph.inspect(identity.identity.did) +const reputation = await client.reputation.update({ + agentId: identity.identity.did, + 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', + agentId: identity.identity.did, + 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', + 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 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 attestation = await client.attestations.runtime({ + 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 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', + 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. +} +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({ privacy_mode: 'hash_only', include_metadata: false }) +``` + +The local identity API returns public identity data only; it does not return +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`, +`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. `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. 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 +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. 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. Revocation +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, +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. + +## 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' @@ -61,7 +299,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', @@ -88,40 +326,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, -}) - -await identities.register({ - did: 'did:fides:agent', - name: 'Payment Agent', - publicKey: '00'.repeat(32), -}) - -await identities.verifyDomain('did:fides:agent', 'agent.example.com') +## Legacy Discovery Clients -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', - capabilities: ['payments.execute'], - endpoints: [{ type: 'mcp', url: 'https://agent.example.com/mcp' }], - trustLevel: 'high', -}) - -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 @@ -257,7 +468,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 1107390..ecd2b39 100644 --- a/packages/sdk/src/agentd/client.ts +++ b/packages/sdk/src/agentd/client.ts @@ -1,12 +1,16 @@ import { - createDelegationToken, + createDelegationTokenV2, createIncidentRecord, createRevocationRecord, deriveEd25519PublicKeyHex, - signDelegationToken, + didFromPublicKey, + isErrorEnvelope, + signDelegationTokenV2, signIncidentRecord, signRevocationRecord, type DelegationToken as CoreDelegationToken, + type SignedDelegationTokenV2 as CoreSignedDelegationTokenV2, + type ErrorEnvelope, type IncidentRecord as CoreIncidentRecord, type RevocationRecord as CoreRevocationRecord, } from '@fides/core' @@ -17,6 +21,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 @@ -34,6 +55,7 @@ export interface AuthorizationRequest { } export type DelegationToken = CoreDelegationToken +export type SignedDelegationTokenV2 = CoreSignedDelegationTokenV2 export interface SessionGrant { id: string @@ -48,7 +70,8 @@ export interface SessionGrant { } export interface SessionCreateRequest { - token: DelegationToken + token?: DelegationToken + signedToken?: SignedDelegationTokenV2 capabilityId?: string audience?: string boundTo?: string @@ -60,6 +83,7 @@ export interface SessionCreateResponse { authorized: boolean session?: SessionGrant errors?: string[] + signedDelegationVerified?: boolean } export interface SessionLookupResponse { @@ -111,7 +135,7 @@ export interface CreateSignedSessionOptions { delegatee: string capabilities: string[] privateKey: Uint8Array | string - constraints?: DelegationToken['constraints'] + constraints?: Record audience?: string[] capabilityId?: string boundTo?: string @@ -160,7 +184,7 @@ export interface IncidentListResponse { } export interface AuthorizationDecision { - decision: 'allow' | 'deny' + decision: 'allow' | 'deny' | 'approve-required' | 'dry-run' explanation: string factors?: Array> session?: Record @@ -222,7 +246,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' @@ -232,14 +257,23 @@ 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) } 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, @@ -247,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), }) } @@ -369,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() } @@ -383,7 +421,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 } @@ -392,3 +432,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/src/discovery/agent-client.ts b/packages/sdk/src/discovery/agent-client.ts index d63bd11..a3543d4 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 @@ -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( @@ -172,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/src/fides-client.ts b/packages/sdk/src/fides-client.ts new file mode 100644 index 0000000..ff1a92e --- /dev/null +++ b/packages/sdk/src/fides-client.ts @@ -0,0 +1,1335 @@ +import { + type AgentIdentity, + type Attestation, + type AgentCard, + 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, + signInvocationRequest, + type ErrorEnvelope, + type InvocationRequest, + type InvocationResult, + type RuntimeAttestation, + type SessionGrantV2, + type SignedAgentCard, + type SignedSessionGrantV2, + type SignedInvocationRequest, + type SignedInvocationResult, + type VersionNegotiationRecord, +} from '@fides/core' +import type { AgentdHealthResponse } from './agentd/client.js' + +export type { AgentdHealthResponse as FidesHealthResponse } from './agentd/client.js' + +export interface FidesClientOptions { + daemonUrl: string + apiKey?: string +} + +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 + constraints?: Record + supported_versions?: string[] + 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 + authority?: 'candidate_only' + cardId?: string + capability?: string + capabilities?: string[] + authorityGranted?: false + evidence_refs?: string[] + 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 + evidenceRefs?: string[] + evidence_refs?: string[] + explanation?: string + [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 + 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 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' +} + +export interface FidesRelayRegisterRequest { + agentId: string + endpointHints?: string[] +} + +export interface FidesDhtPublishRequest { + capability: string + agentId?: string + agentCardId?: string + agentCard?: string + agentCardUrl?: string + 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 + authorityGranted: false + [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 + input?: unknown + dryRun?: boolean + signedRequest?: SignedInvocationRequest +} + +export interface FidesSignedInvocationRequest { + sessionGrant: SessionGrantV2 + input?: unknown + dryRun?: boolean + privateKey: Uint8Array | string + verificationMethod?: string + inputSchema?: unknown + outputSchema?: unknown + 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 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 +} + +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 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 +} + +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 FidesRuntimeAttestationRequest { + agentId: string + codeHash: string + runtimeHash: string + policyHash: string + enclaveMeasurement?: string + 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' + 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 FidesGenericAttestationResponse { + attestation: Attestation + evidenceRefs?: string[] + authorityGranted?: false + [key: string]: unknown +} + +export interface FidesRuntimeAttestationVerificationResponse extends FidesRuntimeAttestationResponse { + id: string + valid: boolean + error?: string +} + +export interface FidesGenericAttestationVerificationResponse extends FidesGenericAttestationResponse { + id: string + valid: boolean + error?: string +} + +export type FidesAttestationResponse = + | FidesIdentityAttestationResponse + | FidesRuntimeAttestationResponse + | FidesGenericAttestationResponse + +export interface FidesInvocationResponse { + authorityGranted: boolean + session: SessionGrantV2 + request: InvocationRequest + signedRequest?: SignedInvocationRequest + signedRequestVerified?: boolean + preflight: Record + result: InvocationResult + signedResult?: SignedInvocationResult + 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 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 FidesGraphInspectionResponse { + agentId: string + authorityGranted: false + graphView: FidesTrustListResponse +} + +export interface FidesReputationUpdateResponse { + reputation: ReputationRecord + authorityGranted: false + [key: string]: unknown +} + +export interface FidesReputationListResponse { + agentId: string + reputations: ReputationRecord[] + authorityGranted: false + [key: string]: unknown +} + +export interface FidesReputationInspectionResponse { + agentId: string + capability: string + reputation: ReputationRecord | null + reputationSignals: ReputationRecord[] + authorityGranted: false +} + +export interface FidesDelegationResponse { + token: DelegationToken + signed: boolean + authorityGranted: false + explanation: string + [key: string]: unknown +} + +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 FidesKillSwitchRuleResponse { + rule: KillSwitchRule + evidenceRefs?: string[] + authorityOverride?: boolean + explanation?: string + [key: string]: unknown +} + +export interface FidesKillSwitchListResponse { + rules: KillSwitchRule[] + active: KillSwitchRule[] + [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 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 + authorityMode?: 'full' | 'dry_run_only' + allowedActions?: Array<'execute' | 'dry_run'> + session: SessionGrantV2 + signedSession?: SignedSessionGrantV2 + signedSessionVerified?: boolean + versionNegotiation?: VersionNegotiationRecord + 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' + + constructor( + message: string, + readonly status: number, + readonly payload: unknown, + readonly error?: ErrorEnvelope + ) { + super(message) + } +} + +export class FidesClient { + readonly identity = { + 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 = { + 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 = { + 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 = { + 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, + 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 = { + evaluate: (body: FidesTrustEvaluationRequest): Promise => ( + this.post('/trust/evaluate', body) as Promise + ), + get: (agentId: string): Promise => ( + this.get(`/trust/${encodeURIComponent(agentId)}`) as Promise + ), + } + + readonly graph = { + inspect: async (agentId: string): Promise => ({ + agentId, + authorityGranted: false, + graphView: await this.get(`/trust/${encodeURIComponent(agentId)}`) as FidesTrustListResponse, + }), + } + + readonly reputation = { + update: (body: FidesReputationUpdateRequest): Promise => ( + this.post('/reputation/update', body) as Promise + ), + 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 = { + evaluate: (body: FidesPolicyEvaluationRequest): Promise => ( + this.post('/policy/evaluate', body) as Promise + ), + } + + readonly delegations = { + create: (body: FidesDelegationRequest): Promise => ( + this.post('/delegations', body) as Promise + ), + } + + readonly approvals = { + create: (body: FidesApprovalCreateRequest): Promise => ( + this.post('/approvals', body) as Promise + ), + list: (): Promise => this.get('/approvals') as Promise, + approve: (approvalId: string, body: FidesApprovalDecisionRequest = {}): Promise => ( + this.post(`/approvals/${encodeURIComponent(approvalId)}/approve`, body) as Promise + ), + deny: (approvalId: string, body: FidesApprovalDecisionRequest = {}): Promise => ( + this.post(`/approvals/${encodeURIComponent(approvalId)}/deny`, body) as Promise + ), + } + + readonly killSwitch = { + enable: (body: FidesKillSwitchEnableRequest): 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 = { + create: (body: FidesRevocationCreateRequest): 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 = { + 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: FidesIncidentResolveRequest): Promise => ( + this.post(`/incidents/${encodeURIComponent(recordId)}/resolve`, body) as Promise + ), + } + + readonly attestations = { + 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 + ), + 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 + ), + runtime: (body: FidesRuntimeAttestationRequest): Promise => ( + this.post('/attestations', 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 = { + request: (body: FidesSessionRequest): 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 = { + 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: (): Promise => this.get('/registry/index') as Promise, + } + + readonly relay = { + 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: (): 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: (): 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 = { + append: (body: FidesEvidenceAppendRequest): 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 = { + run: (): Promise => this.post('/demo/run', {}) as Promise, + } + + readonly simulate = { + adversarial: (): Promise => ( + this.post('/simulate/adversarial', {}) as Promise + ), + } + + constructor(private readonly options: FidesClientOptions) {} + + health(): Promise { + return this.get('/health') as Promise + } + + invoke(body: FidesInvocationRequest): Promise { + 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' }) + } + + 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 delete(path: string): Promise { + 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) { + 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) { + 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 +} + +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) { + throw new FidesClientError('Ed25519 private key must be 32 bytes', 0, {}, undefined) + } + return bytes +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index def58a5..0b52bb4 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, @@ -134,11 +136,36 @@ export { metricsMiddleware } from './observability/metrics-middleware.js' // High-level API export { Fides } from './fides.js' +export { + FidesClient, + FidesClientError, + type FidesClientOptions, + type FidesHealthResponse, + type FidesDiscoveryQuery, + type FidesDiscoveryResponse, + type FidesProviderRecord, + type FidesRegistryPublishRequest, + type FidesRelayRegisterRequest, + type FidesDhtPublishRequest, + type FidesInvocationRequest, + type FidesInvocationResponse, + type FidesGraphInspectionResponse, + type FidesPolicyDecision, + type FidesPolicyDecisionAction, + type FidesPolicyEvaluationResponse, +} from './fides-client.js' // 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/agentd.test.ts b/packages/sdk/test/agentd.test.ts index 09c69f8..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', () => { @@ -49,6 +50,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({ @@ -197,14 +235,49 @@ 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 () => { + 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, @@ -216,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/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') + }) +}) diff --git a/packages/sdk/test/discovery.test.ts b/packages/sdk/test/discovery.test.ts index fb6eb2c..f87dd82 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') @@ -312,6 +311,147 @@ 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') + }) + + 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) + }) + + 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', () => { diff --git a/packages/sdk/test/fides-client.test.ts b/packages/sdk/test/fides-client.test.ts new file mode 100644 index 0000000..f8fcbeb --- /dev/null +++ b/packages/sdk/test/fides-client.test.ts @@ -0,0 +1,1817 @@ +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(() => { + vi.unstubAllGlobals() +}) + +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: { + 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('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('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('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', + 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('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) => { + 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.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' }) + 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.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' }) + 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', + 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' }) + 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.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.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') + await client.registry.start() + await client.registry.publish({ agentCardId: 'did:fides:agent' }) + 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', + 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' }) + 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({ 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' } }) + await client.graph.inspect('did:fides:agent') + + 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(bodyFor('http://localhost:4817/registry/search')).toEqual({ + capability: 'invoice.reconcile', + supported_versions: ['fides.v2.0'], + required_versions: ['fides.v2.0'], + }) + expect(bodyFor('http://localhost:4817/dht/publish')).toEqual({ + capability: 'invoice.reconcile', + agentId: 'did:fides:agent', + }) + expect(bodyFor('http://localhost:4817/evidence/export')).toEqual({ + privacy_mode: 'hash_only', + include_metadata: false, + }) + }) + + 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) => { + 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' }) + + const created = await client.identity.createAgent({ name: 'Calendar Agent' }) + expect(created).toMatchObject({ + identity: { did: 'did:fides:agent' }, + }) + 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' }], + }) + 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', + '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') + }) + + 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('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', + } + + 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, + 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', + 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) + 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 }) + 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 () => { + 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', + 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, + }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + + 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({ + identity: 'did:fides:publisher', + registry: 'npm', + package: '@fides/example-agent', + }) + 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', + '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('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 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') + + const verified = await client.attestations.verify('att_runtime_1') + expect(verified.valid).toBe(true) + 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', + 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('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 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', + capabilities: ['invoice.reconcile'], + }) + expect(delegation.token.capabilities).toEqual(['invoice.reconcile']) + 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, authorityGranted: false }), { + 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, authorityGranted: false }), { + 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, authorityGranted: false }), { + 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.authorityGranted).toBe(false) + 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.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' }) + 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('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 = { + 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 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 }), { status: 200 }) + } + if (String(url).endsWith('/agent-cards/card_1/verify')) { + 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, signed }), { status: 200 }) + } + 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', 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' } }, + }) + + 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', + ]) + }) + + 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', + 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', + 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', + 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, + candidates: [{ + agentId: 'did:fides:agent', + authority: 'candidate_only', + evidence_refs: ['evt_discovery'], + }], + }), { status: 200 }) + })) + + const client = new FidesClient({ daemonUrl: 'http://localhost:7345' }) + + 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.discovery.find({ capability: 'invoice.reconcile' })).resolves.toMatchObject({ + authorityGranted: false, + candidates: [{ + agentId: 'did:fides:agent', + authority: 'candidate_only', + evidence_refs: ['evt_discovery'], + }], + }) + + 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', + ]) + }) + + 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', + }), + }), + ])) + }) +}) 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/shared/src/types.ts b/packages/shared/src/types.ts index 2b9e4b1..d13e4c9 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -130,6 +130,10 @@ export interface AgentCard { heartbeatAt?: string createdAt: string updatedAt: string + verified?: false + urlRequired?: false + authorityGranted?: false + reasons?: string[] } export interface AgentCardQuery { 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..3f240fa --- /dev/null +++ b/packages/trust/README.md @@ -0,0 +1,37 @@ +# @fides/trust + +Capability-specific trust scoring entrypoints for FIDES v2. + +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, 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/packages/trust/package.json b/packages/trust/package.json new file mode 100644 index 0000000..2816ed8 --- /dev/null +++ b/packages/trust/package.json @@ -0,0 +1,45 @@ +{ + "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", + "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/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') + }) +}) 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 722429d..f275195 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,54 @@ 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/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': @@ -105,6 +153,70 @@ 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/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': + 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': @@ -174,6 +286,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': @@ -193,6 +353,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': @@ -212,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': @@ -252,6 +492,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/agentd-dx-smoke.ts b/scripts/agentd-dx-smoke.ts new file mode 100644 index 0000000..5c9c5a7 --- /dev/null +++ b/scripts/agentd-dx-smoke.ts @@ -0,0 +1,260 @@ +import { spawn, execFile } from 'node:child_process' +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) + +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 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') + 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 : []) + 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 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)) { + 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)) +} + +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) +}) diff --git a/scripts/audit-agentd-api.mjs b/scripts/audit-agentd-api.mjs new file mode 100644 index 0000000..fa47160 --- /dev/null +++ b/scripts/audit-agentd-api.mjs @@ -0,0 +1,162 @@ +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 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']], +]) + +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 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) { + 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(/:([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'") + 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 +} diff --git a/scripts/audit-cli-surface.mjs b/scripts/audit-cli-surface.mjs new file mode 100644 index 0000000..920952f --- /dev/null +++ b/scripts/audit-cli-surface.mjs @@ -0,0 +1,127 @@ +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', + 'delegate', + 'demo', + 'simulate', + 'daemon', + ], + }, + { args: ['identity', '--help'], contains: ['create', 'list', 'show'] }, + { 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', '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: ['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: ['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 '] }, + { args: ['approval', 'deny', '--help'], contains: ['', '--approver '] }, + { args: ['evidence', '--help'], contains: ['list', 'inspect', 'verify', 'export'] }, + { 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 = [] + +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.`) diff --git a/scripts/audit-examples.mjs b/scripts/audit-examples.mjs new file mode 100644 index 0000000..b514c59 --- /dev/null +++ b/scripts/audit-examples.mjs @@ -0,0 +1,131 @@ +import { spawnSync } from 'node:child_process' +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)), '..') +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 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}`) + 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') +} + +const forbiddenLegacyCapabilities = [ + 'calendar:create', + 'calendar:list', + 'calendar:delete', + 'invoice:create', + 'invoice:approve', + 'invoice:list', + 'payment:charge', + 'payment:refund', + 'payment:status', + 'email:send', +] +const exampleSourceFiles = findExampleSourceFiles(resolve(root, 'examples')) + .filter(file => !file.endsWith('agent-catalog.ts')) + +for (const file of exampleSourceFiles) { + const source = readFileSync(file, 'utf8') + for (const capability of forbiddenLegacyCapabilities) { + if (source.includes(capability)) { + errors.push(`${relativeToExamples(file)} still references legacy capability ${capability}`) + } + } +} + +if (errors.length > 0) { + console.error(errors.join('\n')) + process.exit(1) +} + +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) +} 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 +} 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 +} 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]) +} diff --git a/scripts/check-package-hygiene.mjs b/scripts/check-package-hygiene.mjs index f9269d0..eeda565 100644 --- a/scripts/check-package-hygiene.mjs +++ b/scripts/check-package-hygiene.mjs @@ -1,12 +1,28 @@ -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 minimumReadmeBytes = 500 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 +31,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 } @@ -44,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) { diff --git a/scripts/public-packages.mjs b/scripts/public-packages.mjs index 4bfe0c1..88fd5fb 100644 --- a/scripts/public-packages.mjs +++ b/scripts/public-packages.mjs @@ -1,10 +1,27 @@ 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/runtime-effect', 'packages/discovery', + 'packages/dht', + 'packages/relay', + 'packages/registry', 'packages/evidence', + 'packages/revocation', + 'packages/incidents', + 'packages/adapters', + 'packages/guard', + 'packages/daemon', 'packages/sdk', 'packages/cli', ] 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/agentd/src/index.ts b/services/agentd/src/index.ts index b9dc8dd..e9a4455 100644 --- a/services/agentd/src/index.ts +++ b/services/agentd/src/index.ts @@ -11,27 +11,113 @@ 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 { MockTEEProvider, InMemoryKillSwitch } from '@fides/runtime' -import { evaluatePolicy, type PolicyBundle } from '@fides/policy' +import { + appendEvidenceEvent, + appendEvidenceEventV2, + createEvidenceChain, + createEvidenceEventV2, + exportEvidenceEventsV2, + normalizeEvidenceEventsV2, + 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' import { aggregateIncidentImpact, authorizeDelegation, + authorizeDelegationV2, authorizeSessionInvocation, + createAttestation, + createAgentIdentity, + computeCapabilityReputation, + computeTrustResult, + createApprovalDecision, + createApprovalRequest, + createCapabilityDescriptor, + createIncidentRecordV2, + createInvocationRequest, + createInvocationResult, + createKillSwitchRule, + createDelegationToken, + createDHTPointerRecord, + createErrorEnvelope, + createRegistryIndexRecord, + createRegistryPeerRecord, + createPrincipalIdentity, + createPublisherIdentity, + createRevocationRecordV2, + createSessionGrantV2, + hashAgentCard, + hashProtocolPayload, + isAttestationExpired, + isKillSwitchRuleActive, + MockTEEProvider as CoreMockTEEProvider, + negotiateProtocolVersion, + normalizeAgentCard, + resolveIncidentRecordV2, + signAgentCard, + signDelegationToken, + signDHTPointerRecord, + signRegistryIndexRecord, + signRegistryPeerRecord, + signSessionGrantV2, + verifySignedInvocationRequestIssuer, + signInvocationResult, + validateAgentCard, + verifyDHTPointerRecord, + verifySignedInvocationResult, + verifySignedRegistryIndexRecord, + verifySignedRegistryPeerRecord, + verifySignedAgentCard, + verifySignedAgentCardIdentity, + verifySignedSessionGrantV2Issuer, verifyDelegationTokenSignature, verifyDomainDid, + evaluateInvocationPreflight, + validateInvocationRequestAgainstSessionGrant, + validateJsonSchemaValue, verifyIncidentRecord, verifyRevocationRecord, + type Attestation, + type AgentIdentity, + type AgentCard, + type ApprovalDecision, + type ApprovalRequest, + type DelegationConstraint, + type DHTPointerRecord, type IncidentRecord, + type IncidentRecordV2, + type FidesErrorCode, + type IdentityTrustAnchor, + type KillSwitchRule, type DelegationToken, + type SignedDelegationTokenV2, + type PrincipalIdentity, + type PublisherIdentity, + type ReputationRecord, type RevocationRecord, + type RevocationRecordV2, + type RuntimeAttestation, + type SessionGrantV2, + type SignedSessionGrantV2, + type SignedInvocationRequest, + type SignedRegistryIndexRecord, + type SignedRegistryPeerRecord, + type SignedAgentCard, + type TrustAnchorType, + type TrustResult, + type VersionNegotiationRecord, } 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' @@ -47,9 +133,105 @@ 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() +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() const authorityStore = createAuthorityStore() +const localStateStore = createLocalDaemonStateStore() +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 localAgentCards = new Map() +const localSignedAgentCards = new Map() +const localRegistryRecords = new Map>() +const localRelayRecords = new Map>() +interface LocalRegisteredAgent { + agentId: string + cardId: string + registeredAt: string + signed: boolean +} +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() +const localRevocationRecords = new Map() +const localIncidentRecords = new Map() +const localGenericAttestations = new Map() +const localRuntimeAttestations = new Map() +let localEvidenceEvents: EvidenceEventV2[] = [] +interface LocalSessionRecord { + session: SessionGrantV2 + signedSession: SignedSessionGrantV2 + policy: FidesPolicyDecision + trust: TrustResult +} +const localSessionGrants = new Map() +let localStateLoaded = false +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', + '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', + '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() @@ -64,6 +246,387 @@ function getCorsOrigin(): string { return corsOrigin || '*' } +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[])) + 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) + } + localEvidenceEvents = normalizeEvidenceEventsV2(snapshot.evidenceEvents as Array>) + localSessionGrants.clear() + for (const record of snapshot.sessionGrants as LocalSessionRecord[]) { + if (record?.session?.session_id && record.signedSession) 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()), + genericAttestations: Array.from(localGenericAttestations.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 } +): 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(), + } +} + +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, + did: record.identity.did, + publicKeyHex: record.publicKeyHex, + createdAt: record.createdAt, + } +} + +function safeIdentityRecord(record: LocalIdentityRecord): Record { + return { + ...safeIdentitySummary(record), + identity: record.identity, + } +} + +function safeRegisteredAgent(record: LocalRegisteredAgent): Record { + const card = localAgentCards.get(record.cardId) + const signed = localSignedAgentCards.has(record.cardId) || record.signed + return { + ...record, + 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', + ], + } +} + +function sessionAuthorityFor(policy: FidesPolicyDecision): { + authorityGranted: boolean + authorityMode: 'full' | 'dry_run_only' + allowedActions: Array<'execute' | 'dry_run'> +} { + 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}` +} + +function findLocalCapability( + agentId: string, + capabilityId: string +): { record: LocalRegisteredAgent; card: AgentCard; capability: AgentCard['capabilities'][number] } | undefined { + const record = localAgents.get(agentId) + if (!record) return undefined + + const card = localAgentCards.get(record.cardId) + if (!card) return undefined + + const capability = card.capabilities.find(candidate => 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 +} + +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 + }) +} + +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' + }) +} + +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, + }, + }) +} + +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) + if (!attestation || attestation.agent_id !== agentId) return false + return runtimeAttestationProvider.verify(attestation) +} + // Global middleware stack app.use('*', metricsMiddleware(collector)) app.use('*', logger()) @@ -72,13 +635,180 @@ 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() 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('/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('/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)) + 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.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.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.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.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)) + 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.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)) + 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.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.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.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 })) @@ -103,14 +833,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({ @@ -123,34 +854,3379 @@ 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) }) -// ─── Identity Resolution (proxy to discovery) ───────────────────── -app.get('/v1/identities/domain/verify', async (c) => { - const domain = c.req.query('domain') - const did = c.req.query('did') - - if (!domain || !did) { - return c.json({ error: 'domain and did query parameters are required' }, 400) +// ─── 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(localError('REQUEST_INVALID', 'type must be agent, publisher, or principal', { field: 'type' }), 400) } - const result = await verifyDomainDid({ - domain, - did, - resolver: resolveTxt, + 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) +}) - return c.json(result, result.verified ? 200 : 422) +app.get('/identities', (c) => { + return c.json({ + identities: Array.from(localIdentities.values()).map(safeIdentitySummary), + }) }) -app.get('/v1/identities/:did', async (c) => { +app.get('/identities/:id', (c) => { + const id = c.req.param('id') + const record = localIdentities.get(id) + if (!record) { + return c.json(localError('IDENTITY_NOT_FOUND', 'identity not found', { id }), 404) + } + 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(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(localError('IDENTITY_NOT_FOUND', '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 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(localError('IDENTITY_NOT_FOUND', '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(localError('ATTESTATION_NOT_FOUND', 'one or more runtime attestations were not found', { runtimeAttestationIds }), 404) + } + + const card = normalizeAgentCard({ + 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 }), + }, + }, + ...(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) { + 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(localError('AGENT_CARD_NOT_FOUND', 'AgentCard not found', { id }), 404) + } + const identity = localIdentities.get(card.identity.did) + if (!identity) { + 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) + 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) { + const canonicalValid = await verifySignedAgentCard(signed) + const identityBound = await verifySignedAgentCardIdentity(signed) + return c.json({ + valid: identityBound, + signed: true, + canonicalValid, + identityBound, + }) + } + + const card = localAgentCards.get(id) + if (!card) { + 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 }) +}) + +app.get('/agent-cards/:id', (c) => { + const id = c.req.param('id') + const card = localAgentCards.get(id) + if (!card) { + return c.json(localError('AGENT_CARD_NOT_FOUND', 'AgentCard not found', { id }), 404) + } + return c.json({ + card, + signed: localSignedAgentCards.get(id) ?? null, + }) +}) + +// ─── 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(localError('REQUEST_INVALID', 'agentCardId is required', { field: 'agentCardId' }), 400) + } + + const card = localAgentCards.get(cardId) + if (!card) { + return c.json(localError('AGENT_CARD_NOT_FOUND', 'AgentCard not found', { cardId }), 404) + } + const signedCard = localSignedAgentCards.get(card.id) + if (!signedCard) { + 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(localError('IDENTITY_KEY_UNBOUND', '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: true, + } + 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(localError('AGENT_NOT_REGISTERED', 'agent not registered', { id }), 404) + } + + return c.json({ + ...safeRegisteredAgent(record), + card: localAgentCards.get(record.cardId) ?? null, + signedCard: localSignedAgentCards.get(record.cardId) ?? null, + }) +}) + +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 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) { + rejected.push({ + ...record, + authorityGranted: false, + protocolCompatibility: 'card_unresolved', + reasons: [ + 'provider_record_card_unresolved', + 'discovery_does_not_grant_authority', + ], + }) + 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 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 ?? ''), + } +} + +async function localDiscoveryResult(body: Record, provider = 'local') { + const capability = typeof body.capability === 'string' ? body.capability : undefined + if (!capability) { + return { error: localError('REQUEST_INVALID', 'capability is required', { field: 'capability' }) } + } + + const rejectedCandidates: Array> = [] + 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({ + 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: true, + authorityGranted: false, + versionNegotiation, + resolution: { + 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.', + }, + descriptor, + card, + reasons: [ + 'candidate_matched_capability', + 'protocol_version_compatible', + 'discovery_does_not_grant_authority', + 'url_not_required_for_local_discovery', + ], + }] + })) + const candidates = candidateSets.flat() + + return { + 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.', + } +} + +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(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(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(result.error, 400) + return c.json(appendDiscoveryEvidence('well-known', body, result)) +}) + +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(localError('REQUEST_INVALID', 'agentId and capability are required', { fields: ['agentId', 'capability'] }), 400) + } + + const trust = computeLocalTrustResult(agentId, capability) + if (!trust) { + return c.json(localError('CAPABILITY_NOT_FOUND', '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(localError('REQUEST_INVALID', 'agentId and capability are required', { fields: ['agentId', 'capability'] }), 400) + } + + const found = findLocalCapability(agentId, capability) + if (!found) { + return c.json(localError('CAPABILITY_NOT_FOUND', '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(localError('REQUEST_INVALID', 'agentId and capability are required', { fields: ['agentId', 'capability'] }), 400) + } + + const found = findLocalCapability(targetAgentId, capabilityId) + if (!found) { + 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(localError('TRUST_BELOW_THRESHOLD', '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.', + }) +}) + +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(localError('REQUEST_INVALID', 'delegator, delegatee, and capabilities are required', { fields: ['delegator', 'delegatee', 'capabilities'] }), 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, + }) + 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: signedToken, + signed: Boolean(delegatorIdentity), + authorityGranted: false, + 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) +}) + +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: 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: 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: 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) : [] + 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 activeRevocation = activeLocalRevocationFor({ + agentId: targetAgentId, + capability: capabilityId, + principalId, + 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, + targetAgentId, + capability: found.capability, + trustResult: trust, + requestedScopes, + killSwitchActive: activeKillSwitch !== undefined, + revocationActive: activeRevocation !== undefined, + incidentsActive: activeIncident !== undefined, + runtimeAttestationValid, + approvalGranted: typeof body.approvalGranted === 'boolean' ? body.approvalGranted : undefined, + }) + + 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, + error: policyErrorEnvelope(policy), + policy, + trust, + killSwitch: activeKillSwitch, + revocation: activeRevocation, + incident: activeIncident, + evidenceRefs: [deniedEvidence.event_id], + }, 409) + } + + const expiresAt = typeof body.expiresAt === 'string' + ? 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 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, + principalId, + capability: capabilityId, + scopes: requestedScopes, + constraints: sessionConstraints, + 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, + }) + 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, + 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: sessionAuthority.authorityGranted, + authority_mode: sessionAuthority.authorityMode, + allowed_actions: sessionAuthority.allowedActions, + negotiated_version: session.negotiated_version, + }, + }) + + return c.json({ + authorized: true, + ...sessionAuthority, + session, + signedSession, + signedSessionVerified: await verifySignedSessionGrantV2Issuer(signedSession), + versionNegotiation: sessionVersionNegotiation, + policy, + trust, + evidenceRefs: [sessionEvidence.event_id], + }, 201) +}) + +app.get('/sessions/:id', async (c) => { + const record = localSessionGrants.get(c.req.param('id')) + if (!record) { + 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, + signedSession: record.signedSession, + signedSessionVerified: await verifySignedSessionGrantV2Issuer(record.signedSession), + policy: record.policy, + trust: record.trust, + }) +}) + +app.post('/sessions/:id/verify', async (c) => { + const record = localSessionGrants.get(c.req.param('id')) + if (!record) { + 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) + } + + const signatureValid = await verifySignedSessionGrantV2Issuer(record.signedSession) + const notExpired = new Date(record.session.expires_at).getTime() > Date.now() + return c.json({ + valid: signatureValid && notExpired, + signatureValid, + notExpired, + session: record.session, + signedSession: record.signedSession, + }) +}) + +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: createErrorEnvelope('SESSION_SCOPE_INVALID', { + message: 'sessionId is required', + }) }, 400) + } + + const record = localSessionGrants.get(sessionId) + if (!record) { + return c.json({ error: createErrorEnvelope('SESSION_NOT_FOUND', { + message: 'Session was not found or is no longer available', + details: { sessionId }, + }), 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 }, + }), sessionId, authorityGranted: false }, 409) + } + + const found = findLocalCapability(record.session.target_agent_id, record.session.capability) + if (!found) { + 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 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) + } + + 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: effectiveDryRun, + 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 verifySignedInvocationRequestIssuer(candidateSignedRequest) + const signedPayload = candidateSignedRequest.payload + const expectedInputHash = hashProtocolPayload(body.input ?? {}) + const grantValidation = validateInvocationRequestAgainstSessionGrant({ + request: signedPayload, + sessionGrant: record.session, + }) + const payloadMatchesInput = signedPayload.input_hash === expectedInputHash && + signedPayload.dry_run === effectiveDryRun + + 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, + grantValidation, + payloadMatchesInput, + sessionId, + request_id: signedPayload.id, + }, + }), authorityGranted: false }, 401) + } + + request = signedPayload + } + const preflight = evaluateInvocationPreflight({ + 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, + 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, + 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, + output, + 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, + session: record.session, + signedSession: record.signedSession, + signedSessionVerified, + request, + signedRequest, + signedRequestVerified, + preflight, + result, + signedResult, + signedResultVerified, + }) +}) + +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(localError('REQUEST_INVALID', 'capability is required', { field: 'capability' }), 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) + 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) +}) + +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(localError('APPROVAL_NOT_FOUND', '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) + 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.', + }) +}) + +app.post('/approvals/:id/deny', async (c) => { + const id = c.req.param('id') + const approval = localApprovals.get(id) + if (!approval) { + return c.json(localError('APPROVAL_NOT_FOUND', '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) + 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, + }) +}) + +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(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(localError('REQUEST_INVALID', 'target is required', { field: 'target' }), 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) + 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) +}) + +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(localError('KILL_SWITCH_RULE_NOT_FOUND', '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 }) +}) + +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(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' + ? body.targetId + : typeof body.target_id === 'string' + ? body.target_id + : undefined + if (!targetId) { + return c.json(localError('REQUEST_INVALID', 'targetId is required', { field: 'targetId' }), 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) + 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) +}) + +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({ + ...localError('REVOCATION_NOT_FOUND', 'revocation record not found', { id }), + 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(localError('INCIDENT_INVALID', 'severity must be low, medium, high, or critical', { field: 'severity' }), 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(localError('INCIDENT_INVALID', 'category is invalid', { field: 'category' }), 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(localError('INCIDENT_INVALID', 'targetAgentId is required', { field: 'targetAgentId' }), 400) + } + + const description = typeof body.description === 'string' ? body.description : undefined + if (!description) { + return c.json(localError('INCIDENT_INVALID', 'description is required', { field: 'description' }), 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) + 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) +}) + +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(localError('INCIDENT_NOT_FOUND', '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(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' + const resolved = resolveIncidentRecordV2(record, status) + localIncidentRecords.set(id, resolved) + return c.json({ record: resolved }) +}) + +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 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' + ? body.agent_id + : undefined + if (!agentId) { + return c.json(localError('REQUEST_INVALID', 'agentId is required', { field: 'agentId' }), 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(localError('REQUEST_INVALID', 'codeHash, runtimeHash, and policyHash are required', { fields: ['codeHash', 'runtimeHash', 'policyHash'] }), 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) + 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, evidenceRefs: [event.event_id], authorityGranted: false }, 201) +}) + +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) + } + return c.json({ attestation }) +}) + +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({ + 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, + ...localError('ATTESTATION_NOT_FOUND', 'attestation not found', { id }), + evidenceRefs: [failed.event_id], + }, 404) + } + const valid = await runtimeAttestationProvider.verify(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 }) +}) + +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: { + ...localError('IDENTITY_NOT_FOUND', 'identity not found', { identity: identityId }), + identity: identityId, + }, + } + } + + const anchor = createLocalIdentityTrustAnchor(body) + if (!anchor) { + return { + status: 400, + body: { + ...localError('REQUEST_INVALID', 'attestation type and value are required', { fields: ['type', 'value'] }), + identity: identityId, + }, + } + } + + 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 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 + : 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 }) +}) + +app.post('/dht/publish', async (c) => { + const body = await c.req.json() + if (!body.capability) { + return c.json(localError('REQUEST_INVALID', 'capability is required', { field: 'capability' }), 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(localError('CAPABILITY_NOT_FOUND', '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(localError('IDENTITY_NOT_FOUND', 'DHT pointer publisher key not found', { publisherId }), 404) + } + + 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, + 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(publisherIdentity.privateKeyHex, 'hex'), publisherId) + 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, authorityGranted: false }, 201) + } + + const pointer = { + id: body.id ?? crypto.randomUUID(), + 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', + } + localDhtPointers.push(pointer) + return c.json({ accepted: true, pointer, authorityGranted: false }, 201) +}) + +async function findLocalDhtPointers(capability?: string) { + const matched = capability + ? localDhtPointers.filter(pointer => pointer.capability === capability) + : localDhtPointers + 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.publisher_id === 'string' ? pointer.publisher_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', 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(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 = await findLocalDhtPointers(capability) + const filtered = filterVersionCompatibleProviderRecords( + body, + found.pointers as Array>, + 'rejectedPointers' + ) + const result = { + provider: 'dht', + capability: found.capability, + pointers: filtered.records, + rejectedPointers: [ + ...found.rejectedPointers, + ...filtered.rejected, + ], + 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') { + 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 signedCard = localSignedAgentCards.get(card.id) + if (!signedCard || !await verifySignedAgentCardIdentity(signedCard)) { + 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, + cardId: card.id, + mode, + capabilities: card.capabilities.map(capability => capability.id), + signed: true, + 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) { + rejectedRecords.push({ + ...record, + authorityGranted: false, + registryIndexVerification: 'missing_signed_registry_index', + reasons: [ + 'registry_index_signature_required', + 'discovery_does_not_grant_authority', + ], + }) + 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 } +} + +async function localFederationPeerRecord(): Promise<{ signed: SignedRegistryPeerRecord | null; verified: boolean }> { + 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) } +} + +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, + cardId: registered.cardId, + capabilities: card.capabilities.map(capability => capability.id), + endpointHints, + online: true, + agentCardUrl: `local://agent-cards/${encodeURIComponent(card.id)}`, + agentCardHash: hashAgentCard(card), + signedAgentCard: true, + agentCardProof: signedCard.proof, + 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' + ? body.agentCardId + : typeof body.cardId === 'string' + ? body.cardId + : undefined + if (!cardId) { + 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(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) +}) + +app.post('/registry/search', 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) + return c.json({ + capability: capability ?? null, + records: filtered.records, + rejectedRecords: [ + ...verified.rejectedRecords, + ...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 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 result = { + provider: 'registry', + capability: capability ?? null, + records: filtered.records, + rejectedRecords: [ + ...verified.rejectedRecords, + ...filtered.rejected, + ], + 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) => { + 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', + ], + })) + const result = { + 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.', + } + return c.json(appendDiscoveryEvidence('federation', body, result)) +}) + +app.get('/registry/index', async (c) => { + const verified = await filterVerifiedLocalRegistryRecords(Array.from(localRegistryRecords.values())) + return c.json({ + mode: 'local_mock_registry', + records: verified.records, + rejectedRecords: verified.rejectedRecords, + authorityGranted: false, + }) +}) + +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' + ? body.agentId + : typeof body.agent_id === 'string' + ? body.agent_id + : undefined + if (!agentId) { + 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(localError('AGENT_NOT_REGISTERED', 'registered local agent not found', { agentId }), 404) + } + localRelayRecords.set(agentId, record) + return c.json({ accepted: true, record, authorityGranted: false }, 201) +}) + +app.post('/relay/discover', async (c) => { + const body = await c.req.json().catch(() => ({})) + const capability = typeof body.capability === 'string' ? body.capability : undefined + const matched = Array.from(localRelayRecords.values()).filter((record) => ( + !capability || (record.capabilities as string[] | undefined)?.includes(capability) + )) + 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 matched = Array.from(localRelayRecords.values()).filter((record) => ( + !capability || (record.capabilities as string[] | undefined)?.includes(capability) + )) + const filtered = filterVersionCompatibleProviderRecords(body, matched) + 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) => { + 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(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(localError('AGENT_NOT_REGISTERED', 'registered local agent not found', { agentId: id }), 404) + } + return c.json({ + agentId: id, + card, + signed: localSignedAgentCards.get(card.id) ?? null, + authorityGranted: false, + }) +}) + +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(localError('REQUEST_INVALID', 'type and actor are required', { fields: ['type', 'actor'] }), 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: 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, + count: localEvidenceEvents.length, + lastHash: localEvidenceEvents.at(-1)?.event_hash ?? null, + scope: 'root-local-evidence-ledger', + checkedAt: new Date().toISOString(), + }) +}) + +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(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 + : 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, + privacyMode: privacyMode ?? 'event_default', + includeMetadata: includeMetadata ?? null, + events, + }) +}) + +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(localError('EVIDENCE_EVENT_NOT_FOUND', 'evidence event not found', { eventId }), 404) + } + return c.json({ event, authorityGranted: false }) +}) + +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 authority = await getLocalAuthorityIdentity() + + 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 = await localRegistryRecordFor(invoice.card.id) + if (registryRecord) localRegistryRecords.set(String(registryRecord.id), registryRecord) + 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, + 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(publisher.privateKeyHex, 'hex'), publisher.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-demo-signed-dht-pointer', + } + localDhtPointers.push(dhtPointer) + + const calendarDiscovery = await localDiscoveryResult({ capability: calendarCapability.id }, 'local') + const invoiceRegistryRecords = Array.from(localRegistryRecords.values()).filter((record) => ( + (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(verifySignedAgentCardIdentity)) + + 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: authority.identity.did, + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }) + 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, + 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: authority.identity.did, + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }) + 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, + 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: localEvidenceEvents.length > 0, + }, + surfaces: { + local: true, + 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, + }) +}) + +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 launderingCapability = createCapabilityDescriptor({ + id: 'calendar.schedule', + riskLevel: 'low', + requiredScopes: ['calendar:write'], + supportedControls: ['dry_run'], + supportsDryRun: true, + }) + 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: 2, + incidentCount: 1, + publisherWeight: 0, + }) + 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, + components: { + identity: 0.2, + publisher: 0.1, + trustAnchors: 0, + capabilityFit: 0.4, + 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: 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: 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, + principal_id: principal.identity.did, + capability: capability.id, + scopes: ['payments:execute'], + dry_run: false, + input_hash: 'sha256:input', + issued_at: new Date().toISOString(), + payload_hash: 'sha256:payload', + }, + 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 { + status: scenarios.every(scenario => scenario.detected) ? 'detected' : 'partial', + mode: 'local-first', + detections: scenarios.map(scenario => scenario.name), + scenarios, + incident, + 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) ───────────────────── +app.get('/v1/identities/domain/verify', async (c) => { + const domain = c.req.query('domain') + const did = c.req.query('did') + + if (!domain || !did) { + return c.json({ error: 'domain and did query parameters are required' }, 400) + } + + const result = await verifyDomainDid({ + domain, + did, + resolver: resolveTxt, + }) + + return c.json(result, result.verified ? 200 : 422) +}) + +app.get('/v1/identities/:did', async (c) => { const did = c.req.param('did') try { const resp = await fetch(`${DISCOVERY_URL}/v1/identities/${encodeURIComponent(did)}`) @@ -227,8 +4303,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/src/middleware/auth.ts b/services/agentd/src/middleware/auth.ts index 9f92309..617eda5 100644 --- a/services/agentd/src/middleware/auth.ts +++ b/services/agentd/src/middleware/auth.ts @@ -47,10 +47,15 @@ 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' || 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/src/storage.ts b/services/agentd/src/storage.ts index 382991a..d4f2afa 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,38 @@ 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[] + genericAttestations: 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 +660,329 @@ 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) + mirrorLocalDaemonStateTables(db, normalized) + } + + 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'); + `) + 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', + updatedAt, + identities: [], + agentCards: [], + signedAgentCards: [], + agents: [], + dhtPointers: [], + registryRecords: [], + relayRecords: [], + trustResults: [], + reputationRecords: [], + delegationTokens: [], + approvals: [], + approvalDecisions: [], + killSwitchRules: [], + revocationRecords: [], + incidentRecords: [], + genericAttestations: [], + 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 : [], + 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 : [], + } +} + +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 cbcb648..1303e26 100644 --- a/services/agentd/test/routes.test.ts +++ b/services/agentd/test/routes.test.ts @@ -45,14 +45,21 @@ vi.mock('node:dns/promises', () => ({ import { app } from '../src/index.js' import { createDelegationToken, + createDelegationTokenV2, + createIdentityKeyPair, createIncidentRecord, + createInvocationRequest, createRevocationRecord, + hashProtocolPayload, signDelegationToken, + signDelegationTokenV2, signIncidentRecord, + signInvocationRequest, signRevocationRecord, } 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 @@ -110,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({ @@ -122,104 +145,2677 @@ describe('Agentd Service Routes', () => { }), privateKey) } - describe('API Key Authentication', () => { - it('fails closed for mutations in production when API key is not configured', async () => { - process.env.NODE_ENV = 'production' - delete process.env.SERVICE_API_KEY + describe('API Key Authentication', () => { + it('fails closed for mutations 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('/v1/evidence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + actor: TEST_DID, + action: 'test', + payload: {}, + }), + }) + expect(res.status).toBe(503) + const data = await res.json() + 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('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('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('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'] }, + { key: 'kill-key', scopes: ['agentd:killswitch:write'] }, + ]) + + const accepted = await app.request('/v1/evidence', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'evidence-key' }, + body: JSON.stringify({ + actor: TEST_DID, + action: 'scoped-write', + payload: {}, + }), + }) + 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' }, + body: JSON.stringify({ global: true }), + }) + expect(forbidden.status).toBe(403) + expect((await forbidden.json()).error).toContain('agentd:killswitch:write') + }) + + it('fails closed when scoped agentd API keys are malformed', async () => { + delete process.env.SERVICE_API_KEY + process.env.AGENTD_API_KEYS = '{bad-json' + + const res = await app.request('/v1/evidence', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'evidence-key' }, + body: JSON.stringify({ + actor: TEST_DID, + action: 'test', + payload: {}, + }), + }) + + expect(res.status).toBe(503) + expect((await res.json()).error).toContain('AGENTD_API_KEYS must be a JSON array') + }) + }) + + describe('GET /health', () => { + it('returns health status with checks for all upstream services', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('health')) { + return createMockResponse({ status: 'healthy' }) + } + return Promise.reject(new Error('unreachable')) + }) + + const res = await app.request('/health') + const data = await res.json() + + expect(data.service).toBe('agentd') + expect(data.timestamp).toBeDefined() + expect(data.checks).toBeDefined() + expect(data.checks.discovery).toBe('connected') + 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') + }) + + it('returns degraded when upstream services are unreachable', async () => { + mockFetch.mockRejectedValue(new Error('connection refused')) + + const res = await app.request('/health') + const data = await res.json() + + expect(data.status).toBe('degraded') + expect(data.checks.discovery).toBe('unreachable') + expect(data.checks.trustGraph).toBe('unreachable') + expect(data.checks.registry).toBe('unreachable') + expect(data.checks.authorityStore).toBe('ready') + expect(data.checks.localStateStore).toBe('ready') + }) + }) + + 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('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 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', + headers: { 'Content-Type': 'application/json' }, + 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) + 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) + 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'] }], + endpoints: [], + }), + }) + 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) + 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, + 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' }, + body: JSON.stringify({ capability: 'invoice.reconcile' }), + }) + 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, + capability: 'invoice.reconcile', + signed: true, + versionNegotiation: expect.objectContaining({ + compatible: true, + negotiated_version: 'fides.v2.0', + }), + resolution: expect.objectContaining({ + mode: 'local_agent_card', + urlRequired: false, + authorityGranted: false, + }), + }), + ])) + 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' }, + body: JSON.stringify({ capability: 'invoice.reconcile' }), + }) + expect(localDiscovered.status).toBe(200) + expect(await localDiscovered.json()).toMatchObject({ + provider: 'local', + authorityGranted: false, + count: 1, + evidenceRefs: [expect.any(String)], + }) + + 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, + evidenceRefs: [expect.any(String)], + }) + }) + + 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({ + authorityGranted: false, + error: { + code: 'AGENT_CARD_INVALID_SIGNATURE', + category: 'agent_card', + message: 'Identity-bound signed AgentCard is required before registration', + details: { cardId: identity.did }, + }, + }) + }) + + 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('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', '/discover/federation', '/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) + 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', { + 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) + expect(dhtData.evidenceRefs).toEqual([expect.any(String)]) + expect(dhtData.evidence_refs).toEqual(dhtData.evidenceRefs) + }) + + 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, + evidenceRefs: [expect.any(String)], + federationPeerVerified: true, + }) + expect(data.evidence_refs).toEqual(data.evidenceRefs) + 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', + 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('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: principal.did, + 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(true) + expect(data.token).toMatchObject({ + delegator: principal.did, + delegatee: 'did:fides:requester:local', + capabilities: ['invoice.reconcile'], + audience: ['did:fides:invoice-agent'], + }) + 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', + 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', + 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.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) + expect(sessionData.signedSessionVerified).toBe(true) + + const fetched = await app.request(`/sessions/${sessionData.session.session_id}`) + expect(fetched.status).toBe(200) + 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', + 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) + 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') + expect(invocationData.signedResult.proof.verificationMethod).toBe(identity.did) + expect(invocationData.signedResultVerified).toBe(true) + + 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('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', { + 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('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 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', + 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', + 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', + }) + + 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' }, + 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 () => { + 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) + expect(requestData.evidenceRefs).toHaveLength(1) + + 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) + 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 () => { + 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) + expect(enabledData.evidenceRefs).toHaveLength(1) + + 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.error.code).toBe('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) + + 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 () => { + 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') + expect(revocationData.evidenceRefs).toHaveLength(1) + + 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) + 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 () => { + 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') + expect(incidentData.evidenceRefs).toHaveLength(1) + + 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) + 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', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'resolved' }), + }) + 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 () => { + 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) + 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', + 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) + 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) + 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) + 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', + 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') + + 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', + }), + ])) + }) - const res = await app.request('/v1/evidence', { + 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({ - actor: TEST_DID, - action: 'test', - payload: {}, + identity: identity.did, + type: 'github', + handle: 'fides-publisher', }), }) - expect(res.status).toBe(503) - const data = await res.json() - expect(data.error).toContain('SERVICE_API_KEY is required in production') + + 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('enforces scoped agentd API keys when configured', async () => { - process.env.AGENTD_API_KEYS = JSON.stringify([ - { key: 'evidence-key', scopes: ['agentd:evidence:write'] }, - { key: 'kill-key', scopes: ['agentd:killswitch:write'] }, - ]) + 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' }, + }), + }) - const accepted = await app.request('/v1/evidence', { + 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) + 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 () => { + const publish = await app.request('/dht/publish', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-API-Key': 'evidence-key' }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - actor: TEST_DID, - action: 'scoped-write', - payload: {}, + capability: 'invoice.reconcile', + agentId: 'did:fides:agent', + agentCardUrl: 'file://agent-card.json', }), }) - expect(accepted.status).toBe(201) + 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) + const data = await find.json() + expect(data.capability).toBe('invoice.reconcile') + expect(data.pointers).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentId: 'did:fides:agent' }), + ])) - const forbidden = await app.request('/v1/killswitch/engage', { + const postFind = await app.request('/dht/find', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-API-Key': 'evidence-key' }, - body: JSON.stringify({ global: true }), + 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' }), + ])) + + 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: [], + rejectedPointers: expect.arrayContaining([ + expect.objectContaining({ + agentId: 'did:fides:agent', + protocolCompatibility: 'card_unresolved', + reasons: expect.arrayContaining([ + 'provider_record_card_unresolved', + 'discovery_does_not_grant_authority', + ]), + }), + ]), }) - expect(forbidden.status).toBe(403) - expect((await forbidden.json()).error).toContain('agentd:killswitch:write') }) - it('fails closed when scoped agentd API keys are malformed', async () => { - delete process.env.SERVICE_API_KEY - process.env.AGENTD_API_KEYS = '{bad-json' + 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 res = await app.request('/v1/evidence', { + const publish = await app.request('/dht/publish', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-API-Key': 'evidence-key' }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - actor: TEST_DID, - action: 'test', - payload: {}, + capability, + agentId: identity.did, }), }) + 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', + 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 }), + }), + ])) - expect(res.status).toBe(503) - expect((await res.json()).error).toContain('AGENTD_API_KEYS must be a JSON array') + 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) + expect(discovery.evidenceRefs).toEqual([expect.any(String)]) + expect(discovery.evidence_refs).toEqual(discovery.evidenceRefs) }) - }) - describe('GET /health', () => { - it('returns health status with checks for all upstream services', async () => { - mockFetch.mockImplementation((url: string) => { - if (url.includes('health')) { - return createMockResponse({ status: 'healthy' }) - } - return Promise.reject(new Error('unreachable')) + 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 res = await app.request('/health') - const data = await res.json() + 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) - expect(data.service).toBe('agentd') - expect(data.timestamp).toBeDefined() - expect(data.checks).toBeDefined() - expect(data.checks.discovery).toBe('connected') - expect(data.checks.trustGraph).toBe('connected') - expect(data.checks.registry).toBe('connected') - expect(data.checks.authorityStore).toBe('ready') - expect(data.authorityStore.kind).toBe('memory') - expect(data.status).toBe('healthy') + 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) + expect(discovery.evidenceRefs).toEqual([expect.any(String)]) + expect(discovery.evidence_refs).toEqual(discovery.evidenceRefs) }) - it('returns degraded when upstream services are unreachable', async () => { - mockFetch.mockRejectedValue(new Error('connection refused')) + 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 res = await app.request('/health') - const data = await res.json() + 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) + const publishedRegistry = await publish.json() + expect(publishedRegistry.authorityGranted).toBe(false) + 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, + }) - expect(data.status).toBe('degraded') - expect(data.checks.discovery).toBe('unreachable') - expect(data.checks.trustGraph).toBe('unreachable') - expect(data.checks.registry).toBe('unreachable') - expect(data.checks.authorityStore).toBe('ready') + 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.rejectedRecords).toEqual([]) + expect(searchData.records).toEqual(expect.arrayContaining([ + expect.objectContaining({ + agentId: identity.did, + registryIndexVerified: true, + agentCardHash: expect.stringMatching(/^sha256:/), + }), + ])) + + 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) + const discoverRegistryData = await discoverRegistry.json() + expect(discoverRegistryData).toMatchObject({ + provider: 'registry', + authorityGranted: false, + evidenceRefs: [expect.any(String)], + rejectedRecords: [], + records: expect.arrayContaining([ + expect.objectContaining({ + agentId: identity.did, + registryIndexVerified: true, + agentCardHash: expect.stringMatching(/^sha256:/), + }), + ]), + }) + expect(discoverRegistryData.evidence_refs).toEqual(discoverRegistryData.evidenceRefs) + + const index = await app.request('/registry/index') + expect(index.status).toBe(200) + const indexData = await index.json() + expect(indexData.rejectedRecords).toEqual([]) + expect(indexData.records).toEqual(expect.arrayContaining([ + 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' }) + 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' }, + body: JSON.stringify({ agentId: identity.did, endpointHints: ['local://calendar-agent'] }), + }) + 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)}`, + 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', + 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, + agentCardHash: expect.stringMatching(/^sha256:/), + signedAgentCard: true, + }), + ])) + + 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) + const discoverRelayData = await discoverRelay.json() + expect(discoverRelayData).toMatchObject({ + provider: 'relay', + authorityGranted: false, + evidenceRefs: [expect.any(String)], + records: expect.arrayContaining([ + expect.objectContaining({ + agentId: identity.did, + agentCardHash: expect.stringMatching(/^sha256:/), + signedAgentCard: true, + versionNegotiation: expect.objectContaining({ compatible: true }), + }), + ]), + }) + expect(discoverRelayData.evidence_refs).toEqual(discoverRelayData.evidenceRefs) + + 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 () => { + const demo = await app.request('/demo/run', { method: 'POST' }) + 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({ + 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, + 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, + signed: true, + verification: expect.objectContaining({ valid: true }), + }), + ])) + 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) + 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.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') + }) + + 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', + metadata: { rawPrompt: 'also-do-not-export' }, + }), + }) + 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) + const verified = await verify.json() + expect(verified.valid).toBe(true) + expect(verified.count).toBeGreaterThanOrEqual(1) + + 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.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) }) }) @@ -562,6 +3158,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'] } @@ -1186,7 +3813,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 () => { diff --git a/services/agentd/test/storage.test.ts b/services/agentd/test/storage.test.ts index 7b392f9..5a3d0a4 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' @@ -11,12 +12,15 @@ import { InMemoryAuthorityStore, PostgresAuthorityStore, createAuthorityClient, + emptyLocalDaemonStateSnapshot, + SqliteLocalDaemonStateStore, runAuthorityMigrations, } from '../src/storage.js' 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 }))) @@ -139,6 +143,100 @@ 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', + 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' }], + 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' } }], + revocationRecords: [{ id: 'rev-1' }], + incidentRecords: [{ id: 'inc-1' }], + killSwitchRules: [{ id: 'kill-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: [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', + 'trust_results', + 'reputation_records', + 'policy_decisions', + 'approvals', + 'delegations', + '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 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() + } + }) + it('rejects unsafe configured authority schema names', () => { const previousSchema = process.env.AGENTD_DB_SCHEMA 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/discovery/src/routes/agents.ts b/services/discovery/src/routes/agents.ts index d25f997..351a160 100644 --- a/services/discovery/src/routes/agents.ts +++ b/services/discovery/src/routes/agents.ts @@ -25,9 +25,21 @@ 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', + ], } } +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 +102,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 +121,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 || {}, @@ -206,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) } @@ -226,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/src/types.ts b/services/discovery/src/types.ts index c08b5f2..fce474a 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,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 1adfc08..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 () => { @@ -279,6 +284,185 @@ 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)}`, + 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') + }) + }) + + 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('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') 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/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/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/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 () => { 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/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({ 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:*", diff --git a/tests/e2e/agentd-openapi-contract.test.ts b/tests/e2e/agentd-openapi-contract.test.ts index f67f914..ad030a9 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) @@ -111,6 +113,192 @@ 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 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 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('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('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('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('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('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') + 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('/')) + .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', @@ -143,6 +331,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 @@ -213,3 +419,48 @@ 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 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 + propertyLines.push(line) + } + return propertyLines.join('\n') +} + +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') +}