agentavow-trust
v0.4.1
Published
AgentAvow for JavaScript: a framework-agnostic gate that decides safe / review / do_not_connect for any tool, MCP server or package from AgentAvow's signed scan results (Ed25519 JWS verified offline), with adapters for Flue, the Vercel AI SDK, the Claude
Maintainers
Readme
agentavow-trust
Formerly
agentgraph-trust. This package is now published asagentavow-trust.
JavaScript/TypeScript SDK for AgentAvow: a signed, recomputable trust score (0 to 100) and an adoption score for any tool, MCP server, package or repo an AI agent connects to.
What is in the box:
agentavow-trust/gate: the framework-agnostic gate. One policy object in, asafe/review/do_not_connectdecision out, from AgentAvow's signed scan result (the EdDSA JWS is verified against the public JWKS, on WebCrypto, so it runs on Node 18+, Bun, Deno and Cloudflare Workers). Catches a tool whose definition changed after it was scanned.agentavow-trust/flue: the gate wired into the Flue agent framework (@flue/runtime2.x): refuse a server at connect time, deny a drifted tool call at run time.agentavow-trust/vercel-ai: the same gate for the Vercel AI SDK (wrapTools).agentavow-trust/claude-agent-sdk: the gate for the Claude Agent SDK: checkmcpServersbefore the session, and aPreToolUsehook that denies eachmcp__…call that fails (0.4.0).agentavow-trust/openai-agents: the gate for the OpenAI Agents SDK: wrap an MCP server soconnect,listToolsandcallToolare checked (0.4.0).agentavow-trust(root) and/verify: Trust Score v2 signed envelope verification, the JS peer of the Pythonagentavow-sdkverify module; both reproduce the server's JCS-canonical, Ed25519-over-SHA-256 check byte-for-byte (this part usesnode:crypto).
One runtime dependency, canonicalize
(RFC 8785 JCS, byte-matches Python's rfc8785). Node >= 18.
Install
npm install agentavow-trustCLI — a signed score from the terminal, no install
The package ships an agentavow-trust bin, so npx runs it without installing:
npx agentavow-trust scan modelcontextprotocol/servers # GitHub repo
npx agentavow-trust scan npm:chalk # npm / pypi / crates / docker / hf
npx agentavow-trust badge you/your-repo # prints the README badge linescan prints the 0–100 trust score, trust tier, finding counts, the report
link, and whether the result carries a signed attestation. badge prints the
markdown for a live badge that links to the full report. Both hit the free
public API (AGENTAVOW_API overrides the base, default
https://agentavow.com/api/v1).
Trust Score v2 — signed, self-verifiable envelopes
Every v2 trust score is a signed envelope you can verify without trusting our server — fetch it, then check the Ed25519 signature against our published JWKS yourself.
import { TrustClient } from 'agentavow-trust';
const client = new TrustClient('https://agentavow.com');
const did = 'did:web:agentgraph.co:agents:<id>';
// Signed envelope: score + per-source methodology breakdown + proof
const env = await client.getAggregate(did);
console.log(env.trust_score, env.contributions.map((c) => c.source));
// Verify it client-side (fetches JWKS, checks signature + freshness)
const result = await client.verify(did);
if (result.valid) { // true iff signature valid AND fresh
console.log('verified:', result.kid);
} else {
console.log('NOT verified:', result.reason);
}
// Scan any GitHub repo -> trust score + findings + a verifiable envelope
const scan = await client.checkRepo('owner', 'repo');
if (scan.trust_envelope) {
console.log(await client.verifyEnvelope(scan.trust_envelope));
}The signer identity (
did:web:agentgraph.co) and the JWKS host intentionally keep theagentgraph.coname so existing attestations stay verifiable; the service and site are AgentAvow atagentavow.com.
Standalone verification (no client)
verifyEnvelope(envelope, jwks, { now }) only needs canonicalize plus
node:crypto, reproducing the server's JCS-canonical, Ed25519-over-SHA-256
check byte-for-byte.
import { verifyEnvelope } from 'agentavow-trust';
const result = verifyEnvelope(envelope, jwks);
// => { valid, signatureValid, fresh, kid, reason }It (1) strips the top-level proof key, (2) JCS-canonicalizes the rest,
(3) SHA-256s it, (4) reads kid from the detached JWS header, (5) finds the
matching { kty: 'OKP', crv: 'Ed25519', x } key in the JWKS, (6) verifies the
Ed25519 signature over the digest, then (7) checks
computed_at + freshness_ttl_seconds >= now. valid is
signatureValid && fresh.
The gate: agentavow-trust/gate
New in 0.3.0.
import { createGate } from 'agentavow-trust/gate'
const gate = createGate({
allowFloor: 51, // trust score that is safe without review (81 = strict)
onReview: 'block', // what a review decision does: block | warn | confirm
onDrift: 'block', // a tool definition that changed since it was scanned
onApiError: 'block', // AgentAvow unreachable or the attestation did not verify
})
const d = await gate.check('https://mcp.deepwiki.com/mcp') // or 'npm:chalk', 'owner/repo'
d.decision // 'safe' | 'review' | 'do_not_connect'
d.allowed // the bottom line after the policy switches
d.score // 74 (the signed trust score)
d.certified // the Certified mark, its own field (a reader can show "Safe to connect · Certified")
d.reason // one sentence with the report link
d.attestation // { jws, kid, verified: true, payload }
// Per tool call: an in-memory compare of the definition the agent was served
// against the per-tool digest signed into the attestation.
const c = await gate.checkToolCall({ server, toolName: 'ask_wiki_question', servedDefinition })
if (!c.allowed) throw new Error(c.reason)How it decides, in order:
- AgentAvow could not answer, or the attestation did not verify:
onApiError(defaultblock, fail closed). - No grade for the target:
onUnscanned(defaultblock). - A
blockOntrigger fired:do_not_connect. Defaults: a critical finding, a sandbox canary credential exfiltrated, a known-malicious dependency, the blocked tier (score 0 to 10). Add'high'to block on high findings too. - The score is under
allowFloor, a high finding is on the result, or the analysis is older thanmaxStaleMs(default 30 days):review.onReviewsays what that means:block,warn(runs,onWarnis called), orconfirm(yourconfirm(decision)hook decides, once per server and reason per cache period, per tool for drift; without a hook,confirmblocks). - Otherwise
safe.
The Certified mark is never one of the three values and never changes them: it
rides along as decision.certified (from the API's certified field) so a
reader can show it next to the decision.
Then, for a tool call, the drift step: the served definition's digest
(sha256 over JCS of {profile, tool}, profile agentavow.mcp-tool-definition.v1)
must equal the digest in the signed tool_digests map, or in your pins for
that server if you set any. A mismatch, or a tool the grade never saw, follows
onDrift (block | review | allow).
The policy is one JSON-able object:
| Field | Default | Meaning |
|-------|---------|---------|
| allowFloor | 51 | Lowest score that is safe without review. 81 is strict. |
| blockOn | ['critical', 'sandbox_canary', 'malicious_dependency', 'blocked_tier'] | Hard stops, regardless of score. 'high' is available. |
| onReview | 'block' | block / warn / confirm. |
| onDrift | 'block' | block / review / allow for a changed or unknown tool definition. |
| onUnscanned | 'block' | block / review / allow when there is no grade. |
| onApiError | 'block' | block (fail closed) / allow (run with a warning). Headless agents should keep the default. |
| maxStaleMs | 30 days | Older analysis is review. |
| cacheTtlMs | 1 hour | In-memory grade cache; a stale entry is served while one refresh runs. |
| pins | {} | { server: { toolName: 'sha256:…' } }: expected digests you reviewed; they replace the signed map for that server. |
| verifySignature | true | Verify the JWS and decide on signed fields only. See below. |
| unmapped | 'allow' | A tool that maps to no server (your own function). |
| baseUrl, jwksUrl, timeoutMs, headers | AgentAvow's public API and JWKS | Endpoints and transport. |
Hooks (not JSON): fetch (injected for tests and proxies), confirm,
onWarn, jwks (an inline JWKS for air-gapped verification).
Signatures. Every scan response carries jws, key_id and jwks_url. With
verifySignature: true the gate verifies the EdDSA (Ed25519) JWS against the
JWKS at https://agentgraph.co/.well-known/jwks.json (the key is pinned by
kid; the JWKS is cached for an hour and refetched once on an unknown kid),
checks the subject is the coordinate you asked about and the attestation has not
expired, and then reads the score, tier, findings, tool digests and supply-chain
block from the signed payload. Fields that are not signed (the sandbox block,
a decision label) can only make the decision stricter, never looser. A
response whose signature does not verify is an API error (onApiError).
With verifySignature: false the unsigned JSON is trusted as served over https,
the same trust you place in the API itself; use it only where WebCrypto Ed25519
is unavailable.
The hot path never scans. check reads the cache (the API's own result is
cached an hour server-side too), and checkToolCall is an in-memory digest
compare. gate.observeTools(server, tools) records a tools/list so later
checkToolCalls can omit servedDefinition; gate.pin(server, tools) records
the digests you reviewed.
Also exported: deriveDecision (the three-phrase rule, pure), applyDrift,
resolvePolicy, GradeClient, verifyGrade, toolDigest / toolKey /
digestMap, parseCoordinate / subjectId, captureRpc / readRpc (JSON-RPC
replies, plain or SSE-framed), GateError. agentavow-trust/jws exports the
bare verifyJws(jws, jwks, { expectKid }) and JwksCache.
Flue: agentavow-trust/flue
For agents built on Flue (@flue/runtime 2.x,
remote MCP servers). Two seams, both verified against the runtime source at
2.2.2:
// src/gate.ts
import { createFlueGate } from 'agentavow-trust/flue'
export const gate = createFlueGate({
allowFloor: 51,
onReview: 'block',
onDrift: 'block',
onApiError: 'block',
})// src/app.ts (module scope, once)
import { instrument } from '@flue/runtime'
import { gate } from './gate.ts'
instrument(gate.instrumentation()) // every mcp__<server>__<tool> call is checked first// src/agents/assistant.ts
'use agent'
import { useMcpConnection, useModel } from '@flue/runtime'
import { gate } from '../gate.ts'
export function Assistant() {
useModel('anthropic/claude-sonnet-4-6')
useMcpConnection(gate.connection({
name: 'deepwiki',
url: 'https://mcp.deepwiki.com/mcp',
optional: true, // do_not_connect mounts zero tools and tells the model why
}))
return 'Answer questions about public repositories.'
}Seam (a), gate.connection(def): returns the same definition with its
fetch wrapped (a field Flue's definition validator accepts, so it works with
useMcpConnection and defineMcpConnection unchanged). Flue hands that fetch
to the MCP transport, which POSTs every JSON-RPC message through it. On
initialize the wrapper fetches the server's grade and, on do_not_connect,
throws: the connect fails, and with optional: true Flue mounts no tools from
that server and announces the reason to the model as a resources signal.
Without optional, the submission fails with that reason. On tools/list the
wrapper reads the reply (plain JSON or SSE-framed), records each served
definition, checks it against the signed or pinned digest, and refuses the
connection when a tool drifted or is unknown under onDrift: 'block'. The
transport receives the same bytes. With tools: [...] on the definition, only
the mounted tools are judged.
Seam (b), gate.instrumentation(): a FlueInstrumentation for
instrument(...). Its interceptor runs around every tool execution; for an
mcp__<server>__<tool> call it runs checkToolCall (an in-memory compare
against what tools/list served) and, when the call is not allowed, throws
instead of calling next(). Flue turns a tool's rejection into an error tool
outcome the model sees, and the conversation continues. This is the seam that
catches a server that redefines a tool after it connected: pin the digests you
reviewed with gate.pin(server, tools) or a pins policy entry and the next
call to a changed tool is denied.
Notes:
- Nothing runs at module scope; the grade is fetched inside the connect, which
Flue performs in request context, so the Cloudflare Workers target works
(the gate needs only
fetchand WebCrypto). onReview: 'confirm'needs yourconfirmhook; Flue has no built-in approval pause, so without oneconfirmblocks.- A server you proxy or grade under a package coordinate:
coordinates: { deepwiki: 'npm:@scope/server' }. - An
mcp__tool from a connection you did not wrap withgate.connectionis not gated (unmapped: 'block'to refuse it). - Legacy
transport: 'sse': replies arrive on the GET stream, so the connect cannot be refused on drift; the listing is still recorded and the per-call check applies. gate.lookup('mcp__deepwiki__ask_wiki_question')gives the server and original tool name behind an adapted name.
Claude Agent SDK: agentavow-trust/claude-agent-sdk
import { query } from '@anthropic-ai/claude-agent-sdk'
import { createClaudeAgentGate } from 'agentavow-trust/claude-agent-sdk'
const gate = createClaudeAgentGate({ onReview: 'confirm' })
const { servers, disallowedTools, refused } = await gate.mcpServers({
deepwiki: { type: 'http', url: 'https://mcp.deepwiki.com/mcp' },
})
for await (const m of query({ prompt, options: { mcpServers: servers, disallowedTools, hooks: gate.hooks() } })) { /* … */ }mcpServers(config)grades each server (its https URL, orcoordinates: { name: 'npm:…' }) and returns only the ones that pass. For an https server it also reads the server'stools/listand puts any tool whose definition drifted from the signed digest intodisallowedTools(mcp__<server>__<tool>).hooks()is aPreToolUsehook onmcp__.*. A call that fails returnspermissionDecision: 'deny'with the reason (the model gets it as the tool result); it holds in every permission mode, includingbypassPermissions. Review underonReview: 'confirm'with noconfirmhook returns'ask', the SDK's own approval flow.canUseToolgives the same decision in thecanUseToolshape, for code that already routes approvals through it (auto-approved tools never reach it, so prefer the hook).- Non-
mcp__tools (Bash, Edit, your own tools) are not gated; anmcp__tool from a server the gate wasn't given followsunmapped.
OpenAI Agents SDK: agentavow-trust/openai-agents
import { Agent, MCPServerStreamableHttp } from '@openai/agents'
import { createOpenAIAgentsGate } from 'agentavow-trust/openai-agents'
const gate = createOpenAIAgentsGate({ onReview: 'block' })
const { servers, refused } = await gate.connectAll([
new MCPServerStreamableHttp({ name: 'deepwiki', url: 'https://mcp.deepwiki.com/mcp' }),
])
const agent = new Agent({ name: 'Assistant', mcpServers: servers })wrap(server, coordinate?)returns the same server withconnect,listToolsandcallToolwrapped.connect()throws aGateErrorwhen the answer fails;listTools()drops a tool whose definition drifted;callTool()throws aGateErrorfor a call that fails, which the SDK'serrorFunctionturns into model-visible text.connectAll(servers)connects the servers that pass and returns the rest asrefused.- A stdio server needs a coordinate:
gate.wrap(server, 'npm:@scope/server')orcoordinates: { name: '…' }. onReview: 'confirm'needs yourconfirmhook (MCP server tools have no SDK approval pause).
Vercel AI SDK tool gate — agentavow-trust/vercel-ai
Shipped in 0.2.2. From 0.3.1 it is a thin adapter over
agentavow-trust/gate, the same core as the Flue adapter: one policy object, the three-phrase decision, signature verification on by default, and drift blocked by default.
wrapTools(tools, policy) returns the same AI SDK ToolSet with each tool's
execute wrapped. Before a tool runs, the gate reads the serving MCP server's
signed result from AgentAvow's free API (cached), verifies the EdDSA
attestation against AgentAvow's JWKS, and runs checkToolCall: the decision
(safe / review / do_not_connect, see "How it decides" above) plus the
drift check of the definition this agent was handed against the per-tool digest
signed into the attestation. A server that quietly changes a tool's definition
after it was scanned is blocked.
import { createMCPClient } from '@ai-sdk/mcp';
import { generateText } from 'ai';
import { wrapTools } from 'agentavow-trust/vercel-ai';
const SERVER = 'https://mcp.deepwiki.com/mcp';
const mcp = await createMCPClient({ transport: { type: 'http', url: SERVER } });
const listing = await mcp.listTools();
const tools = wrapTools(mcp.toolsFromDefinitions(listing), {
server: SERVER, // one server for the whole set
servedTools: listing, // the exact tools/list, for the drift check (optional; see below)
allowFloor: 51, // trust score that is safe without review (81 is strict)
onReview: 'confirm', // block | warn | confirm (the AI SDK's needsApproval)
onDrift: 'block', // a definition that changed since it was scanned
});
await generateText({ model, tools, prompt: '...' });What a call does:
- Safe to connect (or
allowedafter the policy switches): the tool runs. - Do not connect: the tool is not run. Its result is
{ error, agentavow }:erroris one line for the model that leads with the phrase ("Do not connect — 'send_email' was not run. AgentAvow: the definition of …", with· Certifiedafter the phrase when the tool carries the mark), andagentavowis theDecision(decision,allowed,outcome,score,tier,certified,reportUrl,servedDigest,signedDigest,attestation) without the raw grade and attestation payload. Nothing throws unless you setonBlock: 'throw'(aGateErrorwhosedecisionis the same object). - Review before you connect: per
onReview.block(default) as above;warnruns and callsonWarn;confirmsets the tool'sneedsApproval, sogenerateText,streamTextandToolLoopAgentpause for the user and an approved call runs.do_not_connectis never put to the user. If you pass aconfirm(decision)hook, the hook decides instead ofneedsApproval.
The served definition for the drift check comes from, in order: servedTools
(the tools/list you built the tools from; exact), then the wrapped tool itself
(its description and JSON input schema are what the model is shown;
@ai-sdk/mcp normalises a definition when it builds a tool, and the gate tries
the few definitions that normalise to what the tool carries), then the server's
own tools/list (fetchServed, default on), used only when it shows the model
the same thing the wrapped tool does. A server that serves the gate one
definition and the agent another is drift. If a tool declares an outputSchema
or other fields the AI SDK drops and the server cannot be fetched, pass
servedTools, or the call is reported as drift (fail closed).
| Option | Default | Meaning |
|--------|---------|---------|
| server / toolToServer / resolveServer | — | Map a tool to its server coordinate (https://… MCP URL, owner/repo, npm:name, …). An unmapped tool is not gated (unmapped: 'block' to refuse it). |
| every createGate policy field | see the gate table | allowFloor (51), blockOn, onReview, onDrift, onUnscanned, onApiError, maxStaleMs, pins, verifySignature (true), cacheTtlMs, baseUrl, jwksUrl, … |
| servedTools | — | A tools/list (array or listTools() result) for every server, a map keyed by coordinate, or (server) => listing. |
| fetchServed | true | Fetch the server's tools/list when the wrapped tool alone cannot decide the drift check. |
| serverHeaders | — | Per-server headers for that fetch. |
| onBlock | 'output' | output returns { error, agentavow }; throw throws GateError. |
| hooks | — | onWarn, confirm, fetch, jwks. |
createVercelGate(policy) gives the gate itself (decide(toolName, tool?),
wrap(tools), plus everything on createGate's gate). Also exported:
blockedOutput, gateMessage, headline, toolDigest / toolKey,
parseCoordinate, GateError.
Upgrading from 0.2.x
0.2.x options still work as deprecated aliases (a 0.3.1 name wins when both are given).
0.3.0 shipped wrapTools still on the 0.2.x evaluator; upgrade to 0.3.1 for the behaviour below:
| 0.2.x | 0.3.1 |
|-------|-------|
| minScore: n | allowFloor: n |
| onFail: 'block' | onReview: 'block' |
| onFail: 'warn' | onReview: 'warn' |
| onFail: 'confirm' | onReview: 'confirm' (sets needsApproval) |
| onFail: 'throw' | onReview: 'block' + onBlock: 'throw' |
| failClosed: true / false | onApiError: 'block' / 'allow' |
| blockOn: ['critical', 'high', 'medium'] | same; 'medium' / 'low' still block, the rest are gate triggers |
What changes for a 0.2.x caller:
- The default floor is 51, not 81. A tool graded 51 to 80 now runs. Pass
allowFloor: 81(or keepminScore: 81) for the old floor. - A high finding is review, not a block, unless
blockOnincludes'high'(the 0.2.x defaultblockOndid; passing it explicitly keeps that). onFail/onReviewonly soften review. A do-not-connect result (a critical finding, a known-malicious dependency, the blocked tier, drift underonDrift: 'block') is blocked whateveronReviewsays. UseonDriftto soften drift.- The signature is verified by default. A response whose attestation does
not verify is an API error (
onApiError, default block).verifySignature: falserestores the 0.2.x behaviour. - The blocked output's
agentavowis the fullDecision: the 0.2.x keys (outcome,server,score,tier,reportUrl,servedDigest,signedDigest) are still there, plusdecision,allowed,certified. Messages lead with the phrase instead of "AgentAvow blocked …". onFail: 'throw'throwsGateError(err.decisionis aDecision), notToolGateError.TrustGateis deprecated (usecreateVercelGate); itscheck/decidereturn aDecision(allowed) instead of aGateDecision(allow).evaluate,GateDecisionandToolGateErrorare still exported, deprecated.
API
new TrustClient(baseUrl, { apiKey?, token?, timeout? })
| Method | Description |
|--------|-------------|
| getAggregate(did) | Signed v2 envelope for a subject DID |
| getContributions(did) | Just the methodology breakdown |
| checkRepo(owner, repo) | Scan a GitHub repo -> trust score + findings + envelope |
| getJwks() | Issuer JWKS from <baseUrl>/.well-known/jwks.json |
| verifyEnvelope(env, { now? }) | Fetch JWKS + verify an envelope client-side |
| verify(did, { now? }) | getAggregate + verifyEnvelope in one call |
API base is <baseUrl>/api/v1; the JWKS is served outside that prefix.
verifyEnvelope(envelope, jwks, { now? })
Returns { valid, signatureValid, fresh, kid, reason }. reason is one of:
ok, missing or unsupported proof, malformed jws,
no matching key in JWKS, signature invalid, envelope expired (stale).
Also exported: envelopeDigest(envelope) (raw 32-byte SHA-256 of the
JCS-canonical, proof-stripped envelope), isFresh(envelope, { now? }),
PROOF_TYPE.
Verification / tests
npm install
npm run build # tsc -> dist/ (the gate, flue, jws, vercel-ai, claude-agent-sdk and openai-agents entry points)
npm test # builds first (pretest), then node --test against dist/The gate tests use no network: the API, the JWKS and the MCP server are a fake
fetch, and signatures come from an Ed25519 key generated per run. To also run
the Flue adapter against a real @flue/runtime install, point
FLUE_RUNTIME_DIR at a directory whose node_modules holds it
(FLUE_RUNTIME_DIR=/tmp/flue-install npm test).
The v2 envelope test validates against production: it fetches the real JWKS and a
real signed aggregate from the agentgraph.co signing host and asserts the JS
verifier accepts it (valid === true, kid === 'trust-v2-2026'). A passing
live check proves byte-compatible JCS canonicalization + Ed25519 with the
Python/server side. If prod is unreachable it falls back to a pinned fixture
captured from prod.
Spec
docs/standards/trust-score-envelope-v2.0.md
(§6 verification). MIT licensed.
